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);
});