evOo-local/packages/adapters/src/remember.test.ts
Claude 4875753d9c
feat(memory): remember appends dated notes instead of replacing memory
The remember tool rewrote the bot's whole MEMORY.md with each call, so every
new fact erased the earlier ones unless the model re-sent the full document.

- MemoryCommitRequest gains mode "append" (the store joins the note onto the
  current content inside the same serializable transaction and still writes a
  revision row) and every commit is capped at 16 KB per document.
- remember appends a dated bullet ("- YYYY-MM-DD: ...", the user's local date)
  by default and rewrites only with mode "replace"; a full document returns an
  error that tells the bot to condense it. Retries of the same tool call stay
  single appends through the existing effect record.
- Scripted runtime memory entries can append too; the scripted "remember"
  script now appends instead of overwriting the bot's memory.
- When memory does not fit the run's 32 KB budget, the prompt keeps a
  document's newest lines instead of its oldest.
- memory.update maps an oversized save to BAD_REQUEST.
- docs/PRODUCT.md records that UserFact is the cross-space user model and the
  space MEMORY.md stays space notes (no migration).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:10:25 +00:00

97 lines
3.0 KiB
TypeScript

import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit";
import { MemoryDocumentTooLargeError } from "@rakazo/adapter-kit";
import { describe, expect, it, vi } from "vitest";
import { datedMemoryNote, rememberFromTool } from "./remember.js";
const context: AdapterContext = {
operationId: "run-1",
traceId: "run-1",
spaceId: "space-1",
userId: "user-1",
signal: new AbortController().signal,
};
function remember(args: Record<string, unknown>, commit = vi.fn(async () => ({}) as never)) {
return {
commit,
result: rememberFromTool({ commit }, context, {
botId: "bot-1",
runId: "run-1",
threadId: "thread-1",
date: "2026-10-03",
args,
}),
};
}
describe("datedMemoryNote", () => {
it("is one dated bullet whose later lines stay inside it", () => {
expect(datedMemoryNote(" Prefers tea ", "2026-10-03")).toBe("- 2026-10-03: Prefers tea");
expect(datedMemoryNote("Trip plan\nBerlin first\n\nthen Vienna", "2026-10-03")).toBe(
"- 2026-10-03: Trip plan\n Berlin first\n\n then Vienna",
);
});
});
describe("rememberFromTool", () => {
it("appends a dated note to MEMORY.md by default", async () => {
const { commit, result } = remember({ content: "Prefers tea" });
await expect(result).resolves.toEqual({ ok: true });
expect(commit).toHaveBeenCalledWith(
{
scope: "bot",
botId: "bot-1",
path: "MEMORY.md",
content: "- 2026-10-03: Prefers tea",
mode: "append",
sourceRunId: "run-1",
sourceThreadId: "thread-1",
} satisfies MemoryCommitRequest,
context,
);
});
it("rewrites the whole document only when asked to", async () => {
const { commit, result } = remember({
content: "# Chief\n\n- Prefers tea\n",
path: "notes.md",
mode: "replace",
});
await expect(result).resolves.toEqual({ ok: true });
expect(commit).toHaveBeenCalledWith(
expect.objectContaining({
path: "notes.md",
content: "# Chief\n\n- Prefers tea\n",
mode: "replace",
}),
context,
);
});
it("refuses an empty note without writing", async () => {
const { commit, result } = remember({ content: " " });
await expect(result).resolves.toEqual({ error: "content is empty" });
expect(commit).not.toHaveBeenCalled();
});
it("tells the bot to condense a full document", async () => {
const commit = vi.fn(async () => {
throw new MemoryDocumentTooLargeError("MEMORY.md");
});
const { result } = remember({ content: "One more fact" }, commit);
await expect(result).resolves.toEqual({
error:
'MEMORY.md is full (16 KB). Condense it with mode "replace", then save this note again.',
});
});
it("lets other storage failures fail the call", async () => {
const commit = vi.fn(async () => {
throw new Error("database unavailable");
});
await expect(remember({ content: "fact" }, commit).result).rejects.toThrow(
"database unavailable",
);
});
});