Skip to content
Merged
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
3 changes: 2 additions & 1 deletion src/functions/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/functions/enrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
`<agentmemory-past-errors>\n${bugs}\n</agentmemory-past-errors>`,
Expand Down
3 changes: 2 additions & 1 deletion src/functions/working-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}
Expand Down
12 changes: 12 additions & 0 deletions src/state/memory-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import type { CompressedObservation, Lesson, Memory } from "../types.js";

export function renderMemoryText(
memory: Pick<Memory, "title" | "content">,
): 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 +
Expand Down
36 changes: 36 additions & 0 deletions test/durable-recall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
43 changes: 43 additions & 0 deletions test/enrich.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <shutdown>.",
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 &amp; lock: Race &amp; lock failures occur before &lt;shutdown&gt;.",
);
expect(result.context).not.toContain("<shutdown>");
});
});
44 changes: 44 additions & 0 deletions test/memory-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
22 changes: 20 additions & 2 deletions test/working-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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 [];
Expand All @@ -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 () => {
Expand Down