Q26: knowledge has scope, expiry, and supersession

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 05:47:03 -04:00
1 parent 8c70111c93
commit 18128b5ad4
14 files changed
+252 -56

No files matched your search

@@ -0,0 +1,9 @@
export const knowledgeScopeSchema = `
alter table knowledge_entries add column scope_type text not null default 'organization';
alter table knowledge_entries add column scope_id text;
alter table knowledge_entries add column valid_until text;
alter table knowledge_entries add column superseded_by text;
insert into schema_migrations(version, name, applied_at)
values (63, 'knowledge_scope', datetime('now'));
`;
+2
View File
@@ -61,6 +61,7 @@ import { taskHandoffSchema } from "./migrations/059-task-handoff";
import { chatWakeTextPurgeSchema } from "./migrations/060-chat-wake-text-purge";
import { taskRunContextSchema } from "./migrations/061-task-run-context";
import { providerContextWindowSchema } from "./migrations/062-provider-context-window";
import { knowledgeScopeSchema } from "./migrations/063-knowledge-scope";
interface Migration {
version: number;
@@ -131,6 +132,7 @@ const MIGRATIONS: Migration[] = [
{ version: 60, name: "chat_wake_text_purge", sql: chatWakeTextPurgeSchema },
{ version: 61, name: "task_run_context", sql: taskRunContextSchema },
{ version: 62, name: "provider_context_window", sql: providerContextWindowSchema },
{ version: 63, name: "knowledge_scope", sql: knowledgeScopeSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
+40 -12
View File
@@ -1,7 +1,7 @@
import type { DatabaseSync } from "node:sqlite";
import { optionalEnum, optionalLimit, requireString } from "./agent-tool-args";
import { optionalEnum, optionalLimit, optionalText, requireString } from "./agent-tool-args";
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
import { KnowledgeService } from "./knowledge-service";
import { APPLICABLE_KNOWLEDGE_SQL, applicableKnowledgeArgs, KnowledgeService, type KnowledgeReader } from "./knowledge-service";
import type { JsonRecord } from "./provider-http";
import type { ToolDefinition } from "./provider-tool-turn";
import { relevantItems } from "./relevance";
@@ -9,6 +9,7 @@ import { relevantItems } from "./relevance";
const PROMPT_ENTRY_LIMIT = 5;
const PROMPT_ENTRY_CHARS = 600;
const IMPORTANCE = ["low", "normal", "high"] as const;
const SCOPES = ["organization", "project", "goal", "task", "agent"] as const;
interface KnowledgeRow {
id: string;
@@ -16,6 +17,8 @@ interface KnowledgeRow {
category: string;
content: string;
importance: string;
scope_type: string;
valid_until: string | null;
updated_at: string;
}
@@ -35,7 +38,7 @@ export const KNOWLEDGE_TOOLS: ToolDefinition[] = [
},
{
name: "record_knowledge",
description: "Record something the company should remember: a decision, a preference, a verified fact, or why something failed.",
description: "Record something the company should remember: a decision, a preference, a verified fact, or why something failed. Scope it to where it applies; pass supersedes to replace an outdated entry.",
parameters: {
type: "object",
additionalProperties: false,
@@ -44,6 +47,9 @@ export const KNOWLEDGE_TOOLS: ToolDefinition[] = [
content: { type: "string", description: "The knowledge itself, self-contained." },
category: { type: "string", description: "e.g. decision, preference, fact, lesson." },
importance: { type: "string", enum: ["low", "normal", "high"] },
scope: { type: "string", enum: [...SCOPES], description: "Where it applies: the whole company (default), this task's project, goal, or task, or only you (agent)." },
valid_until: { type: "string", description: "UTC ISO time after which it no longer applies." },
supersedes: { type: "string", description: "id of an older entry this one replaces." },
},
required: ["topic", "content", "category"],
},
@@ -53,13 +59,14 @@ export const KNOWLEDGE_TOOLS: ToolDefinition[] = [
export function searchKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const query = requireString(args.query, "query");
const limit = optionalLimit(args.limit, "limit", 10, 30);
const found = relevantItems(knowledgeRows(deps.db, ctx.orgId), describeEntry, query, limit);
const found = relevantItems(knowledgeRows(deps.db, readerOf(ctx)), describeEntry, query, limit);
if (found.length === 0) return `No knowledge matches "${query}".`;
return found.map(formatEntry).join("\n\n");
}
export function recordKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const importance = optionalEnum(args.importance, "importance", IMPORTANCE, "normal");
const scope = optionalEnum(args.scope, "scope", SCOPES, "organization") as (typeof SCOPES)[number];
const id = new KnowledgeService(deps.db).create({
orgId: ctx.orgId,
topic: requireString(args.topic, "topic"),
@@ -68,26 +75,47 @@ export function recordKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext,
importance,
sourceType: "agent",
sourceId: ctx.agent.id,
scopeType: scope,
scopeId: scopeIdFor(ctx, scope),
validUntil: optionalText(args.valid_until, "valid_until"),
supersedesId: optionalText(args.supersedes, "supersedes"),
});
return `Recorded knowledge entry ${id}.`;
}
/** Prompt lines for the entries most relevant to a task. */
export function relevantKnowledgeLines(db: DatabaseSync, orgId: string, taskText: string): string[] {
const found = relevantItems(knowledgeRows(db, orgId), describeEntry, taskText, PROMPT_ENTRY_LIMIT);
export function relevantKnowledgeLines(db: DatabaseSync, reader: KnowledgeReader, taskText: string): string[] {
const found = relevantItems(knowledgeRows(db, reader), describeEntry, taskText, PROMPT_ENTRY_LIMIT);
if (found.length === 0) return [];
return [
"",
"Company knowledge that may apply (search_knowledge for more):",
...found.map((entry) => `- ${entry.topic} [${entry.category}, ${entry.importance}, id=${entry.id}]: ${clip(entry.content)}`),
...found.map((entry) => `- ${entry.topic} [${labels(entry)}]: ${clip(entry.content)}`),
];
}
function knowledgeRows(db: DatabaseSync, orgId: string): KnowledgeRow[] {
function knowledgeRows(db: DatabaseSync, reader: KnowledgeReader): KnowledgeRow[] {
return db.prepare(`
select id, topic, category, content, importance, updated_at from knowledge_entries
where org_id = ? order by updated_at desc
`).all(orgId) as unknown as KnowledgeRow[];
select id, topic, category, content, importance, scope_type, valid_until, updated_at from knowledge_entries
where ${APPLICABLE_KNOWLEDGE_SQL} order by updated_at desc
`).all(...applicableKnowledgeArgs(reader)) as unknown as KnowledgeRow[];
}
function readerOf(ctx: AgentToolContext): KnowledgeReader {
return { orgId: ctx.orgId, agentId: ctx.agent.id, projectId: ctx.taskProjectId, goalId: ctx.taskGoalId, taskId: ctx.taskId };
}
function scopeIdFor(ctx: AgentToolContext, scope: (typeof SCOPES)[number]): string | null {
if (scope === "organization") return null;
if (scope === "agent") return ctx.agent.id;
const id = scope === "project" ? ctx.taskProjectId : scope === "goal" ? ctx.taskGoalId : ctx.taskId;
if (!id) throw new Error(`This run has no ${scope} to scope knowledge to.`);
return id;
}
function labels(entry: KnowledgeRow): string {
const validity = entry.valid_until ? `, valid until ${entry.valid_until}` : "";
return `${entry.category}, ${entry.importance}, ${entry.scope_type}${validity}, id=${entry.id}`;
}
function describeEntry(entry: KnowledgeRow): string {
@@ -95,7 +123,7 @@ function describeEntry(entry: KnowledgeRow): string {
}
function formatEntry(entry: KnowledgeRow): string {
return `${entry.topic} [${entry.category}, ${entry.importance}, id=${entry.id}, updated ${entry.updated_at}]\n${entry.content}`;
return `${entry.topic} [${labels(entry)}, updated ${entry.updated_at}]\n${entry.content}`;
}
function clip(text: string): string {
+3 -5
View File
@@ -1,5 +1,5 @@
import { attachmentNote } from "./agent-attachment-tools";
import { optionalLimit, optionalString } from "./agent-tool-args";
import { optionalLimit, optionalString, utcTimestamp } from "./agent-tool-args";
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
import type { JsonRecord } from "./provider-http";
@@ -124,10 +124,8 @@ function beforeCursor(deps: AgentToolDeps, orgId: string, text: string | null):
if (!row) throw new Error(`No message with id "${text}".`);
return { createdAt: row.created_at, rowid: row.row_id };
}
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{3})?Z$/.exec(text);
const normalized = match ? `${match[1]}${match[2] ?? ".000"}Z` : "";
const time = new Date(normalized);
if (!match || Number.isNaN(time.getTime()) || time.toISOString() !== normalized) {
const normalized = utcTimestamp(text);
if (!normalized) {
throw new Error(`"before" must be a message_id from a previous page or a UTC ISO timestamp like 2026-01-31T09:00:00Z, got "${text}".`);
}
return { createdAt: normalized, rowid: -1 };
+15
View File
@@ -30,3 +30,18 @@ export function optionalEnum(value: unknown, field: string, allowed: readonly st
if (typeof value !== "string" || !allowed.includes(value)) throw new Error(`"${field}" must be one of: ${allowed.join(", ")}.`);
return value;
}
/** The text as a normalized UTC ISO timestamp, or undefined when it is not one. */
export function utcTimestamp(text: string): string | undefined {
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{3})?Z$/.exec(text);
if (!match) return undefined;
const normalized = `${match[1]}${match[2] ?? ".000"}Z`;
const time = new Date(normalized);
return !Number.isNaN(time.getTime()) && time.toISOString() === normalized ? normalized : undefined;
}
/** Omitted means null; anything supplied must be a non-blank string. */
export function optionalText(value: unknown, field: string): string | null {
if (value === undefined) return null;
return requireString(value, field);
}
+61 -5
View File
@@ -1,8 +1,38 @@
import type { DatabaseSync } from "node:sqlite";
import type { CreateKnowledgeEntryInput, DeleteKnowledgeEntryInput } from "../../shared/commands";
import type { CreateKnowledgeEntryInput, DeleteKnowledgeEntryInput, KnowledgeScopeType } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { utcTimestamp } from "./agent-tool-args";
import { AuditService } from "./audit-service";
const SCOPE_TABLES: Record<Exclude<KnowledgeScopeType, "organization">, string> = {
project: "projects",
goal: "goals",
task: "tasks",
agent: "agents",
};
/** Where a reader stands: the scopes whose knowledge applies to them. */
export interface KnowledgeReader {
orgId: string;
agentId: string | null;
projectId: string | null;
goalId: string | null;
taskId: string | null;
}
/** Active, unexpired entries whose scope covers the reader. */
export const APPLICABLE_KNOWLEDGE_SQL = `
org_id = ? and superseded_by is null and (valid_until is null or valid_until > ?)
and (scope_type = 'organization'
or (scope_type = 'project' and scope_id = ?)
or (scope_type = 'goal' and scope_id = ?)
or (scope_type = 'task' and scope_id = ?)
or (scope_type = 'agent' and scope_id = ?))`;
export function applicableKnowledgeArgs(reader: KnowledgeReader): Array<string | null> {
return [reader.orgId, new Date().toISOString(), reader.projectId, reader.goalId, reader.taskId, reader.agentId];
}
export class KnowledgeService {
private readonly audit: AuditService;
@@ -11,12 +41,19 @@ export class KnowledgeService {
}
create(input: CreateKnowledgeEntryInput): string {
this.assertScope(input.orgId, input.scopeType, input.scopeId);
const validUntil = input.validUntil === null ? null : utcTimestamp(input.validUntil);
if (validUntil === undefined) throw new Error(`"valid_until" must be a UTC ISO timestamp like 2026-12-31T00:00:00Z, got "${input.validUntil}".`);
const id = createId("kne");
const now = new Date().toISOString();
if (input.supersedesId) this.assertSupersedable(input.orgId, input.supersedesId);
this.db.prepare(`
insert into knowledge_entries(id, org_id, topic, category, content, importance, source_type, source_id, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.topic.trim(), input.category, input.content, input.importance, input.sourceType, input.sourceId, now, now);
insert into knowledge_entries(id, org_id, topic, category, content, importance, source_type, source_id, scope_type, scope_id, valid_until, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.topic.trim(), input.category, input.content, input.importance, input.sourceType, input.sourceId, input.scopeType, input.scopeId, validUntil, now, now);
if (input.supersedesId) {
this.db.prepare("update knowledge_entries set superseded_by = ?, updated_at = ? where id = ?").run(id, now, input.supersedesId);
}
this.audit.record({
orgId: input.orgId,
entityType: "knowledge_entry",
@@ -24,7 +61,7 @@ export class KnowledgeService {
actorType: "system",
actorId: null,
action: "created",
payload: { topic: input.topic.trim(), category: input.category },
payload: { topic: input.topic.trim(), category: input.category, scopeType: input.scopeType, scopeId: input.scopeId, validUntil, supersedes: input.supersedesId },
});
return id;
}
@@ -33,4 +70,23 @@ export class KnowledgeService {
const result = this.db.prepare("delete from knowledge_entries where id = ? and org_id = ?").run(input.entryId, input.orgId);
if (result.changes === 0) throw new Error("Knowledge entry was not found.");
}
private assertScope(orgId: string, scopeType: KnowledgeScopeType, scopeId: string | null): void {
if (scopeType === "organization") {
if (scopeId !== null) throw new Error("Organization-wide knowledge takes no scope id.");
return;
}
const table = SCOPE_TABLES[scopeType];
if (!table) throw new Error(`Unknown knowledge scope "${scopeType}".`);
if (!scopeId) throw new Error(`Knowledge scoped to a ${scopeType} needs its id.`);
const exists = this.db.prepare(`select 1 from ${table} where id = ? and org_id = ?`).get(scopeId, orgId);
if (!exists) throw new Error(`No ${scopeType} with id "${scopeId}" in this company.`);
}
private assertSupersedable(orgId: string, entryId: string): void {
const row = this.db.prepare("select superseded_by from knowledge_entries where id = ? and org_id = ?")
.get(entryId, orgId) as { superseded_by: string | null } | undefined;
if (!row) throw new Error(`No knowledge entry with id "${entryId}" to supersede.`);
if (row.superseded_by) throw new Error(`Knowledge entry ${entryId} was already superseded by ${row.superseded_by}.`);
}
}
+6 -2
View File
@@ -3,10 +3,10 @@ import { KnowledgeService } from "./knowledge-service";
const MEMORY_CHARS = 2_000;
/** Keeps what a finished or failed run of real work concluded, so later runs can find it. */
/** Keeps what a run of real work concluded, for its project or the whole company. */
export function recordRunMemory(
db: DatabaseSync,
input: { orgId: string; runId: string; taskTitle: string; outcome: "completed" | "failed"; text: string },
input: { orgId: string; runId: string; projectId: string | null; taskTitle: string; outcome: "completed" | "failed"; text: string },
): void {
const text = input.text.trim();
if (!text) return;
@@ -18,5 +18,9 @@ export function recordRunMemory(
importance: "normal",
sourceType: "task_run",
sourceId: input.runId,
scopeType: input.projectId ? "project" : "organization",
scopeId: input.projectId,
validUntil: null,
supersedesId: null,
});
}
+5 -5
View File
@@ -127,8 +127,8 @@ export class TaskExecutionService {
action: "failed",
payload: { taskId: run.task_id, agentId: run.agent_id, providerId: run.provider_id, stage: "preparation", errorMessage: message },
});
const task = this.db.prepare("select title, origin from tasks where id = ?").get(run.task_id) as { title: string; origin: string | null } | undefined;
if (task && task.origin === null) recordRunMemory(this.db, { orgId, runId, taskTitle: task.title, outcome: "failed", text: message });
const task = this.db.prepare("select title, origin, project_id from tasks where id = ?").get(run.task_id) as { title: string; origin: string | null; project_id: string | null } | undefined;
if (task && task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: "failed", text: message });
throw error;
}
return this.executeAgentRun(prepared);
@@ -195,7 +195,7 @@ export class TaskExecutionService {
managerName: this.agentName(orgId, agent.manager_agent_id),
chatContext,
handoffLines: renderTaskHandoff(this.db, orgId, task.handoff_json),
knowledgeLines: relevantKnowledgeLines(this.db, orgId, `${task.title}\n${task.description}`),
knowledgeLines: relevantKnowledgeLines(this.db, { orgId, agentId: agent.id, projectId: task.project_id, goalId: task.goal_id, taskId: task.id }, `${task.title}\n${task.description}`),
priorCompletedRuns: this.completedRunCount(task.id, runId),
}),
toolContext,
@@ -223,7 +223,7 @@ export class TaskExecutionService {
}
this.completeRun(orgId, runId, result.lastRequestId);
this.cooldowns.clear(provider);
if (task.origin === null) recordRunMemory(this.db, { orgId, runId, taskTitle: task.title, outcome: "completed", text: result.finalText });
if (task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: "completed", text: result.finalText });
this.audit.record({
orgId,
entityType: "task_run",
@@ -248,7 +248,7 @@ export class TaskExecutionService {
const message = error instanceof Error ? error.message : String(error);
this.failRun(orgId, runId, providerRequestId, message);
if (isTransientProviderFailure(message)) this.cooldowns.park(provider, message);
else if (task.origin === null) recordRunMemory(this.db, { orgId, runId, taskTitle: task.title, outcome: "failed", text: message });
else if (task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: "failed", text: message });
this.audit.record({
orgId,
entityType: "task_run",
+9
View File
@@ -23,6 +23,8 @@ export interface DeleteDocumentInput {
documentId: string;
}
export type KnowledgeScopeType = "organization" | "project" | "goal" | "task" | "agent";
export interface CreateKnowledgeEntryInput {
orgId: string;
topic: string;
@@ -31,6 +33,13 @@ export interface CreateKnowledgeEntryInput {
importance: string;
sourceType: string;
sourceId: string | null;
scopeType: KnowledgeScopeType;
/** Required unless the scope is organization. */
scopeId: string | null;
/** UTC ISO time the entry stops applying, or null. */
validUntil: string | null;
/** An older entry this one replaces. */
supersedesId: string | null;
}
export interface DeleteKnowledgeEntryInput {
+4
View File
@@ -419,6 +419,10 @@ export interface KnowledgeEntryRecord {
importance: string;
sourceType: string;
sourceId: string | null;
scopeType: string;
scopeId: string | null;
validUntil: string | null;
supersededBy: string | null;
createdAt: string;
updatedAt: string;
}