Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/junior-memory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
76 changes: 66 additions & 10 deletions packages/junior-memory/src/process-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<typeof capturedMemory>[],
result: CreateMemoryResult,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
108 changes: 108 additions & 0 deletions packages/junior-memory/tests/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
/<existing-memories>[\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();

Expand Down
Loading