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
This commit is contained in:
parent
3d180dc2dd
commit
4875753d9c
@ -11,6 +11,7 @@ import type {
|
||||
} from "@rakazo/adapter-kit";
|
||||
import {
|
||||
computerControlExpireJobKey,
|
||||
MemoryDocumentTooLargeError,
|
||||
messagingDeliverJob,
|
||||
routineJobKey,
|
||||
routineWakeupJob,
|
||||
@ -2427,7 +2428,8 @@ export function createRouter(deps: RouterDeps) {
|
||||
},
|
||||
});
|
||||
if (!doc) throw new IsolationError();
|
||||
const updated = await deps.memory.commit(
|
||||
const updated = await deps.memory
|
||||
.commit(
|
||||
{
|
||||
scope: doc.scope as "bot" | "user",
|
||||
botId: doc.botId ?? undefined,
|
||||
@ -2441,7 +2443,13 @@ export function createRouter(deps: RouterDeps) {
|
||||
userId: context.actor.userId,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
);
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof MemoryDocumentTooLargeError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
id: updated.id,
|
||||
scope: doc.scope as "bot" | "user",
|
||||
|
||||
@ -114,6 +114,36 @@ test("memory and skills are readable and editable in the app", async ({ page },
|
||||
await expect(botDocEditor).toHaveValue(new RegExp(botMarker));
|
||||
await botMemory.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
|
||||
// Remembering appends a dated note and keeps the edit above.
|
||||
await rpc(page, "threads/send", {
|
||||
botId: activeBotId(page),
|
||||
text: `remember that the tester ${stamp} prefers tea`,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await rpc<MemoryDocument[]>(page, "memory/list", {
|
||||
botId: activeBotId(page),
|
||||
scope: "bot",
|
||||
})
|
||||
).find((doc) => doc.path === "MEMORY.md")?.content,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toMatch(
|
||||
new RegExp(
|
||||
`^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`,
|
||||
),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ??
|
||||
null,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBeNull();
|
||||
|
||||
// Skills: create one through the editor, reopen it, edit, then delete it.
|
||||
// Builtin catalog is currently empty; user skills still cover create/edit/delete.
|
||||
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();
|
||||
|
||||
@ -127,6 +127,19 @@ Called from `Onboarding.tsx` after first bot ensure.
|
||||
|
||||
Seeded in user `MEMORY.md` (`user-relationship-contract.ts`). Persona copy and prompts follow. Details: `docs/OO-RELATIONSHIP.md`.
|
||||
|
||||
## Memory and the user model
|
||||
|
||||
| Store | Holds | Written by |
|
||||
|-------|-------|------------|
|
||||
| `UserProfile` + `UserFact` (Settings → About you) | The user model: who the user is, across spaces; a fact can be limited to chosen spaces | `update_user_profile`, onboarding, chat consolidation, the user |
|
||||
| User-scoped `MEMORY.md` (Settings → Memory) | Space notes shared by the bots of one space | The user, onboarding harvest |
|
||||
| Bot-scoped `MEMORY.md` (bot → Knowledge) | One bot's notes in one space | `remember` appends dated notes; `mode: "replace"` rewrites |
|
||||
|
||||
Space notes are not migrated into facts: facts are short, consented statements about the user,
|
||||
space notes are free-form context for one space. A memory document holds at most 16 KB, so a
|
||||
bot's notes and the space notes both reach the prompt; when it is full, `remember` asks the bot
|
||||
to condense it.
|
||||
|
||||
## Capability vision — ambient & video (future roadmap)
|
||||
|
||||
**This PR does not build surveillance.** Current Gesamterfahrung stays **Stage + sound + calm onboarding** (`VOICE.md`). The following is Ingo's long-term direction — optional layers only.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export * from "./background-jobs.js";
|
||||
export * from "./cloud-agents.js";
|
||||
export * from "./interfaces.js";
|
||||
export * from "./memory.js";
|
||||
export * from "./registry.js";
|
||||
export * from "./types.js";
|
||||
|
||||
13
packages/adapter-kit/src/memory.ts
Normal file
13
packages/adapter-kit/src/memory.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Largest Markdown memory document a commit may store, in UTF-8 bytes: half of what a run reads,
|
||||
* so a bot's document and the space's shared one both reach the prompt.
|
||||
*/
|
||||
export const MEMORY_DOCUMENT_MAX_BYTES = 16 * 1024;
|
||||
|
||||
/** A commit would grow a memory document past MEMORY_DOCUMENT_MAX_BYTES. */
|
||||
export class MemoryDocumentTooLargeError extends Error {
|
||||
constructor(readonly path: string) {
|
||||
super(`${path} would exceed ${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB`);
|
||||
this.name = "MemoryDocumentTooLargeError";
|
||||
}
|
||||
}
|
||||
@ -250,6 +250,8 @@ export interface MemoryCommitRequest {
|
||||
botId?: string;
|
||||
path: string;
|
||||
content: string;
|
||||
/** "replace" (default) stores `content` as the whole document; "append" adds it at the end. */
|
||||
mode?: "replace" | "append";
|
||||
sourceRunId?: string;
|
||||
sourceThreadId?: string;
|
||||
}
|
||||
@ -435,7 +437,7 @@ export interface ScriptedTurn {
|
||||
ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> };
|
||||
takeover?: { reason: string };
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
memory?: Array<{ scope: "bot" | "user"; path: string; content: string }>;
|
||||
memory?: Array<Pick<MemoryCommitRequest, "scope" | "path" | "content" | "mode">>;
|
||||
complete?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -378,12 +378,18 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
},
|
||||
{
|
||||
name: "remember",
|
||||
description: "Store a durable fact in this bot's explicit memory.",
|
||||
description:
|
||||
"Add a durable note to this bot's memory document; it is appended with today's date. Use mode replace only to rewrite the whole document, e.g. to condense it when it is full.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "string" },
|
||||
path: { type: "string" },
|
||||
path: { type: "string", description: "Memory document, default MEMORY.md." },
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["append", "replace"],
|
||||
description: "append (default) adds a note; replace sets the whole document to content.",
|
||||
},
|
||||
},
|
||||
required: ["content"],
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { formatCurrentTimeInstruction, loadUserTimeZone } from "./current-time.js";
|
||||
import { formatCurrentTimeInstruction, formatLocalDate, loadUserTimeZone } from "./current-time.js";
|
||||
|
||||
describe("formatCurrentTimeInstruction", () => {
|
||||
it("anchors the present moment in UTC with the weekday and no sub-second noise", () => {
|
||||
@ -93,3 +93,12 @@ describe("loadUserTimeZone", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLocalDate", () => {
|
||||
it("is the user's local date, or the UTC date without a known zone", () => {
|
||||
const now = new Date("2026-01-01T03:00:00Z");
|
||||
expect(formatLocalDate("America/Los_Angeles", now)).toBe("2025-12-31");
|
||||
expect(formatLocalDate("Not/AZone", now)).toBe("2026-01-01");
|
||||
expect(formatLocalDate(null, now)).toBe("2026-01-01");
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,6 +27,14 @@ export function formatCurrentTimeInstruction(
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/** Today's date as YYYY-MM-DD in the user's time zone, or in UTC when none is known. */
|
||||
export function formatLocalDate(
|
||||
timeZone: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
return zonedClock(now, knownTimeZone(timeZone) ?? "UTC").local.slice(0, 10);
|
||||
}
|
||||
|
||||
/** The user's time zone from their profile when this runtime knows it. */
|
||||
export async function loadUserTimeZone(
|
||||
prisma: Pick<PrismaClient, "userProfile">,
|
||||
|
||||
@ -319,6 +319,14 @@ describe("mutating tool effect idempotency keys", () => {
|
||||
]);
|
||||
await f.run();
|
||||
expect(f.memoryCommit).toHaveBeenCalledOnce();
|
||||
// remember appends, so only the effect record keeps a retry from adding the note twice.
|
||||
expect(f.memoryCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "append",
|
||||
content: expect.stringMatching(/^- \d{4}-\d{2}-\d{2}: durable fact$/),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(f.effects).toHaveLength(1);
|
||||
|
||||
f.setCalls([
|
||||
|
||||
@ -207,7 +207,7 @@ import { observationToolResult, parseComputerActions } from "./computer-tools.js
|
||||
import { checkpointRunComputerWorkspace } from "./computer-workspace.js";
|
||||
import { sanitizeConnectorError } from "./connector-safety.js";
|
||||
import { createProject } from "./create-project.js";
|
||||
import { formatCurrentTimeInstruction } from "./current-time.js";
|
||||
import { formatCurrentTimeInstruction, formatLocalDate } from "./current-time.js";
|
||||
import {
|
||||
delegateTask,
|
||||
loadDelegatedTaskInstruction,
|
||||
@ -319,6 +319,7 @@ import {
|
||||
tryRecoverProseHandoff,
|
||||
workshopFallbackAfterBlockedHandoff,
|
||||
} from "./prose-handoff-recovery.js";
|
||||
import { datedMemoryNote, rememberFromTool } from "./remember.js";
|
||||
import type { RemoteTransportDependencies } from "./remote-mcp.js";
|
||||
import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js";
|
||||
import {
|
||||
@ -2546,18 +2547,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
}, finish);
|
||||
}
|
||||
if (name === "remember") {
|
||||
await deps.memory.commit(
|
||||
{
|
||||
scope: "bot",
|
||||
return finish(
|
||||
await rememberFromTool(deps.memory, context, {
|
||||
botId: bot.id,
|
||||
path: String(args.path ?? "MEMORY.md"),
|
||||
content: String(args.content ?? ""),
|
||||
sourceRunId: runId,
|
||||
sourceThreadId: thread.id,
|
||||
},
|
||||
context,
|
||||
runId,
|
||||
threadId: thread.id,
|
||||
date: formatLocalDate(userContext.timeZone),
|
||||
args,
|
||||
}),
|
||||
);
|
||||
return finish({ ok: true });
|
||||
}
|
||||
if (name === "update_user_profile") {
|
||||
return finish(
|
||||
@ -4240,7 +4238,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
scope: mem.scope,
|
||||
botId: mem.scope === "bot" ? bot.id : undefined,
|
||||
path: mem.path,
|
||||
content: mem.content,
|
||||
content:
|
||||
mem.mode === "append"
|
||||
? datedMemoryNote(mem.content, formatLocalDate(userContext.timeZone))
|
||||
: mem.content,
|
||||
mode: mem.mode,
|
||||
sourceRunId: runId,
|
||||
sourceThreadId: thread.id,
|
||||
},
|
||||
|
||||
@ -51,6 +51,36 @@ describe("agent memory context", () => {
|
||||
expect(result?.endsWith("</durable_memory>")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the newest notes of a document that does not fit, from a line start", async () => {
|
||||
const notes = Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) => `- 2026-10-0${index % 9}: note ${index}`,
|
||||
);
|
||||
const read = vi.fn(async ({ scope }: { scope: "bot" | "user" }) =>
|
||||
snapshot(
|
||||
scope === "bot"
|
||||
? [
|
||||
document(
|
||||
"bot",
|
||||
"MEMORY.md",
|
||||
`# Chief\n${notes.join("\n")}\n`,
|
||||
41,
|
||||
"2026-10-03T12:00:00.000Z",
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
const result = await loadAgentMemoryContext(storeWith(read), "bot-1", context, 600);
|
||||
|
||||
expect(Buffer.byteLength(result ?? "", "utf8")).toBeLessThanOrEqual(600);
|
||||
expect(result).toContain("## bot: MEMORY.md (revision 41)\n[earlier notes omitted]\n- ");
|
||||
expect(result).toContain("note 39\n\n</durable_memory>");
|
||||
expect(result).not.toContain("# Chief");
|
||||
expect(result).not.toContain("note 0\n");
|
||||
});
|
||||
|
||||
it("omits the memory block when neither scope has documents", async () => {
|
||||
const read = vi.fn(async () => snapshot([]));
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AdapterContext, MemorySnapshot, MemoryStore } from "@rakazo/adapter-kit";
|
||||
|
||||
const MAX_AGENT_MEMORY_BYTES = 32 * 1024;
|
||||
const OMITTED_MARKER = "[earlier notes omitted]\n";
|
||||
|
||||
type ScopedMemoryDocument = MemorySnapshot["documents"][number] & {
|
||||
scope: "bot" | "user";
|
||||
@ -45,10 +46,14 @@ export async function loadAgentMemoryContext(
|
||||
sections.push(heading);
|
||||
remainingBytes -= headingBytes;
|
||||
|
||||
const content = truncateUtf8(document.content, remainingBytes);
|
||||
sections.push(content);
|
||||
remainingBytes -= byteLength(content);
|
||||
if (content !== document.content) break;
|
||||
if (byteLength(document.content) <= remainingBytes) {
|
||||
sections.push(document.content);
|
||||
remainingBytes -= byteLength(document.content);
|
||||
continue;
|
||||
}
|
||||
// Notes are appended, so the end of a document is its newest part.
|
||||
sections.push(newestLines(document.content, remainingBytes));
|
||||
break;
|
||||
}
|
||||
|
||||
return `${preamble}${sections.join("")}${closing}`;
|
||||
@ -58,6 +63,30 @@ function byteLength(value: string): number {
|
||||
return Buffer.byteLength(value, "utf8");
|
||||
}
|
||||
|
||||
/** The end of `content` within `maxBytes`, from a line start, after an omission marker. */
|
||||
function newestLines(content: string, maxBytes: number): string {
|
||||
const markerBytes = byteLength(OMITTED_MARKER);
|
||||
if (maxBytes <= markerBytes) return "";
|
||||
const tail = tailUtf8(content, maxBytes - markerBytes);
|
||||
const startsLine = content.charAt(content.length - tail.length - 1) === "\n";
|
||||
const newline = tail.indexOf("\n");
|
||||
const partialFirstLine = !startsLine && newline >= 0 && newline < tail.length - 1;
|
||||
return `${OMITTED_MARKER}${partialFirstLine ? tail.slice(newline + 1) : tail}`;
|
||||
}
|
||||
|
||||
function tailUtf8(value: string, maxBytes: number): string {
|
||||
const characters = Array.from(value);
|
||||
let bytes = 0;
|
||||
let start = characters.length;
|
||||
while (start > 0) {
|
||||
const characterBytes = byteLength(characters[start - 1]!);
|
||||
if (bytes + characterBytes > maxBytes) break;
|
||||
bytes += characterBytes;
|
||||
start -= 1;
|
||||
}
|
||||
return characters.slice(start).join("");
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
if (maxBytes <= 0) return "";
|
||||
const characters: string[] = [];
|
||||
|
||||
96
packages/adapters/src/remember.test.ts
Normal file
96
packages/adapters/src/remember.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
55
packages/adapters/src/remember.ts
Normal file
55
packages/adapters/src/remember.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { AdapterContext, MemoryStore } from "@rakazo/adapter-kit";
|
||||
import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit";
|
||||
|
||||
const DEFAULT_MEMORY_PATH = "MEMORY.md";
|
||||
|
||||
/** One dated Markdown bullet; later lines are indented so the note stays a single list item. */
|
||||
export function datedMemoryNote(content: string, date: string): string {
|
||||
const [first = "", ...rest] = content.trim().split(/\r?\n/);
|
||||
return [`- ${date}: ${first}`, ...rest.map((line) => (line.trim() ? ` ${line}` : ""))].join(
|
||||
"\n",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The builtin `remember` tool: appends a dated note to the bot's memory document, or rewrites the
|
||||
* whole document with `mode: "replace"`. Retries of the same call are replayed from its effect
|
||||
* record by the executor, so a note is appended once.
|
||||
*/
|
||||
export async function rememberFromTool(
|
||||
memory: Pick<MemoryStore, "commit">,
|
||||
context: AdapterContext,
|
||||
input: {
|
||||
botId: string;
|
||||
runId: string;
|
||||
threadId: string;
|
||||
/** The user's local date, YYYY-MM-DD. */
|
||||
date: string;
|
||||
args: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{ ok: true } | { error: string }> {
|
||||
const replace = input.args.mode === "replace";
|
||||
const content = String(input.args.content ?? "");
|
||||
const path = String(input.args.path ?? "").trim() || DEFAULT_MEMORY_PATH;
|
||||
if (!replace && !content.trim()) return { error: "content is empty" };
|
||||
try {
|
||||
await memory.commit(
|
||||
{
|
||||
scope: "bot",
|
||||
botId: input.botId,
|
||||
path,
|
||||
content: replace ? content : datedMemoryNote(content, input.date),
|
||||
mode: replace ? "replace" : "append",
|
||||
sourceRunId: input.runId,
|
||||
sourceThreadId: input.threadId,
|
||||
},
|
||||
context,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof MemoryDocumentTooLargeError)) throw error;
|
||||
return {
|
||||
error: `${path} is full (${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB). Condense it with mode "replace", then save this note again.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@ -481,7 +481,7 @@ export function inferScript(
|
||||
return [
|
||||
{
|
||||
assistant: "noted — i will keep that in memory.",
|
||||
memory: [{ scope: "bot", path: "MEMORY.md", content: `# Memory\n\n- ${prompt}\n` }],
|
||||
memory: [{ scope: "bot", path: "MEMORY.md", content: prompt, mode: "append" }],
|
||||
complete: true,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit";
|
||||
import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit";
|
||||
import { createDb, type PrismaClient } from "@rakazo/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { MarkdownMemoryStore } from "./index.js";
|
||||
@ -193,6 +194,84 @@ describePostgres("memory commits (PostgreSQL)", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("appends to the current content with a revision per note, creating a missing document", async () => {
|
||||
const path = "appended.md";
|
||||
const append = (content: string): MemoryCommitRequest => ({
|
||||
...request(path, content),
|
||||
mode: "append",
|
||||
});
|
||||
const first = await store.commit(append("- first"), context);
|
||||
expect(first).toMatchObject({ revision: 1, content: "- first\n" });
|
||||
await store.commit(append("- second"), context);
|
||||
const current = await document(path);
|
||||
expect(current).toMatchObject({ revision: 2, content: "- first\n- second\n" });
|
||||
expect(current.revisions.map(({ revision, content }) => ({ revision, content }))).toEqual([
|
||||
{ revision: 1, content: "- first\n" },
|
||||
{ revision: 2, content: "- first\n- second\n" },
|
||||
]);
|
||||
|
||||
// A replace still rewrites the whole document.
|
||||
await store.commit(request(path, "# Condensed\n"), context);
|
||||
await store.commit(append("- third"), context);
|
||||
expect((await document(path)).content).toBe("# Condensed\n\n- third\n");
|
||||
});
|
||||
|
||||
it("keeps both notes when two appends overlap", async () => {
|
||||
const path = "concurrent-append.md";
|
||||
await store.commit(request(path, "# Notes\n"), context);
|
||||
let reads = 0;
|
||||
let release!: () => void;
|
||||
const bothRead = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const concurrentStore = new MarkdownMemoryStore(
|
||||
prisma.$extends({
|
||||
query: {
|
||||
memoryDocument: {
|
||||
async findFirst({ args, query }) {
|
||||
const result = await query(args);
|
||||
reads += 1;
|
||||
if (reads === 2) release();
|
||||
if (reads <= 2) await bothRead;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as PrismaClient,
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
concurrentStore.commit({ ...request(path, "- a"), mode: "append" }, context),
|
||||
concurrentStore.commit({ ...request(path, "- b"), mode: "append" }, context),
|
||||
]);
|
||||
|
||||
expect(results.map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]);
|
||||
const current = await document(path);
|
||||
expect(current.revision).toBe(3);
|
||||
expect(current.content).toMatch(/^# Notes\n\n- [ab]\n- [ab]\n$/);
|
||||
expect(current.content).toContain("- a\n");
|
||||
expect(current.content).toContain("- b\n");
|
||||
});
|
||||
|
||||
it("refuses a document past the size limit without writing anything", async () => {
|
||||
const path = "full.md";
|
||||
const nearlyFull = `${"x".repeat(MEMORY_DOCUMENT_MAX_BYTES - 10)}\n`;
|
||||
await store.commit(request(path, nearlyFull), context);
|
||||
|
||||
await expect(
|
||||
store.commit({ ...request(path, "- one note too many"), mode: "append" }, context),
|
||||
).rejects.toBeInstanceOf(MemoryDocumentTooLargeError);
|
||||
await expect(
|
||||
store.commit(request(path, "é".repeat(MEMORY_DOCUMENT_MAX_BYTES / 2 + 1)), context),
|
||||
).rejects.toBeInstanceOf(MemoryDocumentTooLargeError);
|
||||
const current = await document(path);
|
||||
expect(current).toMatchObject({ revision: 1, content: nearlyFull });
|
||||
expect(current.revisions).toHaveLength(1);
|
||||
|
||||
await store.commit({ ...request(path, "- ok"), mode: "append" }, context);
|
||||
expect((await document(path)).revision).toBe(2);
|
||||
});
|
||||
|
||||
it("bounds conflict retries, preserves only committed saves, and allows a later retry", async () => {
|
||||
const path = "exhausted.md";
|
||||
await store.commit(request(path, "initial"), context);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AdapterContext } from "@rakazo/adapter-kit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MarkdownMemoryStore } from "./index.js";
|
||||
import { appendMarkdown, MarkdownMemoryStore } from "./index.js";
|
||||
|
||||
const context: AdapterContext = {
|
||||
operationId: "read-memory",
|
||||
@ -47,3 +47,18 @@ describe("memory store contract shape", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendMarkdown", () => {
|
||||
it("adds the note below a heading or paragraph, and right below a list", () => {
|
||||
expect(appendMarkdown("# Chief\n\n", "- 2026-10-03: Prefers tea")).toBe(
|
||||
"# Chief\n\n- 2026-10-03: Prefers tea\n",
|
||||
);
|
||||
expect(appendMarkdown("- a\n- b", "- c")).toBe("- a\n- b\n- c\n");
|
||||
expect(appendMarkdown("- a\n more about a\n", "- c")).toBe("- a\n more about a\n- c\n");
|
||||
expect(appendMarkdown("", "- first")).toBe("- first\n");
|
||||
});
|
||||
|
||||
it("leaves the document unchanged for an empty note", () => {
|
||||
expect(appendMarkdown("# Chief\n", " ")).toBe("# Chief\n");
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
MemoryStore,
|
||||
PortableFile,
|
||||
} from "@rakazo/adapter-kit";
|
||||
import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit";
|
||||
import { Prisma, type PrismaClient, withTransactionRetry } from "@rakazo/db";
|
||||
|
||||
export class MarkdownMemoryStore implements MemoryStore {
|
||||
@ -69,8 +70,8 @@ export class MarkdownMemoryStore implements MemoryStore {
|
||||
}
|
||||
|
||||
async commit(request: MemoryCommitRequest, context: AdapterContext): Promise<MemoryRevision> {
|
||||
// A save replaces the current content and appends its history atomically.
|
||||
// Conflicts retry the whole transaction so revision numbers use fresh state.
|
||||
// A save replaces (or appends to) the current content and appends its history atomically.
|
||||
// Conflicts retry the whole transaction so revision numbers and appends use fresh state.
|
||||
return withTransactionRetry(() =>
|
||||
this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
@ -83,10 +84,17 @@ export class MarkdownMemoryStore implements MemoryStore {
|
||||
path: request.path,
|
||||
},
|
||||
});
|
||||
const content =
|
||||
request.mode === "append"
|
||||
? appendMarkdown(existing?.content ?? "", request.content)
|
||||
: request.content;
|
||||
if (Buffer.byteLength(content, "utf8") > MEMORY_DOCUMENT_MAX_BYTES) {
|
||||
throw new MemoryDocumentTooLargeError(request.path);
|
||||
}
|
||||
const doc = existing
|
||||
? await tx.memoryDocument.update({
|
||||
where: { id: existing.id },
|
||||
data: { content: request.content, revision: existing.revision + 1 },
|
||||
data: { content, revision: existing.revision + 1 },
|
||||
})
|
||||
: await tx.memoryDocument.create({
|
||||
data: {
|
||||
@ -95,14 +103,14 @@ export class MarkdownMemoryStore implements MemoryStore {
|
||||
botId: request.botId,
|
||||
scope: request.scope,
|
||||
path: request.path,
|
||||
content: request.content,
|
||||
content,
|
||||
},
|
||||
});
|
||||
await tx.memoryRevision.create({
|
||||
data: {
|
||||
documentId: doc.id,
|
||||
revision: doc.revision,
|
||||
content: request.content,
|
||||
content,
|
||||
sourceRunId: request.sourceRunId,
|
||||
sourceThreadId: request.sourceThreadId,
|
||||
},
|
||||
@ -147,6 +155,20 @@ export class MarkdownMemoryStore implements MemoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `addition` after the existing content, ending with a newline: right below a list, so notes stay
|
||||
* one list, and after a blank line below anything else, such as a heading.
|
||||
*/
|
||||
export function appendMarkdown(existing: string, addition: string): string {
|
||||
const base = existing.trimEnd();
|
||||
const next = addition.trim();
|
||||
if (!next) return existing;
|
||||
if (!base) return `${next}\n`;
|
||||
const lastLine = base.slice(base.lastIndexOf("\n") + 1);
|
||||
const inList = /^\s*(?:[-*+]|\d+\.)\s|^\s{2,}\S/.test(lastLine);
|
||||
return `${base}${inList ? "\n" : "\n\n"}${next}\n`;
|
||||
}
|
||||
|
||||
function snippet(content: string, q: string): string {
|
||||
const idx = content.toLowerCase().indexOf(q);
|
||||
if (idx < 0) return content.slice(0, 140);
|
||||
|
||||
@ -382,6 +382,10 @@ describeJourneys("required product journeys", () => {
|
||||
botId: coder.id,
|
||||
});
|
||||
expect(coderMem.some((m) => m.content.toLowerCase().includes("rust"))).toBe(true);
|
||||
// Remembering appends a dated note below the seeded heading instead of replacing it.
|
||||
expect(coderMem.map((m) => m.content)).toContainEqual(
|
||||
expect.stringMatching(/^# Coder\n\n- \d{4}-\d{2}-\d{2}: remember that coder prefers rust\n$/),
|
||||
);
|
||||
const dedicated = await rpc<Bot>(app, ada, "bots/setComputer", {
|
||||
botId: coder.id,
|
||||
mode: "dedicated",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user