diff --git a/src/functions/context.ts b/src/functions/context.ts index 65c7ec551..d453cb00e 100644 --- a/src/functions/context.ts +++ b/src/functions/context.ts @@ -20,6 +20,7 @@ import { } from "./slots.js"; import { getAgentId, isAgentScopeIsolated } from "../config.js"; import { selectDurableMemories } from "../state/memory-selection.js"; +import { renderMemoryText } from "../state/memory-utils.js"; function estimateTokens(text: string): number { return Math.ceil(text.length / 3); @@ -136,7 +137,7 @@ export function registerContextFunction( }); if (durableMemories.length > 0) { const content = `## Durable Memories\n${durableMemories - .map((memory) => `- ${memory.title}: ${memory.content}`) + .map((memory) => `- ${renderMemoryText(memory)}`) .join("\n")}`; blocks.push({ type: "memory", diff --git a/src/functions/enrich.ts b/src/functions/enrich.ts index 032ec97b4..8d884b5d5 100644 --- a/src/functions/enrich.ts +++ b/src/functions/enrich.ts @@ -3,6 +3,7 @@ import type { Memory } from "../types.js"; import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { logger } from "../logger.js"; +import { renderMemoryText } from "../state/memory-utils.js"; const MAX_CONTEXT_LENGTH = 4000; @@ -111,7 +112,7 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void { if (bugMemories.length > 0) { const bugs = bugMemories .slice(0, 3) - .map((m) => `- ${escapeXml(m.title)}: ${escapeXml(m.content)}`) + .map((memory) => `- ${escapeXml(renderMemoryText(memory))}`) .join("\n"); parts.push( `\n${bugs}\n`, diff --git a/src/functions/working-memory.ts b/src/functions/working-memory.ts index 127195e52..a50ee8148 100644 --- a/src/functions/working-memory.ts +++ b/src/functions/working-memory.ts @@ -5,6 +5,7 @@ import { StateKV } from "../state/kv.js"; import { recordAudit } from "./audit.js"; import { recordAccessBatch } from "./access-tracker.js"; import { logger } from "../logger.js"; +import { renderMemoryText } from "../state/memory-utils.js"; const CORE_SCOPE = "mem:core-memory"; @@ -147,7 +148,7 @@ export function registerWorkingMemoryFunctions( for (const mem of active) { const tokens = estimateTokens(mem.content); if (usedTokens + tokens > budget) continue; - archivalLines.push(`- [${mem.type}] ${mem.title}: ${mem.content}`); + archivalLines.push(`- [${mem.type}] ${renderMemoryText(mem)}`); archivalIds.push(mem.id); usedTokens += tokens; } diff --git a/src/state/memory-utils.ts b/src/state/memory-utils.ts index 8428da2a6..34bbdaf67 100644 --- a/src/state/memory-utils.ts +++ b/src/state/memory-utils.ts @@ -1,5 +1,17 @@ import type { CompressedObservation, Lesson, Memory } from "../types.js"; +export function renderMemoryText( + memory: Pick, +): string { + const slicedTitle = memory.content.slice(0, 80); + const safeTitle = /[\uD800-\uDBFF]$/.test(slicedTitle) + ? slicedTitle.slice(0, -1) + : slicedTitle; + return memory.title === slicedTitle || memory.title === safeTitle + ? memory.content + : `${memory.title}: ${memory.content}`; +} + // Wraps a Memory record in the CompressedObservation shape that // SearchIndex / VectorIndex / enrichment paths consume. Memories share // the same searchable fields as observations (title + content + diff --git a/test/durable-recall.test.ts b/test/durable-recall.test.ts index eb7b7d262..74275ee5a 100644 --- a/test/durable-recall.test.ts +++ b/test/durable-recall.test.ts @@ -173,6 +173,42 @@ describe("durable memory recall", () => { expect(accessRows).toHaveLength(10); }); + it("does not repeat a durable memory title derived from its content", async () => { + const derivedContent = + "Always pin lockfiles before dependency updates so installs stay reproducible across every supported agent runtime."; + const memories = [ + makeMemory({ + id: "mem_derived_title", + title: derivedContent.slice(0, 80), + content: derivedContent, + project: "project-a", + }), + makeMemory({ + id: "mem_independent_title", + title: "Database migration", + content: "Database migration requires downtime.", + project: "project-a", + }), + ]; + for (const memory of memories) { + await kv.set(KV.memories, memory.id, memory); + } + registerContextFunction(sdk as never, kv as never, 20_000); + + const result = (await sdk.trigger("mem::context", { + sessionId: "ses_current", + project: "project-a", + })) as { context: string }; + + expect(result.context).toContain(`- ${derivedContent}`); + expect(result.context).not.toContain( + `${derivedContent.slice(0, 80)}: ${derivedContent}`, + ); + expect(result.context).toContain( + "- Database migration: Database migration requires downtime.", + ); + }); + it("returns capped, ranked, scoped durable memories when hybrid search is empty", async () => { const candidates = [ makeMemory({ id: "mem_global", content: "needle sentinel global", agentId: "agent-a", strength: 10 }), diff --git a/test/enrich.test.ts b/test/enrich.test.ts index adfb31f86..7504cfbb0 100644 --- a/test/enrich.test.ts +++ b/test/enrich.test.ts @@ -210,4 +210,47 @@ describe("Enrich Function", () => { expect(result.context).toContain("Race condition"); expect(result.context).not.toContain("Singleton pattern"); }); + + it("does not repeat a bug memory title derived from its content", async () => { + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + sdk.overrideTrigger("mem::search", async () => ({ results: [] })); + const content = "Race condition in worker pool"; + const memory = makeMemory({ + id: "bug_derived_title", + title: content, + content, + files: ["src/worker.ts"], + }); + await kv.set("mem:memories", memory.id, memory); + + const result = (await sdk.trigger("mem::enrich", { + sessionId: "ses_1", + files: ["src/worker.ts"], + })) as { context: string }; + + expect(result.context).toContain(`- ${content}`); + expect(result.context).not.toContain(`${content}: ${content}`); + }); + + it("preserves an authored prefix title and escapes the rendered memory", async () => { + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + sdk.overrideTrigger("mem::search", async () => ({ results: [] })); + const memory = makeMemory({ + id: "bug_authored_title", + title: "Race & lock", + content: "Race & lock failures occur before .", + files: ["src/worker.ts"], + }); + await kv.set("mem:memories", memory.id, memory); + + const result = (await sdk.trigger("mem::enrich", { + sessionId: "ses_1", + files: ["src/worker.ts"], + })) as { context: string }; + + expect(result.context).toContain( + "- Race & lock: Race & lock failures occur before <shutdown>.", + ); + expect(result.context).not.toContain(""); + }); }); diff --git a/test/memory-utils.test.ts b/test/memory-utils.test.ts new file mode 100644 index 000000000..57fc51a18 --- /dev/null +++ b/test/memory-utils.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { renderMemoryText } from "../src/state/memory-utils.js"; + +const longContent = + "Always pin lockfiles before dependency updates so installs stay reproducible across every supported runtime."; +const surrogateContent = `${"a".repeat(79)}😀tail`; +const rawSurrogateTitle = surrogateContent.slice(0, 80); + +describe("renderMemoryText", () => { + it.each([ + { + name: "short derived title", + title: "Always pin lockfiles", + content: "Always pin lockfiles", + expected: "Always pin lockfiles", + }, + { + name: "80-character derived title", + title: longContent.slice(0, 80), + content: longContent, + expected: longContent, + }, + { + name: "legacy raw surrogate slice", + title: rawSurrogateTitle, + content: surrogateContent, + expected: surrogateContent, + }, + { + name: "safe surrogate slice", + title: rawSurrogateTitle.slice(0, -1), + content: surrogateContent, + expected: surrogateContent, + }, + { + name: "authored prefix title", + title: "Database migration", + content: "Database migration requires downtime.", + expected: "Database migration: Database migration requires downtime.", + }, + ])("renders $name", ({ title, content, expected }) => { + expect(renderMemoryText({ title, content })).toBe(expected); + }); +}); diff --git a/test/working-memory.test.ts b/test/working-memory.test.ts index 446438d1d..b62c11e4d 100644 --- a/test/working-memory.test.ts +++ b/test/working-memory.test.ts @@ -85,6 +85,8 @@ describe("working-memory", () => { }); it("working-context builds core + archival sections", async () => { + const derivedContent = + "Always pin lockfiles before dependency updates so installs stay reproducible across every supported runtime."; mockKv.list.mockImplementation((scope: string) => { if (scope === "mem:core-memory") { return [ @@ -104,12 +106,21 @@ describe("working-memory", () => { { id: "m1", type: "pattern", - title: "API pattern", - content: "REST endpoints follow /api/resource convention", + title: derivedContent.slice(0, 80), + content: derivedContent, isLatest: true, strength: 0.8, updatedAt: new Date().toISOString(), }, + { + id: "m2", + type: "architecture", + title: "Database migration", + content: "Database migration requires downtime.", + isLatest: true, + strength: 0.7, + updatedAt: new Date().toISOString(), + }, ]; } return []; @@ -123,6 +134,13 @@ describe("working-memory", () => { expect(result.coreEntries).toBe(1); expect(result.context).toContain("Core Memory"); expect(result.context).toContain("Use iii primitives"); + expect(result.context).toContain(`- [pattern] ${derivedContent}`); + expect(result.context).not.toContain( + `${derivedContent.slice(0, 80)}: ${derivedContent}`, + ); + expect(result.context).toContain( + "- [architecture] Database migration: Database migration requires downtime.", + ); }); it("auto-page removes lowest-value unpinned entries", async () => {