From 8c2e8316cc09af013520d0e03093c0e5b6e31606 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:55:04 +0000 Subject: [PATCH 1/4] perf(memory): focus extraction hybrid pre-search Passive extraction pre-searched with the full user+tool transcript, which diluted embeddings and FTS. Build a short user-evidence query, keep parallel vector+lexical RRF, and tighten the lexical rank window. Co-Authored-By: David Cramer --- packages/junior-memory/README.md | 5 +- packages/junior-memory/src/process-session.ts | 78 ++++++++++++++++--- packages/junior-memory/src/store.ts | 3 +- packages/junior-memory/tests/storage.test.ts | 77 ++++++++++++++++++ 4 files changed, 151 insertions(+), 12 deletions(-) diff --git a/packages/junior-memory/README.md b/packages/junior-memory/README.md index 4a80526879..e41f8016fe 100644 --- a/packages/junior-memory/README.md +++ b/packages/junior-memory/README.md @@ -64,7 +64,10 @@ exported types, tools, and tests are authoritative. - Candidate review resolves duplicates and supersession before activation. - Search combines independently ranked vector and PostgreSQL full-text matches with reciprocal rank fusion; provider-specific raw scores are never added - together. + together. Each leg stays a bounded top-k probe; lexical ranking never scores + the full match set. +- Passive extraction pre-searches with a short user-evidence query, not the full + transcript or tool dumps, then still uses the same hybrid RRF path. - Automatic recall retrieves a broad candidate window, then uses the memory-owned relevance model to admit at most five directly useful memories. An empty result contributes no filler prompt text. diff --git a/packages/junior-memory/src/process-session.ts b/packages/junior-memory/src/process-session.ts index 86f35a2b53..aee7ba739c 100644 --- a/packages/junior-memory/src/process-session.ts +++ b/packages/junior-memory/src/process-session.ts @@ -29,6 +29,9 @@ const MEMORY_TOOL_NAMES = new Set([ "searchMemories", ]); const MEMORY_TASK_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +/** Keep extraction pre-search short enough for focused hybrid retrieval. */ +const EXTRACTION_SEARCH_QUERY_MAX_CHARS = 1_500; +const EXTRACTION_SEARCH_LIMIT = 10; const extractedMemorySchema = z .object({ content: z.string().min(1), @@ -57,6 +60,57 @@ const extractedMemoryCacheSchema = z.union([ /** Where a passively extracted memory may be stored, or dropped when unproven. */ type MemoryRouteTarget = "drop" | "personal" | "conversation"; +/** + * Build a short hybrid-search query for passive extraction pre-search. + * Prefer durable run-actor instructions, then other user evidence. Never + * include tool dumps that dilute lexical and embedding retrieval. + */ +function buildExtractionSearchQuery( + transcript: PluginRunTranscriptEntry[], +): string { + const instructionTexts: string[] = []; + const otherUserTexts: string[] = []; + for (const entry of transcript) { + if (entry.type !== "message" || entry.role !== "user") { + continue; + } + const text = entry.text?.trim(); + if (!text) { + continue; + } + if (entry.provenance?.authority === "instruction" && entry.isRunActor) { + instructionTexts.push(text); + continue; + } + otherUserTexts.push(text); + } + const preferred = + instructionTexts.length > 0 ? instructionTexts : otherUserTexts; + if (preferred.length === 0) { + return ""; + } + // Prefer the newest durable utterances; they are the usual extraction targets. + const ordered = [...preferred].reverse(); + const parts: string[] = []; + let used = 0; + for (const text of ordered) { + if (used >= EXTRACTION_SEARCH_QUERY_MAX_CHARS) { + break; + } + const remaining = EXTRACTION_SEARCH_QUERY_MAX_CHARS - used; + const slice = + text.length <= remaining + ? text + : `${text.slice(0, Math.max(0, remaining - 3)).trimEnd()}...`; + if (!slice) { + continue; + } + parts.push(slice); + used += slice.length + (parts.length > 1 ? 1 : 0); + } + return parts.join("\n").trim(); +} + function recordCapturedMemory( captured: ReturnType[], result: CreateMemoryResult, @@ -240,14 +294,13 @@ export async function processMemorySession( const transcript = run.transcript .filter((entry) => entry.text?.trim()) .map((entry) => ({ ...entry, text: entry.text!.trim() })); - const evidenceText = transcript - .filter((entry) => entry.type === "toolResult" || entry.role === "user") - .map((entry) => entry.text) - .join("\n\n") - .trim(); - if (!evidenceText) { + const hasExtractionEvidence = transcript.some( + (entry) => entry.type === "toolResult" || entry.role === "user", + ); + if (!hasExtractionEvidence) { return; } + const extractionSearchQuery = buildExtractionSearchQuery(transcript); const runtimeContext = memoryRuntimeContextSchema.parse({ conversationId: run.conversationId, @@ -261,10 +314,15 @@ export async function processMemorySession( }); await store.archiveExpiredMemories(); const extraction = await getTaskExtraction(context, async () => { - const existingMemories = await store.searchMemories({ - limit: 10, - query: evidenceText, - }); + // Hybrid search still fuses vector + lexical ranks. Extraction only needs a + // short query so embeddings and FTS stay focused on durable user evidence. + const existingMemories = + extractionSearchQuery.length > 0 + ? await store.searchMemories({ + limit: EXTRACTION_SEARCH_LIMIT, + query: extractionSearchQuery, + }) + : []; return await agent.extractSessionMemories({ existingMemories: existingMemories.map((memory) => ({ content: memory.content, diff --git a/packages/junior-memory/src/store.ts b/packages/junior-memory/src/store.ts index d97ca83f1d..44f04568c1 100644 --- a/packages/junior-memory/src/store.ts +++ b/packages/junior-memory/src/store.ts @@ -53,7 +53,8 @@ const PREFERENCE_ADJUDICATION_CANDIDATE_LIMIT = 10; const PREFERENCE_ADJUDICATION_VECTOR_LIMIT = 5; const VECTOR_SEARCH_OVERFETCH = 4; const LEXICAL_RANK_OVERFETCH = 4; -const MAX_LEXICAL_RANK_CANDIDATES = 1_000; +/** Cap FTS rank work to a small hybrid top-k window, not the full match set. */ +const MAX_LEXICAL_RANK_CANDIDATES = 200; const MAX_MEMORY_CONTENT_CHARS = 4_000; const EMBEDDING_METRIC = "cosine"; diff --git a/packages/junior-memory/tests/storage.test.ts b/packages/junior-memory/tests/storage.test.ts index da41c8e352..fbebab0fd5 100644 --- a/packages/junior-memory/tests/storage.test.ts +++ b/packages/junior-memory/tests/storage.test.ts @@ -932,6 +932,83 @@ describe("memory plugin storage", () => { } }, 15_000); + it("pre-searches extraction with focused user evidence instead of tool dumps", async () => { + const fixture = await createMemoryFixture(); + + try { + const preference = + "Prefers concise release notes that mention rollback owners."; + const instruction = + "I prefer concise release notes that mention rollback owners."; + const toolDump = + 'webhook payload dump status=ok body={"noise":true,"rows":999}'; + const embedder = createTestEmbedder({ + [instruction]: unitEmbedding(1), + [preference]: unitEmbedding(1), + [toolDump]: unitEmbedding(9), + }); + const store = createMemoryStore(memoryDb(fixture), localContext(), { + embedder, + now: () => TEST_NOW_MS, + }); + await store.createMemory({ + content: preference, + kind: "preference", + idempotencyKey: "memory-test:extraction-focused-query", + }); + const { calls, model } = extractionModel([ + { + kind: "preference", + content: preference, + }, + ]); + + await processMemorySession( + processSessionContext({ + db: memoryDb(fixture), + embedder, + model, + run: { + async load() { + return completedRun({ + transcript: [ + instructionMessage(instruction), + { + type: "toolResult", + toolName: "bash", + text: toolDump, + isError: false, + }, + { + type: "message", + role: "assistant", + text: "Noted.", + }, + ], + }); + }, + }, + }), + ); + + // Hybrid pre-search embeds the focused instruction query, not tool dumps. + expect(embedder.calls.some((batch) => batch.includes(instruction))).toBe( + true, + ); + expect(embedder.calls.some((batch) => batch.includes(toolDump))).toBe( + false, + ); + expect(calls).toHaveLength(1); + const prompt = calls[0]?.prompt ?? ""; + // Existing-memory context came from the focused hybrid hit. + expect(prompt).toMatch( + /[\s\S]*Prefers concise release notes that mention rollback owners\.[\s\S]*<\/existing-memories>/, + ); + } finally { + await fixture.close(); + } + }, 15_000); + it("records empty extraction cost without capturing memories", async () => { const fixture = await createMemoryFixture(); From f2a19ce87cb3b37b87dd5b43dca111856df8b7b6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:22:17 +0000 Subject: [PATCH 2/4] fix(memory): limit extraction pre-search to this turn The run projection prepends prior public-thread messages as ambient context. Pre-search should only embed this turn's run-actor instructions so earlier thread text does not dilute hybrid retrieval. Co-Authored-By: David Cramer --- packages/junior-memory/README.md | 6 +++-- packages/junior-memory/src/process-session.ts | 24 +++++++++---------- packages/junior-memory/tests/storage.test.ts | 14 +++++++++-- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/junior-memory/README.md b/packages/junior-memory/README.md index e41f8016fe..f265052c38 100644 --- a/packages/junior-memory/README.md +++ b/packages/junior-memory/README.md @@ -66,8 +66,10 @@ exported types, tools, and tests are authoritative. with reciprocal rank fusion; provider-specific raw scores are never added together. Each leg stays a bounded top-k probe; lexical ranking never scores the full match set. -- Passive extraction pre-searches with a short user-evidence query, not the full - transcript or tool dumps, then still uses the same hybrid RRF path. +- Passive extraction pre-searches with a short query from this turn's run-actor + instructions only, not prior-thread context or tool dumps, then still uses the + same hybrid RRF path. The extraction model still receives the full run + transcript, including ambient context. - Automatic recall retrieves a broad candidate window, then uses the memory-owned relevance model to admit at most five directly useful memories. An empty result contributes no filler prompt text. diff --git a/packages/junior-memory/src/process-session.ts b/packages/junior-memory/src/process-session.ts index aee7ba739c..c8df917578 100644 --- a/packages/junior-memory/src/process-session.ts +++ b/packages/junior-memory/src/process-session.ts @@ -62,35 +62,33 @@ type MemoryRouteTarget = "drop" | "personal" | "conversation"; /** * Build a short hybrid-search query for passive extraction pre-search. - * Prefer durable run-actor instructions, then other user evidence. Never - * include tool dumps that dilute lexical and embedding retrieval. + * + * The run projection may prepend prior public-thread messages as ambient + * context authority. Pre-search only uses this turn's run-actor instructions so + * earlier thread text does not dilute embeddings or FTS. Tool dumps and prior + * context remain available to the extraction model via the full transcript. */ function buildExtractionSearchQuery( transcript: PluginRunTranscriptEntry[], ): string { const instructionTexts: string[] = []; - const otherUserTexts: string[] = []; for (const entry of transcript) { if (entry.type !== "message" || entry.role !== "user") { continue; } - const text = entry.text?.trim(); - if (!text) { + if (entry.provenance?.authority !== "instruction" || !entry.isRunActor) { continue; } - if (entry.provenance?.authority === "instruction" && entry.isRunActor) { + const text = entry.text?.trim(); + if (text) { instructionTexts.push(text); - continue; } - otherUserTexts.push(text); } - const preferred = - instructionTexts.length > 0 ? instructionTexts : otherUserTexts; - if (preferred.length === 0) { + if (instructionTexts.length === 0) { return ""; } - // Prefer the newest durable utterances; they are the usual extraction targets. - const ordered = [...preferred].reverse(); + // Prefer the newest current-turn instructions; they are the usual targets. + const ordered = [...instructionTexts].reverse(); const parts: string[] = []; let used = 0; for (const text of ordered) { diff --git a/packages/junior-memory/tests/storage.test.ts b/packages/junior-memory/tests/storage.test.ts index fbebab0fd5..ce1c477a23 100644 --- a/packages/junior-memory/tests/storage.test.ts +++ b/packages/junior-memory/tests/storage.test.ts @@ -932,7 +932,7 @@ describe("memory plugin storage", () => { } }, 15_000); - it("pre-searches extraction with focused user evidence instead of tool dumps", async () => { + it("pre-searches extraction from this turn's instructions only", async () => { const fixture = await createMemoryFixture(); try { @@ -940,11 +940,14 @@ describe("memory plugin storage", () => { "Prefers concise release notes that mention rollback owners."; const instruction = "I prefer concise release notes that mention rollback owners."; + const priorThreadContext = + "Earlier in the thread someone mentioned mango chips for QA snacks."; const toolDump = 'webhook payload dump status=ok body={"noise":true,"rows":999}'; const embedder = createTestEmbedder({ [instruction]: unitEmbedding(1), [preference]: unitEmbedding(1), + [priorThreadContext]: unitEmbedding(8), [toolDump]: unitEmbedding(9), }); const store = createMemoryStore(memoryDb(fixture), localContext(), { @@ -972,6 +975,8 @@ describe("memory plugin storage", () => { async load() { return completedRun({ transcript: [ + // Runtime prepends prior public-thread messages as context. + contextMessage(priorThreadContext), instructionMessage(instruction), { type: "toolResult", @@ -991,10 +996,13 @@ describe("memory plugin storage", () => { }), ); - // Hybrid pre-search embeds the focused instruction query, not tool dumps. + // Hybrid pre-search embeds this turn's instruction only. expect(embedder.calls.some((batch) => batch.includes(instruction))).toBe( true, ); + expect( + embedder.calls.some((batch) => batch.includes(priorThreadContext)), + ).toBe(false); expect(embedder.calls.some((batch) => batch.includes(toolDump))).toBe( false, ); @@ -1004,6 +1012,8 @@ describe("memory plugin storage", () => { expect(prompt).toMatch( /[\s\S]*Prefers concise release notes that mention rollback owners\.[\s\S]*<\/existing-memories>/, ); + // Full transcript still reaches the extraction model, including prior context. + expect(prompt).toContain(priorThreadContext); } finally { await fixture.close(); } From f409b6eb9def512e13c52ebdc0aab9d4798cb5ce Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:31:07 +0000 Subject: [PATCH 3/4] feat(memory): include thread context in extraction search Pre-search now spends the query budget on this turn's run-actor instructions first, then ambient thread context. Tool dumps stay excluded. Comments document the selection rules for later augmentation. Co-Authored-By: David Cramer --- packages/junior-memory/README.md | 8 +- packages/junior-memory/src/process-session.ts | 102 ++++++++++++------ packages/junior-memory/tests/storage.test.ts | 16 +-- 3 files changed, 85 insertions(+), 41 deletions(-) diff --git a/packages/junior-memory/README.md b/packages/junior-memory/README.md index f265052c38..0d5d36dc8e 100644 --- a/packages/junior-memory/README.md +++ b/packages/junior-memory/README.md @@ -66,10 +66,10 @@ exported types, tools, and tests are authoritative. with reciprocal rank fusion; provider-specific raw scores are never added together. Each leg stays a bounded top-k probe; lexical ranking never scores the full match set. -- Passive extraction pre-searches with a short query from this turn's run-actor - instructions only, not prior-thread context or tool dumps, then still uses the - same hybrid RRF path. The extraction model still receives the full run - transcript, including ambient context. +- Passive extraction pre-searches with a short hybrid query from this turn's + run-actor instructions plus ambient thread context, not tool dumps, then still + uses the same hybrid RRF path. The extraction model still receives the full run + transcript. - Automatic recall retrieves a broad candidate window, then uses the memory-owned relevance model to admit at most five directly useful memories. An empty result contributes no filler prompt text. diff --git a/packages/junior-memory/src/process-session.ts b/packages/junior-memory/src/process-session.ts index c8df917578..f2b8acfdf7 100644 --- a/packages/junior-memory/src/process-session.ts +++ b/packages/junior-memory/src/process-session.ts @@ -29,7 +29,11 @@ const MEMORY_TOOL_NAMES = new Set([ "searchMemories", ]); const MEMORY_TASK_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; -/** Keep extraction pre-search short enough for focused hybrid retrieval. */ +/** + * Soft cap for the hybrid pre-search query string. + * Keep this small enough that embeddings/FTS stay focused; raise only with + * evidence that longer thread context improves skip/supersede quality. + */ const EXTRACTION_SEARCH_QUERY_MAX_CHARS = 1_500; const EXTRACTION_SEARCH_LIMIT = 10; const extractedMemorySchema = z @@ -61,51 +65,89 @@ const extractedMemoryCacheSchema = z.union([ type MemoryRouteTarget = "drop" | "personal" | "conversation"; /** - * Build a short hybrid-search query for passive extraction pre-search. + * Append newest-first text slices into a bounded hybrid-search query. + * Returns chars consumed, including separators, so callers can fill remaining + * budget from lower-priority sources without re-scanning earlier parts. + */ +function appendSearchQueryParts( + parts: string[], + texts: string[], + used: number, + maxChars: number, +): number { + // Newest first: later instructions/context are usually what extraction cares about. + for (const text of [...texts].reverse()) { + if (used >= maxChars) { + break; + } + const remaining = maxChars - used; + const slice = + text.length <= remaining + ? text + : `${text.slice(0, Math.max(0, remaining - 3)).trimEnd()}...`; + if (!slice) { + continue; + } + // Charge the join separator only when appending onto an existing part. + used += slice.length + (parts.length > 0 ? 1 : 0); + parts.push(slice); + } + return used; +} + +/** + * Build the hybrid pre-search query used only to find existing memories for + * skip/supersede context before passive extraction. * - * The run projection may prepend prior public-thread messages as ambient - * context authority. Pre-search only uses this turn's run-actor instructions so - * earlier thread text does not dilute embeddings or FTS. Tool dumps and prior - * context remain available to the extraction model via the full transcript. + * Intent / future augmentation guide: + * - Include this turn's run-actor instructions: primary extraction targets. + * - Include ambient thread context (`authority: "context"`): prior public-thread + * user messages the runtime prepends so related conversation memories surface. + * - Exclude tool dumps and assistant text: high noise, low durable-fact signal + * for retrieval. They still reach the extraction model via the full transcript. + * - Prefer newest text first within each source class, then spend leftover budget + * on context after instructions. + * - If adding sources later (e.g. selected tool summaries), keep them behind the + * same char budget and document why they beat noise risk. */ function buildExtractionSearchQuery( transcript: PluginRunTranscriptEntry[], ): string { const instructionTexts: string[] = []; + const threadContextTexts: string[] = []; for (const entry of transcript) { if (entry.type !== "message" || entry.role !== "user") { continue; } - if (entry.provenance?.authority !== "instruction" || !entry.isRunActor) { + const text = entry.text?.trim(); + if (!text) { continue; } - const text = entry.text?.trim(); - if (text) { + if (entry.provenance?.authority === "instruction" && entry.isRunActor) { instructionTexts.push(text); + continue; + } + // Runtime-owned prior-thread projection uses context authority, not instruction. + if (entry.provenance?.authority === "context") { + threadContextTexts.push(text); } } - if (instructionTexts.length === 0) { + if (instructionTexts.length === 0 && threadContextTexts.length === 0) { return ""; } - // Prefer the newest current-turn instructions; they are the usual targets. - const ordered = [...instructionTexts].reverse(); const parts: string[] = []; - let used = 0; - for (const text of ordered) { - if (used >= EXTRACTION_SEARCH_QUERY_MAX_CHARS) { - break; - } - const remaining = EXTRACTION_SEARCH_QUERY_MAX_CHARS - used; - const slice = - text.length <= remaining - ? text - : `${text.slice(0, Math.max(0, remaining - 3)).trimEnd()}...`; - if (!slice) { - continue; - } - parts.push(slice); - used += slice.length + (parts.length > 1 ? 1 : 0); - } + let used = appendSearchQueryParts( + parts, + instructionTexts, + 0, + EXTRACTION_SEARCH_QUERY_MAX_CHARS, + ); + appendSearchQueryParts( + parts, + threadContextTexts, + used, + EXTRACTION_SEARCH_QUERY_MAX_CHARS, + ); return parts.join("\n").trim(); } @@ -312,8 +354,8 @@ export async function processMemorySession( }); await store.archiveExpiredMemories(); const extraction = await getTaskExtraction(context, async () => { - // Hybrid search still fuses vector + lexical ranks. Extraction only needs a - // short query so embeddings and FTS stay focused on durable user evidence. + // Hybrid search still fuses vector + lexical ranks. The query is intentionally + // turn instructions + ambient thread context, never tool dumps. const existingMemories = extractionSearchQuery.length > 0 ? await store.searchMemories({ diff --git a/packages/junior-memory/tests/storage.test.ts b/packages/junior-memory/tests/storage.test.ts index ce1c477a23..e02df986f2 100644 --- a/packages/junior-memory/tests/storage.test.ts +++ b/packages/junior-memory/tests/storage.test.ts @@ -932,7 +932,7 @@ describe("memory plugin storage", () => { } }, 15_000); - it("pre-searches extraction from this turn's instructions only", async () => { + it("pre-searches extraction with this turn's instructions and thread context", async () => { const fixture = await createMemoryFixture(); try { @@ -996,13 +996,15 @@ describe("memory plugin storage", () => { }), ); - // Hybrid pre-search embeds this turn's instruction only. - expect(embedder.calls.some((batch) => batch.includes(instruction))).toBe( - true, - ); + // Hybrid pre-search embeds instruction + ambient thread context together. expect( - embedder.calls.some((batch) => batch.includes(priorThreadContext)), - ).toBe(false); + embedder.calls.some((batch) => + batch.some( + (text) => + text.includes(instruction) && text.includes(priorThreadContext), + ), + ), + ).toBe(true); expect(embedder.calls.some((batch) => batch.includes(toolDump))).toBe( false, ); From 9882f000ca3bae761fa5bf8d56bedf735371d61d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:53:12 +0000 Subject: [PATCH 4/4] ref(memory): simplify extraction pre-search Co-Authored-By: David Cramer --- packages/junior-memory/README.md | 7 +- packages/junior-memory/src/process-session.ts | 98 ++++++------------- packages/junior-memory/src/store.ts | 3 +- packages/junior-memory/tests/storage.test.ts | 53 ++++++---- 4 files changed, 68 insertions(+), 93 deletions(-) diff --git a/packages/junior-memory/README.md b/packages/junior-memory/README.md index 0d5d36dc8e..afb13cf647 100644 --- a/packages/junior-memory/README.md +++ b/packages/junior-memory/README.md @@ -64,10 +64,9 @@ exported types, tools, and tests are authoritative. - Candidate review resolves duplicates and supersession before activation. - Search combines independently ranked vector and PostgreSQL full-text matches with reciprocal rank fusion; provider-specific raw scores are never added - together. Each leg stays a bounded top-k probe; lexical ranking never scores - the full match set. -- Passive extraction pre-searches with a short hybrid query from this turn's - run-actor instructions plus ambient thread context, not tool dumps, then still + together. +- Passive extraction pre-searches with a short hybrid query from the run actor's + current instruction plus other user conversation context, not tool dumps, then uses the same hybrid RRF path. The extraction model still receives the full run transcript. - Automatic recall retrieves a broad candidate window, then uses the diff --git a/packages/junior-memory/src/process-session.ts b/packages/junior-memory/src/process-session.ts index f2b8acfdf7..cd91651184 100644 --- a/packages/junior-memory/src/process-session.ts +++ b/packages/junior-memory/src/process-session.ts @@ -35,7 +35,6 @@ const MEMORY_TASK_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; * evidence that longer thread context improves skip/supersede quality. */ const EXTRACTION_SEARCH_QUERY_MAX_CHARS = 1_500; -const EXTRACTION_SEARCH_LIMIT = 10; const extractedMemorySchema = z .object({ content: z.string().min(1), @@ -65,56 +64,20 @@ const extractedMemoryCacheSchema = z.union([ type MemoryRouteTarget = "drop" | "personal" | "conversation"; /** - * Append newest-first text slices into a bounded hybrid-search query. - * Returns chars consumed, including separators, so callers can fill remaining - * budget from lower-priority sources without re-scanning earlier parts. - */ -function appendSearchQueryParts( - parts: string[], - texts: string[], - used: number, - maxChars: number, -): number { - // Newest first: later instructions/context are usually what extraction cares about. - for (const text of [...texts].reverse()) { - if (used >= maxChars) { - break; - } - const remaining = maxChars - used; - const slice = - text.length <= remaining - ? text - : `${text.slice(0, Math.max(0, remaining - 3)).trimEnd()}...`; - if (!slice) { - continue; - } - // Charge the join separator only when appending onto an existing part. - used += slice.length + (parts.length > 0 ? 1 : 0); - parts.push(slice); - } - return used; -} - -/** - * Build the hybrid pre-search query used only to find existing memories for - * skip/supersede context before passive extraction. + * Build the query used to find existing memories before passive extraction. * - * Intent / future augmentation guide: - * - Include this turn's run-actor instructions: primary extraction targets. - * - Include ambient thread context (`authority: "context"`): prior public-thread - * user messages the runtime prepends so related conversation memories surface. - * - Exclude tool dumps and assistant text: high noise, low durable-fact signal - * for retrieval. They still reach the extraction model via the full transcript. - * - Prefer newest text first within each source class, then spend leftover budget - * on context after instructions. - * - If adding sources later (e.g. selected tool summaries), keep them behind the - * same char budget and document why they beat noise risk. + * The run actor's current instruction is strongest, followed by other user + * evidence: prior public-thread context and instructions from other current-turn + * participants. Tool results and assistant text remain available to extraction + * but stay out of search until a small rewrite can turn them into concise facts. + * Any future source must share this budget so retrieval does not drift back to + * embedding the full transcript. */ function buildExtractionSearchQuery( transcript: PluginRunTranscriptEntry[], ): string { - const instructionTexts: string[] = []; - const threadContextTexts: string[] = []; + const instructions: string[] = []; + const conversationContext: string[] = []; for (const entry of transcript) { if (entry.type !== "message" || entry.role !== "user") { continue; @@ -124,31 +87,26 @@ function buildExtractionSearchQuery( continue; } if (entry.provenance?.authority === "instruction" && entry.isRunActor) { - instructionTexts.push(text); - continue; - } - // Runtime-owned prior-thread projection uses context authority, not instruction. - if (entry.provenance?.authority === "context") { - threadContextTexts.push(text); + instructions.push(text); + } else if ( + entry.provenance?.authority === "context" || + (entry.provenance?.authority === "instruction" && + entry.isRunActor === false && + Boolean(entry.provenance.actor)) + ) { + conversationContext.push(text); } } - if (instructionTexts.length === 0 && threadContextTexts.length === 0) { - return ""; + const query = [ + ...instructions.reverse(), + ...conversationContext.reverse(), + ].join("\n"); + if (query.length <= EXTRACTION_SEARCH_QUERY_MAX_CHARS) { + return query; } - const parts: string[] = []; - let used = appendSearchQueryParts( - parts, - instructionTexts, - 0, - EXTRACTION_SEARCH_QUERY_MAX_CHARS, - ); - appendSearchQueryParts( - parts, - threadContextTexts, - used, - EXTRACTION_SEARCH_QUERY_MAX_CHARS, - ); - return parts.join("\n").trim(); + return `${query + .slice(0, EXTRACTION_SEARCH_QUERY_MAX_CHARS - 3) + .trimEnd()}...`; } function recordCapturedMemory( @@ -355,11 +313,11 @@ export async function processMemorySession( await store.archiveExpiredMemories(); const extraction = await getTaskExtraction(context, async () => { // Hybrid search still fuses vector + lexical ranks. The query is intentionally - // turn instructions + ambient thread context, never tool dumps. + // user conversation evidence, never raw tool results or assistant text. const existingMemories = extractionSearchQuery.length > 0 ? await store.searchMemories({ - limit: EXTRACTION_SEARCH_LIMIT, + limit: 10, query: extractionSearchQuery, }) : []; diff --git a/packages/junior-memory/src/store.ts b/packages/junior-memory/src/store.ts index 44f04568c1..d97ca83f1d 100644 --- a/packages/junior-memory/src/store.ts +++ b/packages/junior-memory/src/store.ts @@ -53,8 +53,7 @@ const PREFERENCE_ADJUDICATION_CANDIDATE_LIMIT = 10; const PREFERENCE_ADJUDICATION_VECTOR_LIMIT = 5; const VECTOR_SEARCH_OVERFETCH = 4; const LEXICAL_RANK_OVERFETCH = 4; -/** Cap FTS rank work to a small hybrid top-k window, not the full match set. */ -const MAX_LEXICAL_RANK_CANDIDATES = 200; +const MAX_LEXICAL_RANK_CANDIDATES = 1_000; const MAX_MEMORY_CONTENT_CHARS = 4_000; const EMBEDDING_METRIC = "cosine"; diff --git a/packages/junior-memory/tests/storage.test.ts b/packages/junior-memory/tests/storage.test.ts index e02df986f2..dfe092651b 100644 --- a/packages/junior-memory/tests/storage.test.ts +++ b/packages/junior-memory/tests/storage.test.ts @@ -932,7 +932,7 @@ describe("memory plugin storage", () => { } }, 15_000); - it("pre-searches extraction with this turn's instructions and thread context", async () => { + it("pre-searches extraction with user conversation context", async () => { const fixture = await createMemoryFixture(); try { @@ -942,13 +942,19 @@ describe("memory plugin storage", () => { "I prefer concise release notes that mention rollback owners."; const priorThreadContext = "Earlier in the thread someone mentioned mango chips for QA snacks."; + const participantInstruction = + "For releases, the rollback owner is listed in the deploy checklist."; + const participantActor: Actor = { + platform: "local", + userId: "local-participant", + }; const toolDump = 'webhook payload dump status=ok body={"noise":true,"rows":999}'; + const assistantReply = "Noted."; + const unattributedUserText = + "Unattributed ambient text should not search."; const embedder = createTestEmbedder({ - [instruction]: unitEmbedding(1), [preference]: unitEmbedding(1), - [priorThreadContext]: unitEmbedding(8), - [toolDump]: unitEmbedding(9), }); const store = createMemoryStore(memoryDb(fixture), localContext(), { embedder, @@ -959,12 +965,7 @@ describe("memory plugin storage", () => { kind: "preference", idempotencyKey: "memory-test:extraction-focused-query", }); - const { calls, model } = extractionModel([ - { - kind: "preference", - content: preference, - }, - ]); + const { calls, model } = extractionModel([]); await processMemorySession( processSessionContext({ @@ -974,9 +975,19 @@ describe("memory plugin storage", () => { run: { async load() { return completedRun({ + actors: [localInstructionActor, participantActor], transcript: [ // Runtime prepends prior public-thread messages as context. contextMessage(priorThreadContext), + { + type: "message", + role: "user", + text: unattributedUserText, + }, + nonRunActorInstructionMessage( + participantInstruction, + participantActor, + ), instructionMessage(instruction), { type: "toolResult", @@ -987,7 +998,7 @@ describe("memory plugin storage", () => { { type: "message", role: "assistant", - text: "Noted.", + text: assistantReply, }, ], }); @@ -996,19 +1007,27 @@ describe("memory plugin storage", () => { }), ); - // Hybrid pre-search embeds instruction + ambient thread context together. + // Hybrid pre-search embeds all user conversation evidence together. expect( embedder.calls.some((batch) => batch.some( (text) => - text.includes(instruction) && text.includes(priorThreadContext), + text.includes(instruction) && + text.includes(priorThreadContext) && + text.includes(participantInstruction), ), ), ).toBe(true); - expect(embedder.calls.some((batch) => batch.includes(toolDump))).toBe( - false, - ); - expect(calls).toHaveLength(1); + expect( + embedder.calls.some((batch) => + batch.some( + (text) => + text.includes(toolDump) || + text.includes(assistantReply) || + text.includes(unattributedUserText), + ), + ), + ).toBe(false); const prompt = calls[0]?.prompt ?? ""; // Existing-memory context came from the focused hybrid hit. expect(prompt).toMatch(