diff --git a/src/bundled/claude-code/prompt.ts b/src/bundled/claude-code/prompt.ts index d82b935180..e0c6dc1249 100644 --- a/src/bundled/claude-code/prompt.ts +++ b/src/bundled/claude-code/prompt.ts @@ -36,6 +36,8 @@ export function systemPrompt( team?: string; /** The agent's `core` memory; `null` when it has none yet. */ memory?: string | null; + /** Its recent notes, as `recentNotesSection` gives them. */ + notes?: string; }>, ) { const model = input.scope === "thread" ? threadModel : channelModel; @@ -55,6 +57,7 @@ export function systemPrompt( : "", input.team?.trim() ?? "", coreMemorySection(input.memory), + input.notes ?? "", ] .filter(Boolean) .join("\n\n"); diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 2ed5e964e9..0af9a7f07f 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -97,10 +97,12 @@ function setup( }; const host = { spawn, request: vi.fn() } as unknown as Host; const read = vi.fn(async (_filters: readonly ReadFilter[]) => thread); - const memoryRead = vi.fn(async () => ({ - entries: [] as readonly MemoryEntry[], - partial: false, - })); + const memoryRead = vi.fn( + async (_pubkey: string, _signal: AbortSignal, _slug?: string) => ({ + entries: [] as readonly MemoryEntry[], + partial: false, + }), + ); const session = { workflowAuthority: options.workflowAuthority, read, @@ -839,7 +841,7 @@ it("puts the agent's teams in its system prompt, resuming idle sessions when the runtime.dispose(); }); -it("starts each session from the host's core memory, reading none from the relay", async () => { +it("starts each session from the host's core memory, reading only recent notes from the relay", async () => { const { deliver, claudes, core, memoryRead } = setup(); const mention = (id: string) => deliver({ @@ -862,7 +864,13 @@ it("starts each session from the host's core memory, reading none from the relay await flush(10); expect(asked("1")).toContain("\nI am Claude.\n"); expect(core).toHaveBeenCalledWith(self); - expect(memoryRead).not.toHaveBeenCalled(); + // Core memory comes from the host; the relay reads are yesterday's and + // today's notes, each by its slug. + expect(memoryRead).toHaveBeenCalledTimes(2); + for (const [pubkey, , slug] of memoryRead.mock.calls) { + expect(pubkey).toBe(self); + expect(slug).toMatch(/^mem\/notes\/\d{4}-\d{2}-\d{2}$/); + } // The host could not read it: the session starts without it. core.mockResolvedValueOnce(undefined); await mention("2"); @@ -871,6 +879,63 @@ it("starts each session from the host's core memory, reading none from the relay expect(asked("2")).not.toContain(""); }); +it("starts a session without recent notes the relay is slow to give", async () => { + const { deliver, claudes, memoryRead } = setup(); + memoryRead.mockImplementation(() => new Promise(() => {})); + await deliver({ + type: "mention", + event: message("1", "@Claude 1", { + tags: [ + ["h", channel], + ["p", self], + ], + }), + }); + await vi.waitFor( + () => + expect(claudes().find((process) => process.prompts.length)).toBeDefined(), + { timeout: 4_000 }, + ); + expect( + systemPromptOf(claudes().find((process) => process.prompts.length)), + ).not.toContain(""); +}); + +it("starts each session with the agent's recent notes after its core memory", async () => { + const { deliver, claudes, core, memoryRead } = setup(); + core.mockResolvedValueOnce("I am Claude."); + memoryRead.mockImplementation(async (_pubkey, _signal, slug) => ({ + entries: slug?.startsWith("mem/notes/") + ? [ + { + slug, + body: `Noted in ${slug}.`, + eventId: "e".repeat(64), + createdAt: 1, + }, + ] + : [], + partial: false, + })); + await deliver({ + type: "mention", + event: message("1", "@Claude 1", { + tags: [ + ["h", channel], + ["p", self], + ], + }), + }); + await flush(10); + const prompt = systemPromptOf( + claudes().find((process) => process.prompts.length), + ); + const [yesterday, today] = memoryRead.mock.calls.map(([, , slug]) => slug); + expect(prompt).toContain( + `\n\n\n\nNoted in ${yesterday}.\n\n`, + ); +}); + it("names the agent in its system prompt, resuming idle sessions when it is renamed", async () => { const root = message("2", "@Claude hi", { tags: [ @@ -2241,10 +2306,16 @@ it("method calls: a session launch makes no config call on the relay session; on await flush(10); expect(f.claudes()).toHaveLength(1); expect(systemPromptOf(f.claudes()[0])).toContain("I am Claude."); - // The start makes no config requests. The only call is the conversation's - // name lookup for the sender and mentioned agent, which requests only - // profiles missing from the cache (none when both are cached). - expect(made()).toEqual({ + // The start makes no config requests. The only calls are the agent's + // recent notes, by slug, and the conversation's name lookup for the sender + // and mentioned agent, which requests only profiles missing from the cache + // (none when both are cached). + const { "agentMemories.read": notes, ...rest } = made(); + expect(notes?.map(([pubkey, , slug]) => [pubkey, slug])).toEqual([ + [self, expect.stringMatching(/^mem\/notes\/\d{4}-\d{2}-\d{2}$/)], + [self, expect.stringMatching(/^mem\/notes\/\d{4}-\d{2}-\d{2}$/)], + ]); + expect(rest).toEqual({ "profiles.ensure": [[[alice, self], "foreground"]], }); }); @@ -2390,6 +2461,8 @@ it("controls an idle Claude session without sending a message, and rejects reset const agent = { ...f.agent, config: DEFAULT_CONFIG } as unknown as Agent; try { await f.deliver({ type: "mention", event: root }); + // The start reads the agent's recent notes first. + await flush(10); const process = f.claudes()[0]; if (!process) throw new Error("Expected Claude process"); await vi.waitFor(() => expect(process.prompts).toHaveLength(1)); @@ -2420,6 +2493,7 @@ it("controls an idle Claude session without sending a message, and rejects reset ], }), }); + await flush(10); expect(f.claudes()).toHaveLength(2); expect(f.claudes()[1]?.options.args).not.toContain("--resume"); } finally { @@ -2488,6 +2562,8 @@ it("keeps an accepted Claude handoff past its deadline while compaction runs", a let finishCompaction: (() => void) | undefined; try { await f.deliver({ type: "mention", event: root }); + // The start reads the agent's recent notes first. + await flush(10); const process = f.claudes()[0]; if (!process) throw new Error("Expected Claude process"); await vi.waitFor(() => expect(process.prompts).toHaveLength(1)); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index db78013f5d..492bf69176 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -45,6 +45,8 @@ import { appClient, readFile } from "../../buzz-mcp/app-client"; import { MemoryReads, READ_TIMEOUT_MS, + abortable, + recentNotesSection, } from "../../features/agent-harness/core-memory"; import { readProject } from "../../features/agent-harness/project"; import { SessionControls } from "../../features/agent-harness/session-control"; @@ -160,6 +162,9 @@ const listing = (added: readonly AddedTool[]): Listing => ({ : tool, }); +/** How long a session start waits for the agent's recent notes. */ +const NOTES_WAIT_MS = 2_000; + export class ClaudeRuntime { private readonly agents = new Map(); private readonly memoryReads = new MemoryReads(); @@ -937,7 +942,17 @@ export class ClaudeRuntime { ]), launch: async (key) => { const { workspace, instructions, scope } = entry.config; - const memory = await this.core(agent); + // Read once per process: a new note doesn't restart a session, and a + // slow read starts it without them. + const [memory, notes] = await Promise.all([ + this.core(agent), + recentNotesSection((slug) => + abortable( + this.memories(agent, slug), + AbortSignal.timeout(NOTES_WAIT_MS), + ), + ), + ]); const directories = key ? entry.directories.get(key) : undefined; return { ...where(entry.config), @@ -955,6 +970,7 @@ export class ClaudeRuntime { instructions, team: entry.team, ...(memory === undefined ? {} : { memory }), + notes, }), }; }, diff --git a/src/features/agent-harness/core-memory.test.ts b/src/features/agent-harness/core-memory.test.ts index 096643422b..44128aa408 100644 --- a/src/features/agent-harness/core-memory.test.ts +++ b/src/features/agent-harness/core-memory.test.ts @@ -1,6 +1,12 @@ import { expect, it, vi } from "vitest"; import type { MemoryEntry, MemoryListing } from "../agents/memory"; -import { MemoryReads, coreMemorySection, coreOf } from "./core-memory"; +import { + MemoryReads, + NOTE_LIMIT, + coreMemorySection, + coreOf, + recentNotesSection, +} from "./core-memory"; const agent = "a".repeat(64); const core: MemoryEntry = { @@ -117,3 +123,73 @@ it("shows core memory, prompts for one when there is none, and omits unknown", ( expect(coreMemorySection(undefined)).toBe(""); expect(coreMemorySection(" ")).toBe(""); }); + +it("shows yesterday's and today's notes, oldest first, by this device's dates", async () => { + const note = (slug: string, body: string) => ({ ...core, slug, body }); + const read = vi.fn(async (slug: string) => + listing( + slug === "mem/notes/2026-02-28" + ? [note(slug, "Ada likes tea.\n")] + : slug === "mem/notes/2026-03-01" + ? [note(slug, "Ada moved to Leeds.")] + : [], + ), + ); + // Just after midnight on the 1st: yesterday is the last day of February. + const now = new Date(2026, 2, 1, 0, 5); + expect(await recentNotesSection(read, now)).toBe( + [ + "", + '', + "Ada likes tea.", + "", + '', + "Ada moved to Leeds.", + "", + "", + ].join("\n"), + ); + expect(read.mock.calls.map(([slug]) => slug)).toEqual([ + "mem/notes/2026-02-28", + "mem/notes/2026-03-01", + ]); +}); + +it("adds nothing for a missing, blank or unread note", async () => { + const now = new Date(2026, 9, 30, 12); + expect(await recentNotesSection(async () => listing([]), now)).toBe(""); + expect(await recentNotesSection(async () => listing([], true), now)).toBe(""); + expect( + await recentNotesSection( + async (slug) => listing([{ ...core, slug, body: " \n" }]), + now, + ), + ).toBe(""); + expect( + await recentNotesSection(async () => { + throw new Error("Buzz is not connected"); + }, now), + ).toBe(""); + // One read failing still shows the other note. + expect( + await recentNotesSection(async (slug) => { + if (slug.endsWith("29")) throw new Error("timed out"); + return listing([{ ...core, slug, body: "Today." }]); + }, now), + ).toContain('\nToday.\n'); +}); + +it("keeps the newest part of a long note, cut where a character starts", async () => { + // "é" is two bytes, so the limit falls inside one. + const body = `${"old\n".repeat(10)}${"é".repeat(NOTE_LIMIT / 2)}x`; + const section = await recentNotesSection( + async (slug) => + slug.endsWith("30") ? listing([{ ...core, slug, body }]) : listing([]), + new Date(2026, 9, 30, 12), + ); + const kept = section.split("\n")[3] ?? ""; + expect(section.split("\n")[2]).toBe("[older part of this note left out]"); + expect(kept).toBe(`${"é".repeat(NOTE_LIMIT / 2 - 1)}x`); + expect(new TextEncoder().encode(kept).length).toBeLessThanOrEqual(NOTE_LIMIT); + expect(section).not.toContain("old\n"); +}); diff --git a/src/features/agent-harness/core-memory.ts b/src/features/agent-harness/core-memory.ts index dd34e66049..9406c7d1f8 100644 --- a/src/features/agent-harness/core-memory.ts +++ b/src/features/agent-harness/core-memory.ts @@ -71,6 +71,45 @@ export function coreMemorySection(memory: string | null | undefined) { return body ? `\n${body}\n` : ""; } +/** The most of each note `recentNotesSection` shows, in UTF-8 bytes. */ +export const NOTE_LIMIT = 8 * 1024; + +/** Yesterday's and today's `mem/notes/` notes, in a section of a prompt. + * Dates are this device's, which JS takes as UTC when it has no time zone. + * Notes are appended to, so a long one keeps its end. A note that is missing, + * blank or unread adds nothing. */ +export async function recentNotesSection( + read: (slug: string) => Promise, + now = new Date(), +) { + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + const notes = await Promise.all( + [yesterday, now].map(async (day) => { + const slug = `mem/notes/${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`; + const body = await read(slug).then( + (listing) => + listing.entries.find((entry) => entry.slug === slug)?.body.trim(), + () => undefined, + ); + return body ? `\n${tail(body)}\n` : ""; + }), + ); + const body = notes.filter(Boolean).join("\n"); + return body ? `\n${body}\n` : ""; +} + +const pad = (value: number) => String(value).padStart(2, "0"); + +/** The last `NOTE_LIMIT` bytes of `text`, cut where a character starts. */ +function tail(text: string) { + const bytes = new TextEncoder().encode(text); + if (bytes.length <= NOTE_LIMIT) return text; + let start = bytes.length - NOTE_LIMIT; + while (((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1; + return `[older part of this note left out]\n${new TextDecoder().decode(bytes.subarray(start))}`; +} + /** Stops waiting when `signal` aborts, even if the read itself does not. */ export function abortable(promise: Promise, signal: AbortSignal) { return new Promise((resolve, reject) => { diff --git a/src/features/agents/launch-boundary.test.ts b/src/features/agents/launch-boundary.test.ts index c9e313b2c1..f27f415130 100644 --- a/src/features/agents/launch-boundary.test.ts +++ b/src/features/agents/launch-boundary.test.ts @@ -305,16 +305,27 @@ const postRunCoreRefresh = (request: Request) => request.memory?.agent === agentKey.pubkey && request.memory.slug === "core"; -/** Apart from the post-run core refresh, the recorded requests are exactly - * one conversation read for each of `conversations`, in order: an extra, a - * wider or a different read fails. */ +/** A Claude Code start's read of one of the agent's recent notes, by slug + * (`recentNotesSection`). */ +const recentNotesRead = (request: Request) => + request.memory?.agent === agentKey.pubkey && + /^mem\/notes\/\d{4}-\d{2}-\d{2}$/.test(request.memory.slug ?? ""); + +/** Apart from the post-run core refresh, and with `notes` a start's recent + * notes reads, the recorded requests are exactly one conversation read for + * each of `conversations`, in order: an extra, a wider or a different read + * fails. */ function expectOnlyAllowed( f: Fixture, conversations: readonly RelayEvent[] = [], + notes = false, ) { expect( f.requests - .filter((request) => !postRunCoreRefresh(request)) + .filter( + (request) => + !postRunCoreRefresh(request) && !(notes && recentNotesRead(request)), + ) .map((request) => request.filters), ).toEqual(conversations.map(conversationRead)); } @@ -355,7 +366,7 @@ async function expectCounterSeesSync(f: Fixture) { }); } -it("starts Claude sessions from the synced copies, with no relay request online or offline", async () => { +it("starts Claude sessions from the synced copies, with no relay request but recent notes online or offline", async () => { const claude = fakeClaude(); const host = { spawn: claude.spawn, request: vi.fn() } as unknown as Host; const { revisions, seeded } = heads("buzz/claude-code", { @@ -389,7 +400,7 @@ it("starts Claude sessions from the synced copies, with no relay request online expect(first?.options.cwd).toBe("/this/device"); expect(JSON.stringify(first?.received)).not.toContain("LOCAL INSTRUCTIONS"); expect(f.deliveries[0]?.revisions).toEqual(revisions); - expectOnlyAllowed(f); + expectOnlyAllowed(f, [], true); // The relay goes away; the next conversation starts from the same copies. f.transport.offline = true; @@ -400,7 +411,7 @@ it("starts Claude sessions from the synced copies, with no relay request online "LOCAL INSTRUCTIONS", ); expect(f.deliveries[1]?.revisions).toEqual(revisions); - expectOnlyAllowed(f); + expectOnlyAllowed(f, [], true); await expectCounterSeesSync(f); });