diff --git a/packages/junior-memory/README.md b/packages/junior-memory/README.md index 4a80526879..afb13cf647 100644 --- a/packages/junior-memory/README.md +++ b/packages/junior-memory/README.md @@ -65,6 +65,10 @@ exported types, tools, and tests are authoritative. - Search combines independently ranked vector and PostgreSQL full-text matches with reciprocal rank fusion; provider-specific raw scores are never added 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 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..cd91651184 100644 --- a/packages/junior-memory/src/process-session.ts +++ b/packages/junior-memory/src/process-session.ts @@ -29,6 +29,12 @@ const MEMORY_TOOL_NAMES = new Set([ "searchMemories", ]); const MEMORY_TASK_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +/** + * 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 extractedMemorySchema = z .object({ content: z.string().min(1), @@ -57,6 +63,52 @@ const extractedMemoryCacheSchema = z.union([ /** Where a passively extracted memory may be stored, or dropped when unproven. */ type MemoryRouteTarget = "drop" | "personal" | "conversation"; +/** + * Build the query used to find existing memories before passive extraction. + * + * 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 instructions: string[] = []; + const conversationContext: 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) { + instructions.push(text); + } else if ( + entry.provenance?.authority === "context" || + (entry.provenance?.authority === "instruction" && + entry.isRunActor === false && + Boolean(entry.provenance.actor)) + ) { + conversationContext.push(text); + } + } + const query = [ + ...instructions.reverse(), + ...conversationContext.reverse(), + ].join("\n"); + if (query.length <= EXTRACTION_SEARCH_QUERY_MAX_CHARS) { + return query; + } + return `${query + .slice(0, EXTRACTION_SEARCH_QUERY_MAX_CHARS - 3) + .trimEnd()}...`; +} + function recordCapturedMemory( captured: ReturnType[], result: CreateMemoryResult, @@ -240,14 +292,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 +312,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. The query is intentionally + // user conversation evidence, never raw tool results or assistant text. + const existingMemories = + extractionSearchQuery.length > 0 + ? await store.searchMemories({ + limit: 10, + query: extractionSearchQuery, + }) + : []; return await agent.extractSessionMemories({ existingMemories: existingMemories.map((memory) => ({ content: memory.content, diff --git a/packages/junior-memory/tests/storage.test.ts b/packages/junior-memory/tests/storage.test.ts index da41c8e352..dfe092651b 100644 --- a/packages/junior-memory/tests/storage.test.ts +++ b/packages/junior-memory/tests/storage.test.ts @@ -932,6 +932,114 @@ describe("memory plugin storage", () => { } }, 15_000); + it("pre-searches extraction with user conversation context", 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 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({ + [preference]: unitEmbedding(1), + }); + 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([]); + + await processMemorySession( + processSessionContext({ + db: memoryDb(fixture), + embedder, + model, + 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", + toolName: "bash", + text: toolDump, + isError: false, + }, + { + type: "message", + role: "assistant", + text: assistantReply, + }, + ], + }); + }, + }, + }), + ); + + // 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(participantInstruction), + ), + ), + ).toBe(true); + 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( + /[\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(); + } + }, 15_000); + it("records empty extraction cost without capturing memories", async () => { const fixture = await createMemoryFixture();