From 4ae66fb6f68d07ae89abcb8c058ab0f95f8b1e6a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:39:21 -0800 Subject: [PATCH 1/2] feat(memory): batch learning and surface cited project facts --- .../components/ProjectKnowledgeEntry.test.tsx | 108 ++++- .../src/components/ProjectKnowledgeEntry.tsx | 159 +++++++- .../components/ProjectKnowledgeFactList.tsx | 4 +- .../SettingsServerControlsRouteScreen.tsx | 2 +- .../src/features/threads/ThreadComposer.tsx | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../src/mcp/toolkits/knowledge/tools.ts | 5 +- .../src/orchestration-v2/EffectWorker.test.ts | 6 +- .../src/orchestration-v2/EffectWorker.ts | 2 +- .../ProviderTurnStartService.test.ts | 72 ++++ .../ProviderTurnStartService.ts | 83 +++- .../server/src/persistence/Migrations.test.ts | 21 +- apps/server/src/persistence/Migrations.ts | 2 + .../093_ProjectKnowledgeLearningQueue.ts | 16 + .../knowledge/AssistedLearning.test.ts | 372 +++++++++++++++++- .../src/project/knowledge/AssistedLearning.ts | 361 ++++++++++++++--- .../knowledge/ProjectKnowledge.test.ts | 193 ++++++++- .../src/project/knowledge/ProjectKnowledge.ts | 164 +++++++- .../knowledge/assistedLearningSchedule.ts | 8 +- .../project/knowledge/holographicScoring.ts | 2 +- .../src/project/knowledge/persistence.ts | 9 +- .../src/provider/agentContextBudget.json | 2 +- apps/server/src/server.ts | 2 +- apps/web/src/components/ChatView.tsx | 1 + .../AssistedLearningSection.tsx | 4 +- .../ProjectMemoryPanel.browser.test.tsx | 74 +++- .../projectKnowledge/ProjectMemoryPanel.tsx | 100 +++-- .../ProjectMemorySuggestions.tsx | 109 +++++ .../src/components/settings/ZackSettings.tsx | 4 +- docs/user/composer.md | 43 +- .../src/projectKnowledge/index.test.ts | 18 + .../src/projectKnowledge/index.ts | 23 ++ packages/contracts/src/environment.test.ts | 7 + packages/contracts/src/environment.ts | 1 + .../contracts/src/projectKnowledge.test.ts | 46 +++ packages/contracts/src/projectKnowledge.ts | 86 ++++ .../src/server/textGeneration.ts | 5 +- .../src/server/textGenerationPrompts.test.ts | 27 ++ .../src/server/textGenerationPrompts.ts | 20 +- 39 files changed, 1978 insertions(+), 186 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/093_ProjectKnowledgeLearningQueue.ts create mode 100644 apps/web/src/components/projectKnowledge/ProjectMemorySuggestions.tsx diff --git a/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx b/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx index fea867f7cd530..ff8c1f5d01b4c 100644 --- a/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx +++ b/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx @@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({ deliveries: null as unknown, readDeliveries: vi.fn(), focused: true, + previewSupported: true, androidSheet: false, draft: {} as { text: string; context?: { records: unknown[] } }, list: null as unknown, @@ -164,6 +165,7 @@ vi.mock("../state/server", () => ({ environment: { capabilities: { projectKnowledge: true, + projectKnowledgePreview: state.previewSupported, projectKnowledgeHistory: true, projectKnowledgeDeliveries: state.deliveryCapabilities[environmentId], }, @@ -183,11 +185,11 @@ vi.mock("../state/projectKnowledge", () => ({ }, readFact: () => null, status: () => null, - search: (request: unknown) => { - return state.searchRequest(request); - }, + search: Object.assign((request: unknown) => state.searchRequest(request), { + permissionAtom: () => true, + }), readSource: () => state.source, - prepareContext: "prepare", + prepareContext: { kind: "prepare", permissionAtom: () => true }, mutate: { permissionAtom: () => true }, }, })); @@ -200,7 +202,8 @@ vi.mock("../state/query", () => ({ }), })); vi.mock("../state/use-atom-command", () => ({ - useAtomCommand: (command: unknown) => (command === "prepare" ? state.prepare : state.mutate), + useAtomCommand: (command: unknown) => + (command as { kind?: string }).kind === "prepare" ? state.prepare : state.mutate, })); vi.mock("../state/use-composer-drafts", () => ({ useComposerDraft: () => state.draft, @@ -284,12 +287,16 @@ const deliveryRecord: ProjectKnowledgeDeliveries = { }, ], }; -function setup(entries = [scope]) { +function setup( + entries: Array = [scope], + previewSupported = true, +) { state.uuidSequence = 0; state.deliveryCapabilities = {}; state.deliveries = null; state.readDeliveries.mockReset(); state.focused = true; + state.previewSupported = previewSupported; state.androidSheet = false; state.draft = { text: "" }; state.list = { facts: [fact], nextCursor: null }; @@ -949,3 +956,92 @@ it.each(["Hardware Back", "Header Back", "Back", "Cancel"])( expect(screen.container.textContent).toContain("Edit"); }, ); + +it("recalls a completed first draft once and dismisses a suggestion without attaching it", async () => { + using screen = setup([{ ...scope, suggest: true }]); + vi.useFakeTimers(); + try { + expect(state.searchRequest).not.toHaveBeenCalled(); + state.draft = { text: "Change the cache keys" }; + screen.rerender(); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(state.searchRequest).not.toHaveBeenCalled(); + state.draft = { text: "Change the cache keys to include tenant IDs." }; + state.search = { + messages: { matches: [], nextCursor: null }, + facts: { + state: "available", + coverage: "complete", + nextCursor: null, + matches: [ + { + fact: { + ...fact, + trust: 0.9, + sources: [ + { + kind: "message", + threadId: ThreadId.make("source"), + messageId: MessageId.make("source-message"), + digest: "a".repeat(64), + }, + ], + }, + score: { + holographic: 0.9, + lexical: 0.9, + tokenOverlap: 0.9, + temporalDecay: 1, + reinforcement: 1, + trust: 0.9, + combined: 0.9, + }, + }, + ], + }, + }; + screen.rerender(); + await act(async () => vi.advanceTimersByTimeAsync(1000)); + expect(screen.container.textContent).toContain("Project memory:"); + expect(state.searchRequest.mock.calls[0]?.[0].input).toMatchObject({ + query: state.draft.text, + recordRetrieval: false, + projectId: scope.projectId, + }); + const recalledDraft = state.draft.text; + state.draft = { text: `${recalledDraft} Keep the existing conventions.` }; + screen.rerender(); + expect(screen.container.textContent).toContain("Project memory:"); + state.draft = { text: "Replace the sign in page with a new layout." }; + screen.rerender(); + expect(screen.container.textContent).not.toContain("Project memory:"); + state.draft = { text: recalledDraft }; + screen.rerender(); + expect(screen.container.textContent).toContain("Project memory:"); + await screen.click("Dismiss"); + expect(screen.container.textContent).not.toContain("Project memory:"); + expect(state.prepare).not.toHaveBeenCalled(); + expect(state.insert).not.toHaveBeenCalled(); + state.draft = { text: "Another request about tenant cache keys." }; + screen.rerender(); + await act(async () => vi.advanceTimersByTimeAsync(2000)); + expect( + new Set(state.searchRequest.mock.calls.map(([request]) => request.input.requestId)).size, + ).toBe(1); + } finally { + vi.useRealTimers(); + } +}); + +it("keeps passive draft recall off for servers without preview support", async () => { + using screen = setup([{ ...scope, suggest: true }], false); + vi.useFakeTimers(); + try { + state.draft = { text: "Change cache keys to include tenant IDs." }; + screen.rerender(); + await act(async () => vi.advanceTimersByTimeAsync(2000)); + expect(state.searchRequest).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } +}); diff --git a/apps/mobile/src/components/ProjectKnowledgeEntry.tsx b/apps/mobile/src/components/ProjectKnowledgeEntry.tsx index 4a0142b201da6..7535fbfda5f77 100644 --- a/apps/mobile/src/components/ProjectKnowledgeEntry.tsx +++ b/apps/mobile/src/components/ProjectKnowledgeEntry.tsx @@ -18,6 +18,12 @@ import { type ProjectKnowledgeSource, type ThreadId, } from "@t3tools/contracts"; +import { + addProjectMemorySuggestion, + boundProjectMemorySuggestionQuery, + isSubstantiveProjectMemoryQuery, + selectProjectMemorySuggestions, +} from "@t3tools/client-runtime/projectKnowledge"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { @@ -64,6 +70,7 @@ type Scope = { readonly projectId: ProjectId; readonly draftKey: string; readonly disabled: boolean; + readonly suggest?: boolean; }; type MessageCitation = { readonly threadId: ThreadId; readonly messageId: MessageId }; // The digest is optional so a feed message can be cited before the server has @@ -108,7 +115,11 @@ export function useOpenProjectMemory( export function ProjectKnowledgeEntry(props: Scope) { const isFocused = useIsFocused(); const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); - const [open, setOpen] = useState<{ readonly seed: MessageCitation | null } | null>(null); + const [open, setOpen] = useState<{ + readonly seed: MessageCitation | null; + readonly fact?: ProjectFact; + readonly source?: KnowledgeMessageRef; + } | null>(null); const { environmentId, projectId, draftKey } = props; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers this entry with the header and feed open requests while mounted useEffect(() => { @@ -126,18 +137,31 @@ export function ProjectKnowledgeEntry(props: Scope) { openListeners.delete(listener); }; }, [environmentId, projectId, draftKey, isFocused]); - if (config?.environment.capabilities.projectKnowledge !== true || !open || !isFocused) - return null; + if (config?.environment.capabilities.projectKnowledge !== true || !isFocused) return null; return ( - setOpen(null)} - /> + <> + {props.suggest && config.environment.capabilities.projectKnowledgePreview === true ? ( + setOpen({ seed: null, fact })} + onSource={(source) => setOpen({ seed: null, source })} + /> + ) : null} + {open ? ( + setOpen(null)} + /> + ) : null} + ); } @@ -146,6 +170,8 @@ function ProjectKnowledgeSheet( readonly history: boolean; readonly assisted: boolean; readonly seedMessage: MessageCitation | null; + readonly suggestedFact?: ProjectFact; + readonly suggestedSource?: KnowledgeMessageRef; readonly onClose: () => void; }, ) { @@ -158,14 +184,23 @@ function ProjectKnowledgeSheet( const attachedInScope = attached && projectKnowledgeRecordInScope(attached, scope) ? attached : undefined; const [selections, setSelections] = useState>( - attachedInScope?.knowledge.selections ?? [], + props.suggestedFact + ? addProjectMemorySuggestion(attachedInScope?.knowledge.selections ?? [], props.suggestedFact) + : (attachedInScope?.knowledge.selections ?? []), ); // Set when a selection change removed the reference this sheet opened with. const [detached, setDetached] = useState(false); const [stack, setStack] = useState>( - props.seedMessage - ? [{ kind: "list" }, { kind: "remember", ref: props.seedMessage }] - : [{ kind: "list" }], + props.suggestedFact + ? [ + { kind: "list" }, + { kind: "fact", factId: props.suggestedFact.id, snapshot: props.suggestedFact }, + ] + : props.suggestedSource + ? [{ kind: "list" }, { kind: "source", ref: props.suggestedSource }] + : props.seedMessage + ? [{ kind: "list" }, { kind: "remember", ref: props.seedMessage }] + : [{ kind: "list" }], ); const page = stack.at(-1)!; const [hints, setHints] = useState<{ @@ -634,3 +669,95 @@ function RememberEditor(props: { /> ); } + +function ProjectMemorySuggestions( + props: Scope & { + readonly onUse: (fact: ProjectFact) => void; + readonly onSource: (source: KnowledgeMessageRef) => void; + }, +) { + const canUse = useAtomValue(projectKnowledge.prepareContext.permissionAtom(props.environmentId)); + const draft = useComposerDraft(props.draftKey); + const [request, setRequest] = useState<{ query: string; requestId: CommandId } | null>(null); + const [dismissed, setDismissed] = useState>([]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- issues one recall request after the user pauses composing a substantive draft + useEffect(() => { + if (request || !isSubstantiveProjectMemoryQuery(draft.text)) return; + const timer = setTimeout( + () => + setRequest({ + query: boundProjectMemorySuggestionQuery(draft.text), + requestId: CommandId.make(uuidv4()), + }), + 1000, + ); + return () => clearTimeout(timer); + }, [draft.text, request]); + const result = useEnvironmentQuery( + request + ? projectKnowledge.search({ + environmentId: props.environmentId, + input: { + ...request, + projectId: props.projectId, + recordRetrieval: false, + preview: true, + minTrust: 0.5, + limit: 8, + messageLimit: 1, + }, + }) + : null, + ); + const suggestions = result.data + ? selectProjectMemorySuggestions(result.data, request?.query).filter( + ({ fact }) => !dismissed.includes(fact.id), + ) + : []; + if (!request || !draft.text.trimStart().startsWith(request.query) || !suggestions.length) + return null; + return ( + + {suggestions.map(({ fact, reason }) => ( + + Project memory: {fact.content} + {reason} + + {fact.sources.some((source) => source.kind === "user") ? ( + Saved by you + ) : null} + {fact.sources + .filter((source) => source.kind === "message") + .slice(0, 1) + .flatMap((source) => + source.kind === "message" + ? [ + , + ] + : [], + )} + + + + ))} + + ); +} diff --git a/apps/web/src/components/settings/ZackSettings.tsx b/apps/web/src/components/settings/ZackSettings.tsx index 29ef7440f8cf7..3fa6609a22ab4 100644 --- a/apps/web/src/components/settings/ZackSettings.tsx +++ b/apps/web/src/components/settings/ZackSettings.tsx @@ -71,8 +71,8 @@ function AssistedLearningRow() { description={ unavailableReason ?? (isProjectScope - ? "After each run in this project, your text generation model saves up to three cited facts to Project memory. Each run costs one model call." - : "After each run, your text generation model saves up to three cited facts to Project memory. Each run costs one model call. Projects can override it.") + ? "Completed runs in this project are grouped into background learning batches. Your text generation model saves up to three cited facts per batch; eligible batches use at most one model call." + : "Completed runs are grouped into background learning batches. Your text generation model saves up to three cited facts per batch; eligible batches use at most one model call. Projects can override it.") } resetAction={ unavailableReason === null && diff --git a/docs/user/composer.md b/docs/user/composer.md index 8115114548855..e84b0ee4752cf 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -130,7 +130,7 @@ Open **Project memory** from the thread details, the command palette, or `mod+shift+u` on web or desktop, or from thread actions on mobile. Search conversations and saved facts in the current project, inspect their sources, and select evidence for your next message. Selecting a fact shows its evidence; -only its checkbox adds it to your draft. Archived conversations are excluded unless +choose **Attach** to include the selected evidence in your draft. Archived conversations are excluded unless you include them. A fact whose cited message was edited, deleted, or archived is flagged in the list. @@ -154,24 +154,29 @@ up in recall unless you show superseded facts. After you save, T3 points out existing facts that look like duplicates or possible contradictions so you can compare them; it never merges or replaces them on its own. -T3 also saves facts from finished runs on its own. After a run finishes, fails, or -is stopped, your text generation model reads that run's messages and saves up to -three decisions, constraints, or conventions, each citing the messages it came -from. Each run costs one model call. To turn this off, use **Save facts from -finished runs** under **Memory health** in Project memory, or in **Settings → Labs → -Zack** for one project or every project. The **Automatic** filter lists these facts, -and a fact from a run that failed or was stopped says so. - -An exact duplicate of an existing fact is skipped. Different facts are saved -separately, even when they look similar or may conflict. Both can come up in recall -until you supersede one. -Agents find saved facts by searching; nothing is added to their prompts on its own. -A fact's trust rating, age, and how often it is recalled all affect its rank, and a -fact rated below the recall threshold stops coming up. Facts are not deleted -automatically. -A fact you forget is not saved again from the same run, but a later run can learn it -again. Project memory shows what the last run did, including why it was skipped or -failed. +When **Save facts from finished runs** is on, T3 reviews new runs from a thread +and its child threads together. A batch is eligible after eight completed runs or +30 minutes of inactivity. Your text generation model can propose up to three +facts in one call per eligible batch. Each fact needs a citation to a message +you wrote or corroborating messages from separate runs in different threads. +Failed and stopped runs can supply evidence. Change this setting under **Memory +health** in Project memory or in **Settings → Labs → Zack**. Project memory +shows the last outcome and the **Automatic** filter lists automatically saved facts. + +An exact repeat can add new citations to a saved fact. You decide when similar +or conflicting facts should supersede one another. Trust, age and retrieval +history affect recall. Forgetting a fact removes its content, and a later run +can supply new evidence for it. + +Pause while composing the first request in a thread to see up to two related +facts. Each suggestion shows why it matched. Open its source, choose **Use** to +review it for attachment, or **Dismiss** it for this draft. Previewing a +suggestion costs no model tokens and does not increase its trust. + +Agents can search project memory on demand. On an unattended first turn with T3 +tools available and room in the context window, the agent can also receive up to +two fact IDs. It can open a fact and follow its citations when the task needs +the evidence. A fact's details show its history: when it was created, edited, superseded, and rated helpful or unhelpful, with the trust change from each rating. **Memory health** counts facts by status. When facts lose their cited diff --git a/packages/client-runtime/src/projectKnowledge/index.test.ts b/packages/client-runtime/src/projectKnowledge/index.test.ts index e02b32a6c9cd6..123ec31a18286 100644 --- a/packages/client-runtime/src/projectKnowledge/index.test.ts +++ b/packages/client-runtime/src/projectKnowledge/index.test.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import { expect, it } from "vite-plus/test"; import { + addProjectMemorySuggestion, deliveredKnowledgeNotes, deliveredKnowledgeSnapshot, KNOWLEDGE_DELIVERY_OMISSION_LABEL, @@ -134,3 +135,20 @@ it("traces trust from before the first recorded event through each feedback", () "Marked unhelpful", ]); }); + +it("adds suggested facts without losing selections or duplicating revisions", () => { + const first = { kind: "fact" as const, factId: ProjectFactId.make("first"), revision: 1 }; + const second = { kind: "fact" as const, factId: ProjectFactId.make("second"), revision: 1 }; + expect(addProjectMemorySuggestion([first], { id: second.factId, revision: 2 })).toEqual([ + first, + { ...second, revision: 2 }, + ]); + expect( + addProjectMemorySuggestion([first, second, first], { id: first.factId, revision: 3 }), + ).toEqual([{ ...first, revision: 3 }, second]); + const full = Array.from({ length: 20 }, (_, i) => ({ + ...first, + factId: ProjectFactId.make(String(i)), + })); + expect(addProjectMemorySuggestion(full, { id: second.factId, revision: 1 })).toBe(full); +}); diff --git a/packages/client-runtime/src/projectKnowledge/index.ts b/packages/client-runtime/src/projectKnowledge/index.ts index 598ff3acb56ec..61c32b03d174b 100644 --- a/packages/client-runtime/src/projectKnowledge/index.ts +++ b/packages/client-runtime/src/projectKnowledge/index.ts @@ -1,8 +1,15 @@ +export { + boundProjectMemorySuggestionQuery, + isSubstantiveProjectMemoryQuery, + selectProjectMemorySuggestions, + type ProjectMemorySuggestion, +} from "@t3tools/contracts"; import { WS_METHODS, type AssistedLearningRun, type KnowledgeConflictHint, type KnowledgeOrigin, + type KnowledgeSelection, type KnowledgeSourceUnavailableReason, type ProjectFact, type ProjectFactHistoryEntry, @@ -219,3 +226,19 @@ export function knowledgeConflictHintLabel(hint: KnowledgeConflictHint): string return "Possible conflict with"; } } + +export function addProjectMemorySuggestion( + selections: ReadonlyArray, + fact: Pick, +): ReadonlyArray { + const selection = { kind: "fact" as const, factId: fact.id, revision: fact.revision }; + const exists = selections.some((item) => item.kind === "fact" && item.factId === fact.id); + if (!exists) return selections.length < 20 ? [...selections, selection] : selections; + let replaced = false; + return selections.flatMap((item) => { + if (item.kind !== "fact" || item.factId !== fact.id) return [item]; + if (replaced) return []; + replaced = true; + return [selection]; + }); +} diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 1b318ff19cdf4..b7adddb530768 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -22,6 +22,13 @@ describe("ExecutionEnvironmentDescriptor", () => { expect(decodeDescriptor(oldServer).capabilities).not.toHaveProperty( "projectKnowledgeDeliveries", ); + expect(decodeDescriptor(oldServer).capabilities.projectKnowledgePreview).toBeUndefined(); + expect( + decodeDescriptor({ + ...oldServer, + capabilities: { ...oldServer.capabilities, projectKnowledgePreview: true }, + }).capabilities.projectKnowledgePreview, + ).toBe(true); for (const supported of [false, true]) { expect( decodeDescriptor({ diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index b718ea2062f23..7cda3fd70509b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -77,6 +77,7 @@ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; export const ExecutionEnvironmentCapabilities = Schema.Struct({ modelEvaluations: Schema.optionalKey(Schema.Boolean), projectKnowledge: Schema.optionalKey(Schema.Boolean), + projectKnowledgePreview: Schema.optionalKey(Schema.Boolean), /** projectKnowledge.readFact, projectKnowledge.status and the supersede mutation. */ projectKnowledgeHistory: Schema.optionalKey(Schema.Boolean), /** projectKnowledge.readDeliveries and the recorded copy delivered with sent messages. */ diff --git a/packages/contracts/src/projectKnowledge.test.ts b/packages/contracts/src/projectKnowledge.test.ts index f124fd571695d..1e0d5affab88e 100644 --- a/packages/contracts/src/projectKnowledge.test.ts +++ b/packages/contracts/src/projectKnowledge.test.ts @@ -3,12 +3,15 @@ import * as Schema from "effect/Schema"; import { ProjectKnowledgeDelivery, + ProjectKnowledgeSearchResult, + selectProjectMemorySuggestions, ProjectKnowledgeListInput, ProjectKnowledgeListResult, ProjectKnowledgeMutation, ProjectKnowledgeStatus, } from "./projectKnowledge.ts"; +const decodeSearchResult = Schema.decodeUnknownSync(ProjectKnowledgeSearchResult); const decodeDelivery = Schema.decodeUnknownSync(ProjectKnowledgeDelivery); const decodeList = Schema.decodeUnknownSync(ProjectKnowledgeListResult); @@ -137,3 +140,46 @@ it.each(["over_budget", "source_unavailable", "stale_revision", "not_permitted", expect(decodeDelivery({ ...oldDelivery, omissions }).omissions).toEqual(omissions); }, ); + +describe("project memory suggestions", () => { + it("keeps only strong cited matches and bounds optional context", () => { + const score = { + holographic: 1, + lexical: 1, + tokenOverlap: 0.4, + temporalDecay: 1, + reinforcement: 1, + trust: 0.8, + combined: 1, + }; + const result = decodeSearchResult({ + messages: { matches: [] }, + facts: { + state: "available", + coverage: "complete", + nextCursor: null, + matches: [ + { fact: { ...fact({ kind: "user" }), id: "uncited" }, score }, + { + fact: { ...fact({ kind: "user" }), id: "weak", sources: [{ kind: "user" }] }, + score: { ...score, lexical: 0, tokenOverlap: 0.01 }, + }, + ...["first", "second", "third"].map((id) => ({ + fact: { ...fact({ kind: "user" }), id, sources: [{ kind: "user" }] }, + score, + })), + ], + }, + }); + expect(selectProjectMemorySuggestions(result).map((item) => item.fact.id)).toEqual([ + "first", + "second", + ]); + expect( + selectProjectMemorySuggestions({ + ...result, + facts: { state: "unavailable", reason: "kernel_unavailable" }, + }), + ).toEqual([]); + }); +}); diff --git a/packages/contracts/src/projectKnowledge.ts b/packages/contracts/src/projectKnowledge.ts index 3780a2b47d103..039a77a285100 100644 --- a/packages/contracts/src/projectKnowledge.ts +++ b/packages/contracts/src/projectKnowledge.ts @@ -24,6 +24,7 @@ export const ProjectFactId = TrimmedNonEmptyString.pipe(Schema.brand("ProjectFac export type ProjectFactId = typeof ProjectFactId.Type; export const KnowledgeDigest = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)); export const KnowledgeMessageRef = Schema.Struct({ + runId: Schema.optionalKey(RunId), kind: Schema.Literal("message"), threadId: ThreadId, messageId: MessageId, @@ -464,6 +465,8 @@ export const ProjectKnowledgeSearchInput = Schema.Struct({ // Retrieval reinforces ranking. Browsing surfaces (as-you-type, previews) pass false so only // deliberate lookups count; omitted means true. recordRetrieval: Schema.optionalKey(Schema.Boolean), + /** Passive suggestions do not record receipts, reinforce retrieval, or return continuations. */ + preview: Schema.optionalKey(Schema.Boolean), }); export type ProjectKnowledgeSearchInput = typeof ProjectKnowledgeSearchInput.Type; export const KnowledgeScore = Schema.Struct({ @@ -491,6 +494,89 @@ const ProjectKnowledgeSearchResultSchema = Schema.Struct({ ]), }); export type ProjectKnowledgeSearchResult = typeof ProjectKnowledgeSearchResultSchema.Type; +export interface ProjectMemorySuggestion { + readonly fact: ProjectFact; + readonly score: typeof KnowledgeScore.Type; + readonly reason: string; +} + +const PROJECT_MEMORY_SUGGESTION_QUERY_MAX_LENGTH = 1000; + +export function boundProjectMemorySuggestionQuery(query: string): string { + return query.trim().slice(0, PROJECT_MEMORY_SUGGESTION_QUERY_MAX_LENGTH); +} + +const memoryQueryFiller = new Set([ + "the", + "and", + "for", + "this", + "that", + "with", + "please", + "help", + "can", + "you", + "could", + "would", + "want", + "need", + "hello", + "there", + "thanks", +]); +const memoryQueryTerms = (query: string) => [ + ...new Set( + ( + boundProjectMemorySuggestionQuery(query) + .toLowerCase() + .match(/[\p{L}\p{N}_/:.]+/gu) ?? [] + ).filter((term) => term.length >= 3 && !memoryQueryFiller.has(term)), + ), +]; + +export function isSubstantiveProjectMemoryQuery(query: string): boolean { + const terms = memoryQueryTerms(query); + return ( + terms.length >= 3 || + terms.some( + (term) => + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(term) && term.length >= 6, + ) + ); +} + +export function selectProjectMemorySuggestions( + result: ProjectKnowledgeSearchResult, + query = "", +): ReadonlyArray { + if (result.facts.state !== "available") return []; + const terms = memoryQueryTerms(query); + return result.facts.matches + .filter( + ({ fact, score }) => + fact.status === "saved" && + fact.supersededBy == null && + fact.sources.length > 0 && + score.trust >= 0.5 && + score.combined > 0 && + (score.tokenOverlap >= 0.15 || (score.lexical >= 0.65 && score.tokenOverlap >= 0.05)), + ) + .slice(0, 2) + .map(({ fact, score }) => { + const content = [fact.content ?? "", ...fact.entities, ...fact.tags].join(" ").toLowerCase(); + const matched = terms.filter((term) => content.includes(term)).slice(0, 3); + return { + fact, + score, + reason: + matched.length > 0 + ? `Matches ${matched.map((term) => `“${term}”`).join(", ")} in saved project knowledge.` + : "Strong lexical match in saved project knowledge.", + }; + }); +} + export const ProjectKnowledgeSearchResult: Schema.Codec< ProjectKnowledgeSearchResult, typeof ProjectKnowledgeSearchResultSchema.Encoded diff --git a/packages/provider-core/src/server/textGeneration.ts b/packages/provider-core/src/server/textGeneration.ts index 499f8ca1d394d..2b831c209b7b2 100644 --- a/packages/provider-core/src/server/textGeneration.ts +++ b/packages/provider-core/src/server/textGeneration.ts @@ -103,9 +103,12 @@ export type ReviewGuideGenerationResult = Omit< export interface KnowledgeCandidatesGenerationInput { cwd: string; runOutcome: "completed" | "failed" | "interrupted"; - /** Finalized messages of one run, oldest first; `ref` is what citations name. */ + /** Finalized new evidence from one thread family, oldest first; `ref` is what citations name. */ messages: ReadonlyArray<{ readonly ref: string; + readonly threadId?: string; + readonly runId?: string; + readonly runOutcome?: "completed" | "failed" | "interrupted"; readonly role: "user" | "assistant"; readonly text: string; }>; diff --git a/packages/provider-core/src/server/textGenerationPrompts.test.ts b/packages/provider-core/src/server/textGenerationPrompts.test.ts index dabd14a23dd98..f16cda60ae0cf 100644 --- a/packages/provider-core/src/server/textGenerationPrompts.test.ts +++ b/packages/provider-core/src/server/textGenerationPrompts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; +import { buildKnowledgeCandidatesPrompt } from "./textGenerationPrompts.ts"; import { normalizeCliError } from "./textGenerationUtils.ts"; describe("normalizeCliError", () => { @@ -15,3 +16,29 @@ describe("normalizeCliError", () => { expect(result.message).not.toContain("secret-token"); }); }); + +it("preserves each run boundary and terminal outcome in family review prompts", () => { + const { prompt } = buildKnowledgeCandidatesPrompt({ + runOutcome: "completed", + messages: [ + { + ref: "M1", + role: "user", + text: "Keep the API stable.", + threadId: "root", + runId: "run-a", + runOutcome: "interrupted", + }, + { + ref: "M2", + role: "assistant", + text: "Checked compatibility.", + threadId: "child", + runId: "run-b", + runOutcome: "completed", + }, + ], + }); + expect(prompt).toContain("[M1] user (T1/R1, interrupted):\nKeep the API stable."); + expect(prompt).toContain("[M2] assistant (T2/R2, completed):\nChecked compatibility."); +}); diff --git a/packages/provider-core/src/server/textGenerationPrompts.ts b/packages/provider-core/src/server/textGenerationPrompts.ts index 97e3b2ddb31f9..887867f034a2f 100644 --- a/packages/provider-core/src/server/textGenerationPrompts.ts +++ b/packages/provider-core/src/server/textGenerationPrompts.ts @@ -491,14 +491,25 @@ export interface KnowledgeCandidatesPromptInput { runOutcome: "completed" | "failed" | "interrupted"; messages: ReadonlyArray<{ readonly ref: string; + readonly threadId?: string; + readonly runId?: string; + readonly runOutcome?: "completed" | "failed" | "interrupted"; readonly role: "user" | "assistant"; readonly text: string; }>; } export function buildKnowledgeCandidatesPrompt(input: KnowledgeCandidatesPromptInput) { + const threads = new Map(); + const runs = new Map(); + for (const message of input.messages) { + if (message.threadId !== undefined && !threads.has(message.threadId)) + threads.set(message.threadId, `T${threads.size + 1}`); + if (message.runId !== undefined && !runs.has(message.runId)) + runs.set(message.runId, `R${runs.size + 1}`); + } const prompt = [ - "You review one finished exchange between a user and a coding agent and extract durable project knowledge a future agent working in this repository should know.", + "You review a bounded batch of new finished exchanges from one project thread family and extract durable project knowledge a future agent working in this repository should know.", "Return a JSON object with key candidates: an array of at most 3 objects with keys kind, content, entities, citations.", "Rules:", "- kind is one of decision, constraint, convention", @@ -507,6 +518,8 @@ export function buildKnowledgeCandidatesPrompt(input: KnowledgeCandidatesPromptI "- content is one or two plain sentences that stand alone without this conversation", "- entities lists up to 5 short names of files, modules, tools, or concepts the knowledge is about", "- citations lists the refs (like M1) of the messages that state or directly support it; never invent a ref", + "- assistant-only claims need independent supporting messages from different completed runs and threads; repeated assertions from one agent are insufficient", + "- for failed or interrupted runs extract only what the user stated, not what the agent assumed", "- return an empty candidates array when nothing durable was established, which is the common case", ...(input.runOutcome === "completed" ? [] @@ -515,7 +528,10 @@ export function buildKnowledgeCandidatesPrompt(input: KnowledgeCandidatesPromptI ]), "", "Messages:", - ...input.messages.map((message) => `[${message.ref}] ${message.role}:\n${message.text}\n`), + ...input.messages.map( + (message) => + `[${message.ref}] ${message.role}${message.runId === undefined ? "" : ` (${threads.get(message.threadId ?? "") ?? "T?"}/${runs.get(message.runId)}, ${message.runOutcome ?? input.runOutcome})`}:\n${message.text}\n`, + ), ].join("\n"); const outputSchema = Schema.Struct({ candidates: Schema.Array( From fb5f5c0ffacac6e0369cdd811f7bd19dc1513d88 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:59:52 -0800 Subject: [PATCH 2/2] fix(memory): align replay fixtures and migration assertions --- apps/server/src/orchestration-v2/ProviderTurnStartService.ts | 3 +-- .../src/orchestration-v2/testkit/ProviderReplayHarness.ts | 1 + .../src/persistence/reconcileV2PreviewMigration.test.ts | 2 ++ apps/server/src/project/knowledge/HolographicMemory.test.ts | 4 ++-- apps/server/src/provider/agentContextBudget.json | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 98cc71391acf3..c2b500d2ebed9 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1648,8 +1648,7 @@ export const layer: Layer.Layer< const budget = handoffBudgetAssumptions({ tokenCap, modelContextWindow, - userText: - turnNotes === "" ? composer.baseText : `${turnNotes}\n\n${composer.baseText}`, + userText: turnNotes === "" ? composer.baseText : `${turnNotes}\n\n${composer.baseText}`, attachments: message.attachments, providerThread: budgetProviderThread, nativeContextEstimate: diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 8723fabda85e2..73204eef07a5a 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -531,6 +531,7 @@ export function layerWithRegistry( Layer.mock(ProjectKnowledge.ProjectKnowledge)({ nativeDeliveryEstimate: () => Effect.succeed(0), readSentDeliveries: () => Effect.succeed([]), + suggest: () => Effect.succeed([]), }), layerContextHandoffServiceProvided, layerEventSinkProvided, diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 5ce01dedc80cd..ee986af24a4e6 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -72,6 +72,7 @@ describe("V2 preview upgrade", () => { [90, "ScheduledTaskThreadMode"], [91, "DropProjectLoomIndexes"], [92, "ProjectKnowledgeSavedLifecycle"], + [93, "ProjectKnowledgeLearningQueue"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -187,6 +188,7 @@ describe("V2 preview upgrade", () => { [90, "ScheduledTaskThreadMode"], [91, "DropProjectLoomIndexes"], [92, "ProjectKnowledgeSavedLifecycle"], + [93, "ProjectKnowledgeLearningQueue"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/project/knowledge/HolographicMemory.test.ts b/apps/server/src/project/knowledge/HolographicMemory.test.ts index 0e8aef4a8e390..ae9ab0e6d7983 100644 --- a/apps/server/src/project/knowledge/HolographicMemory.test.ts +++ b/apps/server/src/project/knowledge/HolographicMemory.test.ts @@ -23,12 +23,12 @@ const candidate = ( ...overrides, }); -it("retains the distinct ASCII search tokenizer", () => { +it("retains Unicode words in the distinct search tokenizer", () => { assert.deepEqual( projectMemorySearchTokens( "The AND a src/lib.rs crate::Thing foo.bar _ GrüßE 東京 SQLite sqlite", ), - ["and", "crate::thing", "foo.bar", "gr", "sqlite", "src/lib.rs", "the"], + ["and", "crate::thing", "foo.bar", "grüße", "sqlite", "src/lib.rs", "the", "東京"], ); }); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index a546d2e6827bd..a133094097764 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 8824, "codexAdditionalContextBytes": 10550, - "mcpToolListJsonBytes": 67801 + "mcpToolListJsonBytes": 67687 }