From 4875753d9caede91936e26f8ec2852d057291335 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:10:25 +0000 Subject: [PATCH 1/5] 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 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/router.ts | 38 +++++--- apps/web/e2e/knowledge-panel.spec.ts | 30 ++++++ docs/PRODUCT.md | 13 +++ packages/adapter-kit/src/index.ts | 1 + packages/adapter-kit/src/memory.ts | 13 +++ packages/adapter-kit/src/types.ts | 4 +- packages/adapters/src/builtin-tools.ts | 10 +- packages/adapters/src/current-time.test.ts | 11 ++- packages/adapters/src/current-time.ts | 8 ++ .../src/executor-effect-idempotency.test.ts | 8 ++ packages/adapters/src/executor.ts | 26 ++--- packages/adapters/src/memory-context.test.ts | 30 ++++++ packages/adapters/src/memory-context.ts | 37 ++++++- packages/adapters/src/remember.test.ts | 96 +++++++++++++++++++ packages/adapters/src/remember.ts | 55 +++++++++++ packages/adapters/src/scripted-runtime.ts | 2 +- packages/memory/src/commit.postgres.test.ts | 79 +++++++++++++++ packages/memory/src/index.test.ts | 17 +++- packages/memory/src/index.ts | 32 ++++++- packages/testkit/src/journeys.test.ts | 4 + 20 files changed, 472 insertions(+), 42 deletions(-) create mode 100644 packages/adapter-kit/src/memory.ts create mode 100644 packages/adapters/src/remember.test.ts create mode 100644 packages/adapters/src/remember.ts diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..11ec389 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -11,6 +11,7 @@ import type { } from "@rakazo/adapter-kit"; import { computerControlExpireJobKey, + MemoryDocumentTooLargeError, messagingDeliverJob, routineJobKey, routineWakeupJob, @@ -2427,21 +2428,28 @@ export function createRouter(deps: RouterDeps) { }, }); if (!doc) throw new IsolationError(); - const updated = await deps.memory.commit( - { - scope: doc.scope as "bot" | "user", - botId: doc.botId ?? undefined, - path: doc.path, - content: input.content, - }, - { - operationId: "mem", - traceId: "mem", - spaceId: context.actor.spaceId, - userId: context.actor.userId, - signal: new AbortController().signal, - }, - ); + const updated = await deps.memory + .commit( + { + scope: doc.scope as "bot" | "user", + botId: doc.botId ?? undefined, + path: doc.path, + content: input.content, + }, + { + operationId: "mem", + traceId: "mem", + spaceId: context.actor.spaceId, + 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", diff --git a/apps/web/e2e/knowledge-panel.spec.ts b/apps/web/e2e/knowledge-panel.spec.ts index a504c9d..3ab2d19 100644 --- a/apps/web/e2e/knowledge-panel.spec.ts +++ b/apps/web/e2e/knowledge-panel.spec.ts @@ -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(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(); diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 8c69d87..4577151 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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. diff --git a/packages/adapter-kit/src/index.ts b/packages/adapter-kit/src/index.ts index 43f7898..ac00d1b 100644 --- a/packages/adapter-kit/src/index.ts +++ b/packages/adapter-kit/src/index.ts @@ -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"; diff --git a/packages/adapter-kit/src/memory.ts b/packages/adapter-kit/src/memory.ts new file mode 100644 index 0000000..9e1ccd3 --- /dev/null +++ b/packages/adapter-kit/src/memory.ts @@ -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"; + } +} diff --git a/packages/adapter-kit/src/types.ts b/packages/adapter-kit/src/types.ts index d77cb5e..8ca259f 100644 --- a/packages/adapter-kit/src/types.ts +++ b/packages/adapter-kit/src/types.ts @@ -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>; complete?: boolean; } diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 0ff52fa..8502640 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -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"], }, diff --git a/packages/adapters/src/current-time.test.ts b/packages/adapters/src/current-time.test.ts index 55d2676..88aaee2 100644 --- a/packages/adapters/src/current-time.test.ts +++ b/packages/adapters/src/current-time.test.ts @@ -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"); + }); +}); diff --git a/packages/adapters/src/current-time.ts b/packages/adapters/src/current-time.ts index e1dd058..ad57e41 100644 --- a/packages/adapters/src/current-time.ts +++ b/packages/adapters/src/current-time.ts @@ -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, diff --git a/packages/adapters/src/executor-effect-idempotency.test.ts b/packages/adapters/src/executor-effect-idempotency.test.ts index 374494a..9901102 100644 --- a/packages/adapters/src/executor-effect-idempotency.test.ts +++ b/packages/adapters/src/executor-effect-idempotency.test.ts @@ -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([ diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 5cb9181..7181880 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -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, }, diff --git a/packages/adapters/src/memory-context.test.ts b/packages/adapters/src/memory-context.test.ts index 3df4d13..93bcd21 100644 --- a/packages/adapters/src/memory-context.test.ts +++ b/packages/adapters/src/memory-context.test.ts @@ -51,6 +51,36 @@ describe("agent memory context", () => { expect(result?.endsWith("")).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"); + 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([])); diff --git a/packages/adapters/src/memory-context.ts b/packages/adapters/src/memory-context.ts index 1912225..e53d266 100644 --- a/packages/adapters/src/memory-context.ts +++ b/packages/adapters/src/memory-context.ts @@ -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[] = []; diff --git a/packages/adapters/src/remember.test.ts b/packages/adapters/src/remember.test.ts new file mode 100644 index 0000000..05a86d0 --- /dev/null +++ b/packages/adapters/src/remember.test.ts @@ -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, 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", + ); + }); +}); diff --git a/packages/adapters/src/remember.ts b/packages/adapters/src/remember.ts new file mode 100644 index 0000000..7f411ac --- /dev/null +++ b/packages/adapters/src/remember.ts @@ -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, + context: AdapterContext, + input: { + botId: string; + runId: string; + threadId: string; + /** The user's local date, YYYY-MM-DD. */ + date: string; + args: Record; + }, +): 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 }; +} diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index 6a19544..b75121f 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -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, }, ]; diff --git a/packages/memory/src/commit.postgres.test.ts b/packages/memory/src/commit.postgres.test.ts index 9fdecfc..c6f07d1 100644 --- a/packages/memory/src/commit.postgres.test.ts +++ b/packages/memory/src/commit.postgres.test.ts @@ -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((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); diff --git a/packages/memory/src/index.test.ts b/packages/memory/src/index.test.ts index 92f77fe..7cc98fc 100644 --- a/packages/memory/src/index.test.ts +++ b/packages/memory/src/index.test.ts @@ -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"); + }); +}); diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 937c00e..14931c1 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -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 { - // 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); diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index f5ae027..f236434 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -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(app, ada, "bots/setComputer", { botId: coder.id, mode: "dedicated", From 84dff4d84616ade6331ea7ffae51dc6f132f5539 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:38 +0000 Subject: [PATCH 2/5] refactor(memory): derive the run's memory budget from the document limit The prompt budget was a separate 32 KB constant; it is now twice the per-document limit, so a full bot document and a full space document keep fitting if either number changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/memory-context.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/adapters/src/memory-context.ts b/packages/adapters/src/memory-context.ts index e53d266..901ba7e 100644 --- a/packages/adapters/src/memory-context.ts +++ b/packages/adapters/src/memory-context.ts @@ -1,6 +1,8 @@ import type { AdapterContext, MemorySnapshot, MemoryStore } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES } from "@rakazo/adapter-kit"; -const MAX_AGENT_MEMORY_BYTES = 32 * 1024; +/** Room for a full bot document and a full space document. */ +const MAX_AGENT_MEMORY_BYTES = 2 * MEMORY_DOCUMENT_MAX_BYTES; const OMITTED_MARKER = "[earlier notes omitted]\n"; type ScopedMemoryDocument = MemorySnapshot["documents"][number] & { From ddf13adf08010cc49b2529bb8a04419f1a34a7f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:38 +0000 Subject: [PATCH 3/5] feat(profile): edit facts in place in About you About you could only confirm or delete what the bots know about the user, so a fact that was slightly wrong or filed under the wrong category had to be deleted and taught again, and a fact could not be limited to some spaces from the app even though the profile supports it. - Each fact gets an Edit button that turns its row into an inline editor: wording, category, and (when the user has more than one space, or the fact is already limited) which spaces it shows in. Escape or Cancel leaves it unchanged; only changed fields are sent through profile.update editFacts. - A limited fact names its spaces next to its source. - The repository validates edits like new facts: a wording whose normalized form is empty or equals another fact of the user is refused, all edits of one update apply atomically, and the stored embedding is only dropped when the wording really changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/user-profile-edit.spec.ts | 74 ++++++++++ apps/web/src/components/UserFactEditor.tsx | 129 ++++++++++++++++++ .../src/components/UserProfileSettings.tsx | 118 ++++++++++++---- apps/web/src/lib/user-profile-view.test.ts | 44 ++++++ apps/web/src/lib/user-profile-view.ts | 57 +++++++- packages/db/src/user-profile.postgres.test.ts | 60 ++++++++ packages/db/src/user-profile.ts | 70 ++++++---- .../testkit/src/user-profile.postgres.test.ts | 41 ++++++ 8 files changed, 537 insertions(+), 56 deletions(-) create mode 100644 apps/web/e2e/user-profile-edit.spec.ts create mode 100644 apps/web/src/components/UserFactEditor.tsx diff --git a/apps/web/e2e/user-profile-edit.spec.ts b/apps/web/e2e/user-profile-edit.spec.ts new file mode 100644 index 0000000..612ab13 --- /dev/null +++ b/apps/web/e2e/user-profile-edit.spec.ts @@ -0,0 +1,74 @@ +/** + * E2E: Settings → About you edits a fact in place: its wording, category and the spaces it + * shows in. + */ +import { expect, test } from "@playwright/test"; +import type { UserProfileState } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; + +test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester"); + await completeOnboarding(page); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + await rpc(page, "profile/update", { + addFacts: [{ category: "preferences", value: "Prefers tea" }], + }); + + const settings = await openUserSettings(page, "profile"); + const profile = settings.getByTestId("user-profile-settings"); + const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" }); + await tea.getByRole("button", { name: "Edit" }).click(); + + const editor = profile.getByTestId("user-fact-editor"); + const wording = editor.getByRole("textbox", { name: "Fact" }); + await expect(wording).toBeFocused(); + await wording.fill("Prefers green tea"); + await editor.getByRole("combobox", { name: "Category" }).selectOption("habits"); + await editor.getByRole("button", { name: "All spaces" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Side project" }).click(); + await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked(); + await page.keyboard.press("Escape"); + await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible(); + await captureScreenshot(page, testInfo, "user-profile-edit"); + + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + const edited = profile + .getByTestId("user-facts-habits") + .getByTestId("user-fact") + .filter({ hasText: "Prefers green tea" }); + await expect(edited).toContainText("Side project"); + await expect(tea).toHaveCount(0); + const teaFact = async () => + (await rpc(page, "profile/get", null)).facts.find((fact) => + fact.value.includes("tea"), + ); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + category: "habits", + allSpaces: false, + spaceIds: [side.id], + }); + + // Escape leaves the fact as it was; showing it everywhere again clears the limit. + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded"); + await page.keyboard.press("Escape"); + await expect(editor).toHaveCount(0); + await expect(edited).toContainText("Prefers green tea"); + await expect(settings).toBeVisible(); + + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("button", { name: "Side project" }).click(); + await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click(); + await page.keyboard.press("Escape"); + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + await expect(edited).not.toContainText("Side project"); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + allSpaces: true, + spaceIds: [], + }); +}); diff --git a/apps/web/src/components/UserFactEditor.tsx b/apps/web/src/components/UserFactEditor.tsx new file mode 100644 index 0000000..a7b7e9a --- /dev/null +++ b/apps/web/src/components/UserFactEditor.tsx @@ -0,0 +1,129 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { UserFact, UserFactCategory } from "@rakazo/contracts"; +import { USER_FACT_CATEGORIES, USER_FACT_VALUE_MAX_LENGTH } from "@rakazo/contracts"; +import { + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + NativeSelect, + NativeSelectOption, +} from "@rakazo/ui-web"; +import { ChevronDownIcon } from "lucide-react"; +import { useState } from "react"; +import type { UserFactDraft } from "../lib/user-profile-view"; +import { factSpaceNames, toggleFactSpace, userFactDraft } from "../lib/user-profile-view"; + +/** Edits one fact in place: its wording, category and, with more than one space, where it shows. */ +export function UserFactEditor({ + fact, + spaces, + categoryLabels, + busy, + onSave, + onCancel, +}: { + fact: UserFact; + /** The user's spaces; the visibility control appears with more than one. */ + spaces: ReadonlyArray<{ id: string; name: string }>; + categoryLabels: Record; + busy: boolean; + onSave: (draft: UserFactDraft) => void; + onCancel: () => void; +}) { + const { t } = useLingui(); + const [draft, setDraft] = useState(() => userFactDraft(fact)); + const allSpaces = t`All spaces`; + const showSpaces = spaces.length > 1 || draft.spaceIds !== null; + + return ( +
  • +
    { + event.preventDefault(); + if (draft.value.trim()) onSave(draft); + }} + onKeyDown={(event) => { + // The spaces menu renders in a portal; its Escape only closes the menu. + if (event.key === "Escape" && event.currentTarget.contains(event.target as Node)) { + event.stopPropagation(); + onCancel(); + } + }} + > + setDraft({ ...draft, value: event.target.value })} + /> +
    + + setDraft({ ...draft, category: event.target.value as UserFactCategory }) + } + > + {USER_FACT_CATEGORIES.map((category) => ( + + {categoryLabels[category]} + + ))} + + {showSpaces ? ( + + } + > + + {factSpaceNames(draft.spaceIds, spaces).join(", ") || allSpaces} + + + + setDraft({ ...draft, spaceIds: null })} + > + {allSpaces} + + + {spaces.map((space) => ( + + setDraft({ + ...draft, + spaceIds: toggleFactSpace(draft.spaceIds, space.id, checked), + }) + } + > + + {space.name} + + + ))} + + + ) : null} +
    + + +
    +
    +
    +
  • + ); +} diff --git a/apps/web/src/components/UserProfileSettings.tsx b/apps/web/src/components/UserProfileSettings.tsx index 6a8e2e3..3811a9a 100644 --- a/apps/web/src/components/UserProfileSettings.tsx +++ b/apps/web/src/components/UserProfileSettings.tsx @@ -10,7 +10,7 @@ import { AlertDialogTitle, Button, } from "@rakazo/ui-web"; -import { XIcon } from "lucide-react"; +import { PencilIcon, XIcon } from "lucide-react"; import type { ReactNode } from "react"; import { useEffect, useId, useState } from "react"; import { getActiveUiLocale } from "../lib/i18n"; @@ -20,9 +20,12 @@ import { CLEARED_PROFILE_FIELD, displayLanguageTag, displayRegionTag, + factSpaceNames, filledProfileFields, groupUserFacts, + userFactEdit, } from "../lib/user-profile-view"; +import { UserFactEditor } from "./UserFactEditor"; function Row({ value, @@ -51,14 +54,30 @@ function Row({ ); } -/** Everything the user's bots know about the user, with consent and forget controls. */ +/** Everything the user's bots know about the user, with edit, consent and forget controls. */ export function UserProfileSettings() { const { t } = useLingui(); const [state, setState] = useState(null); + const [spaces, setSpaces] = useState>([]); + const [editingId, setEditingId] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [forgetOpen, setForgetOpen] = useState(false); + useEffect(() => { + let cancelled = false; + // Only names a fact's spaces; without them the profile still works. + rpc.spaces + .list() + .then((navigation) => { + if (!cancelled) setSpaces(navigation.spaces.map(({ id, name }) => ({ id, name }))); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { let cancelled = false; rpc.profile @@ -74,14 +93,16 @@ export function UserProfileSettings() { }; }, [t]); - async function apply(change: () => Promise) { - if (busy) return; + async function apply(change: () => Promise): Promise { + if (busy) return false; setBusy(true); setError(null); try { setState(await change()); + return true; } catch (err) { setError(err instanceof Error ? err.message : t`Couldn't save`); + return false; } finally { setBusy(false); } @@ -178,36 +199,75 @@ export function UserProfileSettings() { {categoryLabels[group.category]}
      - {group.facts.map((fact) => ( - ( - <> - {fact.status === "pending" ? ( + {group.facts.map((fact) => + fact.id === editingId ? ( + setEditingId(null)} + onSave={(draft) => { + const edit = userFactEdit(fact, draft); + if (!edit) { + setEditingId(null); + return; + } + void apply(() => rpc.profile.update({ editFacts: [edit] })).then((saved) => { + if (saved) setEditingId(null); + }); + }} + /> + ) : ( + ( + <> + {fact.status === "pending" ? ( + + ) : null} - ) : null} - {deleteButton( - describedBy, - () => void apply(() => rpc.profile.forget({ factIds: [fact.id] })), - )} - - )} - /> - ))} + {deleteButton( + describedBy, + () => void apply(() => rpc.profile.forget({ factIds: [fact.id] })), + )} + + )} + /> + ), + )}
    ))} diff --git a/apps/web/src/lib/user-profile-view.test.ts b/apps/web/src/lib/user-profile-view.test.ts index 0843590..0f774c6 100644 --- a/apps/web/src/lib/user-profile-view.test.ts +++ b/apps/web/src/lib/user-profile-view.test.ts @@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest"; import { displayLanguageTag, displayRegionTag, + factSpaceNames, filledProfileFields, groupUserFacts, + toggleFactSpace, + userFactDraft, + userFactEdit, } from "./user-profile-view"; function fact(overrides: Partial & Pick): UserFact { @@ -74,3 +78,43 @@ describe("user profile view", () => { expect(displayRegionTag("not a tag", "en")).toBe("not a tag"); }); }); + +describe("user fact editing", () => { + const tea = fact({ id: "tea", value: "Prefers tea", category: "preferences" }); + const limited = fact({ id: "lin", allSpaces: false, spaceIds: ["space-b", "space-a"] }); + + it("sends only what changed, or nothing", () => { + expect(userFactEdit(tea, userFactDraft(tea))).toBeNull(); + expect(userFactEdit(tea, { ...userFactDraft(tea), value: " Prefers tea " })).toBeNull(); + expect( + userFactEdit(limited, { ...userFactDraft(limited), spaceIds: ["space-a", "space-b"] }), + ).toBeNull(); + expect( + userFactEdit(tea, { value: " Prefers green tea ", category: "habits", spaceIds: null }), + ).toEqual({ id: "tea", value: "Prefers green tea", category: "habits" }); + expect(userFactEdit(tea, { ...userFactDraft(tea), spaceIds: ["space-a"] })).toEqual({ + id: "tea", + spaceIds: ["space-a"], + }); + expect(userFactEdit(limited, { ...userFactDraft(limited), spaceIds: null })).toEqual({ + id: "lin", + spaceIds: null, + }); + }); + + it("limits a fact to checked spaces and shows it everywhere when none is left", () => { + expect(toggleFactSpace(null, "space-a", true)).toEqual(["space-a"]); + expect(toggleFactSpace(["space-a"], "space-b", true)).toEqual(["space-a", "space-b"]); + expect(toggleFactSpace(["space-a", "space-b"], "space-a", false)).toEqual(["space-b"]); + expect(toggleFactSpace(["space-a"], "space-a", false)).toBeNull(); + }); + + it("names the spaces of a limited fact", () => { + const spaces = [ + { id: "space-a", name: "Home" }, + { id: "space-b", name: "Side project" }, + ]; + expect(factSpaceNames(["space-b", "gone"], spaces)).toEqual(["Side project"]); + expect(factSpaceNames(null, spaces)).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/user-profile-view.ts b/apps/web/src/lib/user-profile-view.ts index 97877dc..623e9be 100644 --- a/apps/web/src/lib/user-profile-view.ts +++ b/apps/web/src/lib/user-profile-view.ts @@ -1,6 +1,15 @@ -import type { UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts"; +import type { UpdateUserProfile, UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts"; import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; +/** What the fact editor holds: null spaces means every space. */ +export interface UserFactDraft { + value: string; + category: UserFactCategory; + spaceIds: string[] | null; +} + +type UserFactEdit = NonNullable[number]; + export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary"; const PROFILE_FIELDS: ProfileField[] = [ @@ -75,3 +84,49 @@ export function displayRegionTag(tag: string, uiLocale: string): string { return tag; } } + +export function userFactDraft(fact: UserFact): UserFactDraft { + return { + value: fact.value, + category: fact.category, + spaceIds: fact.allSpaces ? null : fact.spaceIds, + }; +} + +/** The `editFacts` entry for what changed in the draft, or null when nothing did. */ +export function userFactEdit(fact: UserFact, draft: UserFactDraft): UserFactEdit | null { + const edit: UserFactEdit = { id: fact.id }; + const value = draft.value.trim(); + if (value !== fact.value) edit.value = value; + if (draft.category !== fact.category) edit.category = draft.category; + const before = userFactDraft(fact).spaceIds; + const sameSpaces = + before === null || draft.spaceIds === null + ? before === draft.spaceIds + : before.length === draft.spaceIds.length && + draft.spaceIds.every((spaceId) => before.includes(spaceId)); + if (!sameSpaces) edit.spaceIds = draft.spaceIds; + return Object.keys(edit).length > 1 ? edit : null; +} + +/** Checks or unchecks one space; unchecking the last one shows the fact everywhere again. */ +export function toggleFactSpace( + spaceIds: string[] | null, + spaceId: string, + checked: boolean, +): string[] | null { + const current = spaceIds ?? []; + const next = checked + ? [...current.filter((id) => id !== spaceId), spaceId] + : current.filter((id) => id !== spaceId); + return next.length > 0 ? next : null; +} + +/** Names of the spaces a limited fact shows in, in the order of `spaces`; unknown ids are skipped. */ +export function factSpaceNames( + spaceIds: string[] | null, + spaces: ReadonlyArray<{ id: string; name: string }>, +): string[] { + if (!spaceIds) return []; + return spaces.filter((space) => spaceIds.includes(space.id)).map((space) => space.name); +} diff --git a/packages/db/src/user-profile.postgres.test.ts b/packages/db/src/user-profile.postgres.test.ts index 03e10b0..d1c218c 100644 --- a/packages/db/src/user-profile.postgres.test.ts +++ b/packages/db/src/user-profile.postgres.test.ts @@ -179,6 +179,66 @@ describePostgres("user profile (PostgreSQL)", () => { }); }); + it("edits a fact in place, refusing a wording another fact already has", async () => { + const { userId, homeSpaceId } = await newUser(); + const work = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: "Work", + }); + const [tea, coffee] = await updateUserProfile(prisma, userId, { + addFacts: [ + { category: "preferences", value: "Prefers tea", source: "user" }, + { category: "preferences", value: "Drinks coffee at work", source: "conversation" }, + ], + }); + await prisma.userFact.update({ + where: { id: tea!.id }, + data: { embedding: [0.1, 0.2], embeddingModel: "test-embedding" }, + }); + + // Same wording, new category and visibility: the embedding still fits. + await updateUserProfile(prisma, userId, { + editFacts: [{ id: tea!.id, value: " Prefers tea ", category: "habits", spaceIds: [work.id] }], + }); + expect(await prisma.userFact.findUniqueOrThrow({ where: { id: tea!.id } })).toMatchObject({ + value: "Prefers tea", + category: "habits", + allSpaces: false, + embedding: [0.1, 0.2], + embeddingModel: "test-embedding", + }); + expect( + (await getUserProfileState(prisma, userId)).facts.find((fact) => fact.id === tea!.id), + ).toMatchObject({ allSpaces: false, spaceIds: [work.id] }); + + for (const value of ["“prefers TEA!”", "..."]) { + await expect( + updateUserProfile(prisma, userId, { + editFacts: [ + { id: coffee!.id, category: "work" }, + { id: coffee!.id, value }, + ], + }), + ).rejects.toBeInstanceOf(InvalidUserFactError); + } + // The whole update failed, the category change included. + expect(await prisma.userFact.findUniqueOrThrow({ where: { id: coffee!.id } })).toMatchObject({ + value: "Drinks coffee at work", + category: "preferences", + }); + + // A fact may take a wording that an earlier edit in the same update gave up. + await updateUserProfile(prisma, userId, { + editFacts: [ + { id: tea!.id, value: "Prefers green tea" }, + { id: coffee!.id, value: "prefers tea" }, + ], + }); + const values = (await getUserProfileState(prisma, userId)).facts.map((fact) => fact.value); + expect(values.sort()).toEqual(["Prefers green tea", "prefers tea"]); + }); + it("limits a fact to the user's own spaces", async () => { const owner = await newUser(); const other = await newUser("Grace"); diff --git a/packages/db/src/user-profile.ts b/packages/db/src/user-profile.ts index c8f80f3..da24418 100644 --- a/packages/db/src/user-profile.ts +++ b/packages/db/src/user-profile.ts @@ -250,36 +250,54 @@ async function addFacts( return results; } -async function editFact( +/** + * Applies the user's fact edits in order. A new wording must still say something and must not + * repeat another fact, compared the way new facts are. + */ +async function editFacts( tx: Prisma.TransactionClient, userId: string, - edit: UserFactEdit, + edits: UserFactEdit[], ): Promise { - const fact = await tx.userFact.findFirst({ - where: { id: edit.id, userId }, - select: { id: true }, + if (edits.length === 0) return; + const facts = await tx.userFact.findMany({ + where: { userId }, + select: { id: true, value: true }, }); - if (!fact) throw new UserFactNotFoundError(); - const data: Prisma.UserFactUpdateInput = {}; - if (edit.category !== undefined) { - data.category = edit.category; - data.sensitive = isSensitiveUserFactCategory(edit.category); + const values = new Map(facts.map((fact) => [fact.id, fact.value])); + for (const edit of edits) { + const current = values.get(edit.id); + if (current === undefined) throw new UserFactNotFoundError(); + const data: Prisma.UserFactUpdateInput = {}; + if (edit.category !== undefined) { + data.category = edit.category; + data.sensitive = isSensitiveUserFactCategory(edit.category); + } + if (edit.value !== undefined && edit.value.trim() !== current) { + const value = edit.value.trim(); + const key = normalizeUserFactValue(value); + if (!key) throw new InvalidUserFactError("Fact is empty"); + for (const [id, other] of values) { + if (id !== edit.id && normalizeUserFactValue(other) === key) { + throw new InvalidUserFactError("This fact already exists"); + } + } + values.set(edit.id, value); + data.value = value; + // The stored embedding described the old wording. + data.embedding = []; + data.embeddingModel = null; + } + if (edit.spaceIds !== undefined) { + const spaces = visibleSpacesOf(edit.spaceIds); + data.allSpaces = spaces === null; + data.visibleSpaces = { + deleteMany: {}, + ...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}), + }; + } + if (Object.keys(data).length > 0) await tx.userFact.update({ where: { id: edit.id }, data }); } - if (edit.value !== undefined) { - data.value = edit.value.trim(); - // The stored embedding described the old wording. - data.embedding = []; - data.embeddingModel = null; - } - if (edit.spaceIds !== undefined) { - const spaces = visibleSpacesOf(edit.spaceIds); - data.allSpaces = spaces === null; - data.visibleSpaces = { - deleteMany: {}, - ...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}), - }; - } - await tx.userFact.update({ where: { id: fact.id }, data }); } async function confirmFacts( @@ -341,7 +359,7 @@ export async function updateUserProfile( }); } const added = await addFacts(tx, userId, update.addFacts ?? []); - for (const edit of update.editFacts ?? []) await editFact(tx, userId, edit); + await editFacts(tx, userId, update.editFacts ?? []); await confirmFacts(tx, userId, update.confirmFactIds ?? []); return added; }); diff --git a/packages/testkit/src/user-profile.postgres.test.ts b/packages/testkit/src/user-profile.postgres.test.ts index 11b3ede..462e461 100644 --- a/packages/testkit/src/user-profile.postgres.test.ts +++ b/packages/testkit/src/user-profile.postgres.test.ts @@ -161,6 +161,47 @@ describeWithDatabase("user profile through the API and the main bot", () => { expect(forgotten.facts).toEqual([]); expect(forgotten.profile).toMatchObject({ timeZone: null, language: null, summary: "" }); }); + + it("edits a fact's wording, category and spaces for its owner only", async () => { + const cookie = await signup(app, `profile-edit-${stamp}@rakazo.test`, "Profile Edit"); + const intruder = await signup(app, `profile-edit-other-${stamp}@rakazo.test`, "Intruder"); + const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" }); + const elsewhere = await rpc<{ id: string }>(app, intruder, "spaces/create", { name: "Theirs" }); + const added = await rpc(app, cookie, "profile/update", { + addFacts: [ + { category: "preferences", value: "Prefers tea" }, + { category: "work", value: "Works late" }, + ], + }); + const tea = added.facts.find((fact) => fact.value === "Prefers tea")!; + + const edited = await rpc(app, cookie, "profile/update", { + editFacts: [ + { id: tea.id, value: "Prefers green tea", category: "habits", spaceIds: [side.id] }, + ], + }); + expect(edited.facts.find((fact) => fact.id === tea.id)).toMatchObject({ + value: "Prefers green tea", + category: "habits", + allSpaces: false, + spaceIds: [side.id], + }); + + const edit = (body: unknown, who = cookie) => raw(app, who, "profile/update", body); + expect((await edit({ editFacts: [{ id: tea.id, value: "works late!" }] })).status).toBe(400); + expect((await edit({ editFacts: [{ id: tea.id, spaceIds: [elsewhere.id] }] })).status).toBe( + 400, + ); + expect((await edit({ editFacts: [{ id: tea.id, value: "Stolen" }] }, intruder)).status).toBe( + 404, + ); + expect((await rpc(app, intruder, "profile/get")).facts).toEqual([]); + expect( + (await rpc(app, cookie, "profile/get")).facts.find( + (fact) => fact.id === tea.id, + ), + ).toMatchObject({ value: "Prefers green tea", spaceIds: [side.id] }); + }); }); async function signup(app: App, email: string, name: string) { From e13dbe5ffaa7255586908b507afd6d2f21bcd6e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:39 +0000 Subject: [PATCH 4/5] feat(inbox): list and answer items from all of the user's spaces The inbox in the main conversation only showed asks, approvals, takeovers and unread threads of the current space. The main conversation lives in the home space, so anything a bot needed in a project space never reached it. - inbox.list now covers every space the user belongs to (except spaces being deleted), still only the user's own bots, runs and threads. Items from another space carry that space's id and name. - The web inbox shows the space name next to the bot for those items, answers them with that space's header so the existing thread procedures check membership and ownership for that space, and opens their thread in it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/inbox.test.ts | 36 ++++++++++ apps/api/src/inbox.ts | 47 ++++++++++-- apps/web/e2e/inbox.spec.ts | 69 ++++++++++++++++++ apps/web/src/components/MainInbox.test.tsx | 79 ++++++++++++++++---- apps/web/src/components/MainInbox.tsx | 60 +++++++++++----- apps/web/src/pages/Shell.tsx | 7 +- packages/contracts/src/inbox.ts | 9 ++- packages/contracts/src/rpc.ts | 2 +- packages/testkit/src/authorization.test.ts | 10 +++ packages/testkit/src/inbox.test.ts | 83 +++++++++++++++++++--- 10 files changed, 351 insertions(+), 51 deletions(-) diff --git a/apps/api/src/inbox.test.ts b/apps/api/src/inbox.test.ts index 08360a1..f5e754d 100644 --- a/apps/api/src/inbox.test.ts +++ b/apps/api/src/inbox.test.ts @@ -18,9 +18,11 @@ function run( status: "waiting_input" | "waiting_takeover", thread: Partial = {}, bot = scout, + spaceId = "space-home", ): InboxRunRow { return { id, + spaceId, status, updatedAt: at(0), bot, @@ -48,8 +50,10 @@ const approval = { function unreadThread( bot: InboxUnreadThreadRow["bot"], latest?: { id: string; text: string; minute: number }, + spaceId = "space-home", ): InboxUnreadThreadRow { return { + spaceId, createdAt: at(0), bot, messages: latest @@ -227,4 +231,36 @@ describe("buildInboxItems", () => { }); expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() }); }); + + it("names the space of items from the user's other spaces", () => { + const side = { id: "space-side", name: "Side project" }; + const items = buildInboxItems({ + runs: [ + run("run-home", "waiting_input"), + run("run-side", "waiting_input", {}, writer, side.id), + run("takeover-side", "waiting_takeover", {}, writer, side.id), + ], + messages: [ + message("ask-home", "run-home", [question], 1), + message("ask-side", "run-side", [question], 2), + ], + unreadThreads: [ + unreadThread( + { id: "bot-quiet", name: "Quiet", color: "#3EC5A8" }, + { id: "quiet-latest", text: "Done", minute: 3 }, + side.id, + ), + ], + otherSpaces: new Map([[side.id, side]]), + }); + + expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([ + ["takeover", "Side project"], + ["ask", null], + ["ask", "Side project"], + ["unread", "Side project"], + ]); + expect(items[1]).not.toHaveProperty("space"); + expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } }); + }); }); diff --git a/apps/api/src/inbox.ts b/apps/api/src/inbox.ts index 549d5a6..23162b6 100644 --- a/apps/api/src/inbox.ts +++ b/apps/api/src/inbox.ts @@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { promptSnippet } from "./runs.js"; -/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */ +/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */ const INBOX_RUN_LIMIT = 50; const INBOX_UNREAD_LIMIT = 50; const INBOX_PREVIEW_LENGTH = 280; @@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280; type AskBlock = Extract; type ComputerBlock = Extract; type InboxBot = { id: string; name: string; color: string }; +type InboxSpace = NonNullable; export interface InboxRunRow { id: string; + spaceId: string; status: string; updatedAt: Date; bot: InboxBot; @@ -33,6 +35,7 @@ export interface InboxMessageRow { } export interface InboxUnreadThreadRow { + spaceId: string; createdAt: Date; bot: InboxBot | null; /** The newest bot message, if any. */ @@ -95,14 +98,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number { /** * Turns waiting runs, their ask and takeover messages, and unread threads into inbox items: * everything that waits on the user first, then unread threads not already covered, each - * oldest first. + * oldest first. Items from another space name it. */ export function buildInboxItems(input: { runs: InboxRunRow[]; /** Ask and takeover messages of those runs, newest first. */ messages: InboxMessageRow[]; unreadThreads: InboxUnreadThreadRow[]; + /** The user's other spaces by id; rows of any space not listed belong to the current one. */ + otherSpaces?: ReadonlyMap; }): InboxItem[] { + const spaceOf = (spaceId: string) => { + const space = input.otherSpaces?.get(spaceId); + return space ? { space } : {}; + }; const messagesByRun = new Map(); for (const message of input.messages) { if (!message.runId) continue; @@ -122,6 +131,7 @@ export function buildInboxItems(input: { kind: isApprovalAskBlock(pending.block) ? "approval" : "ask", bot: run.bot, target, + ...spaceOf(run.spaceId), runId: run.id, messageId: pending.message.id, ask: pending.block, @@ -135,6 +145,7 @@ export function buildInboxItems(input: { kind: "takeover", bot: run.bot, target: { botId: run.bot.id }, + ...spaceOf(run.spaceId), runId: run.id, messageId: request?.message.id ?? null, text: request?.block.text ?? "", @@ -154,6 +165,7 @@ export function buildInboxItems(input: { kind: "unread", bot: thread.bot, target, + ...spaceOf(thread.spaceId), messageId: latest?.id ?? null, text: previewText(latest?.blocks), createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(), @@ -165,13 +177,34 @@ export function buildInboxItems(input: { const botSelect = { id: true, name: true, color: true } as const; -/** The current space's inbox: only the actor's own bots, runs and threads. */ +/** + * The other spaces whose items the inbox shows: every space the user belongs to, except spaces + * being deleted. + */ +async function otherInboxSpaces( + prisma: PrismaClient, + actor: Actor, +): Promise> { + const memberships = await prisma.spaceMember.findMany({ + where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } }, + select: { space: { select: { id: true, name: true } } }, + }); + return new Map(memberships.map(({ space }) => [space.id, space])); +} + +/** + * The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are + * answered and opened in their own space, where the thread procedures check membership and + * ownership again. + */ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise { + const otherSpaces = await otherInboxSpaces(prisma, actor); + const spaceIds = [actor.spaceId, ...otherSpaces.keys()]; const ownBot = { userId: actor.userId, archivedAt: null }; const [runs, unreadThreads] = await Promise.all([ prisma.run.findMany({ where: { - spaceId: actor.spaceId, + spaceId: { in: spaceIds }, userId: actor.userId, status: { in: ["waiting_input", "waiting_takeover"] }, bot: ownBot, @@ -179,6 +212,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise { + const stamp = Date.now(); + await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross"); + await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 }); + const helper = await createBot(page, "Helper"); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", { + name: "Scout", + title: "", + description: "", + notifyOnFinish: true, + computerMode: "team", + }); + await rpcInSpace(page, side.id, "threads/send", { + botId: scout.id, + text: "ask me which city to use", + }); + const runInSide = async () => + ( + await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", { + botId: scout.id, + }) + ).run ?? null; + await expect + .poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 }) + .toBe("waiting_input"); + + await openMainConversation(page, helper.id); + const inbox = page.getByRole("region", { name: "Needs you" }); + const ask = inbox.locator("[data-kind='ask']"); + await expect(ask).toContainText("Scout", { timeout: 30_000 }); + await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await captureScreenshot(page, testInfo, "inbox-cross-space"); + + await ask.getByRole("button", { name: "Edit first" }).click(); + await ask.getByRole("textbox", { name: "Answer" }).fill("Paris"); + await ask.getByRole("button", { name: "Send answer" }).click(); + await expect(ask).toHaveCount(0); + await expect.poll(runInSide, { timeout: 60_000 }).toBeNull(); + + // The reply arrives as unread from the other space and opens there. + const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" }); + await expect(reply).toContainText("Paris", { timeout: 45_000 }); + await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await reply.getByRole("button").click(); + await page.waitForURL(new RegExp(`/app/${scout.id}$`)); + await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); + expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id); +}); + +async function rpcInSpace( + page: Page, + spaceId: string, + procedure: string, + body: unknown, +): Promise { + const response = await page.request.post(`/rpc/${procedure}`, { + data: { json: body }, + headers: { "x-rakazo-space-id": spaceId }, + }); + const parsed = (await response.json()) as { json?: T; error?: { message?: string } }; + if (!response.ok() || parsed.error) { + throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`); + } + return parsed.json as T; +} + async function createBot(page: Page, name: string) { return rpc<{ id: string }>(page, "bots/create", { name, diff --git a/apps/web/src/components/MainInbox.test.tsx b/apps/web/src/components/MainInbox.test.tsx index 0d6175d..c208258 100644 --- a/apps/web/src/components/MainInbox.test.tsx +++ b/apps/web/src/components/MainInbox.test.tsx @@ -79,6 +79,22 @@ const unread: InboxItem = { text: "Draft ready for review", createdAt, }; +const side = { id: "space-side", name: "Side project" }; +const askInOtherSpace: InboxItem = { + kind: "ask", + bot: writer, + target: { botId: writer.id }, + space: side, + runId: "run-side", + messageId: "msg-side", + ask: { kind: "ask", text: "Which title fits?", status: "pending" }, + createdAt, +}; +const groupAskInOtherSpace: InboxItem = { + ...approvalInGroup, + space: side, + messageId: "msg-side-group", +}; const idleBots = [ { id: scout.id, status: "idle", unread: false, preview: "" }, @@ -89,10 +105,18 @@ let container: HTMLDivElement; let root: Root; const onOpenBot = vi.fn(); const onOpenGroup = vi.fn(); +const onOpenInSpace = vi.fn(); function render(bots = idleBots) { return act(async () => - root.render(), + root.render( + , + ), ); } @@ -111,6 +135,7 @@ beforeEach(() => { api.markRead.mockReset().mockResolvedValue({ ok: true }); onOpenBot.mockReset(); onOpenGroup.mockReset(); + onOpenInSpace.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -154,12 +179,10 @@ it("answers through the thread the ask came from and drops only that item", asyn itemOf("approval").querySelector("[data-answer]")?.click(), ); - expect(api.answer).toHaveBeenCalledWith({ - groupId: "group-ops", - runId: "run-approval", - messageId: "msg-approval", - answer: "Paris", - }); + expect(api.answer).toHaveBeenCalledWith( + { groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris" }, + { context: {} }, + ); expect(api.markRead).not.toHaveBeenCalled(); expect(api.list).toHaveBeenCalledTimes(2); expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1); @@ -173,7 +196,7 @@ it("marks the bot's own thread read before answering, so the question does not r await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); - expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }); + expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }, { context: {} }); expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan( api.answer.mock.invocationCallOrder[0]!, ); @@ -187,12 +210,10 @@ it("refreshes after a failed answer so a stale ask disappears", async () => { await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); - expect(api.answer).toHaveBeenCalledWith({ - botId: scout.id, - runId: "run-ask", - messageId: "msg-ask", - answer: "Paris", - }); + expect(api.answer).toHaveBeenCalledWith( + { botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris" }, + { context: {} }, + ); expect(container.querySelector("[data-testid='main-inbox']")).toBeNull(); }); @@ -208,6 +229,36 @@ it("opens the bot's thread for takeovers and unread threads, and the source thre expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]); }); +it("names another space's items and answers and opens them in that space", async () => { + api.list.mockResolvedValueOnce({ items: [askInOtherSpace, askInOwnThread] }); + api.list.mockResolvedValue({ items: [askInOwnThread, groupAskInOtherSpace] }); + api.answer.mockResolvedValue({ ok: true }); + await render(); + + const items = container.querySelectorAll("[data-testid='main-inbox-item']"); + expect(items[0]?.querySelector("[data-testid='main-inbox-space']")?.textContent).toBe( + "· Side project", + ); + expect(items[1]?.querySelector("[data-testid='main-inbox-space']")).toBeNull(); + + await act(async () => items[0]?.querySelector("[data-answer]")?.click()); + expect(api.markRead).toHaveBeenCalledWith( + { botId: writer.id }, + { context: { spaceId: side.id } }, + ); + expect(api.answer).toHaveBeenCalledWith( + { botId: writer.id, runId: "run-side", messageId: "msg-side", answer: "Paris" }, + { context: { spaceId: side.id } }, + ); + + const [own, group] = container.querySelectorAll("[data-testid='main-inbox-item']"); + await act(async () => group?.querySelector("button")?.click()); + await act(async () => own?.querySelector("button")?.click()); + expect(onOpenInSpace.mock.calls).toEqual([[side.id, "/app/g/group-ops"]]); + expect(onOpenBot.mock.calls).toEqual([[scout.id]]); + expect(onOpenGroup).not.toHaveBeenCalled(); +}); + it("refreshes when a bot's state changes, not on every render", async () => { api.list.mockResolvedValue({ items: [] }); await render(); diff --git a/apps/web/src/components/MainInbox.tsx b/apps/web/src/components/MainInbox.tsx index 5f3007b..8ea65bd 100644 --- a/apps/web/src/components/MainInbox.tsx +++ b/apps/web/src/components/MainInbox.tsx @@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000; type PendingAskItem = Extract; +/** Thread procedures for an item run in the item's own space. */ +function itemContext(item: InboxItem) { + return { context: item.space ? { spaceId: item.space.id } : {} }; +} + /** - * What the user's bots need from them outside the main conversation, with each bot as the - * sender: asks and approvals are answered in place, takeovers and unread threads open the - * bot's thread. + * What the user's bots in all of their spaces need from them outside the main conversation, with + * each bot as the sender: asks and approvals are answered in place, takeovers and unread threads + * open the bot's thread. */ export function MainInbox({ bots, onOpenBot, onOpenGroup, + onOpenInSpace, }: { /** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */ bots: readonly Pick[]; onOpenBot: (botId: string) => void; onOpenGroup: (groupId: string) => void; + /** Opens an app path, such as a thread, in another of the user's spaces. */ + onOpenInSpace: (spaceId: string, path: string) => void; }) { const { t } = useLingui(); const [items, setItems] = useState([]); @@ -59,11 +67,16 @@ export function MainInbox({ }, [refresh]); const open = useCallback( - (target: InboxThreadTarget) => { - if ("botId" in target) onOpenBot(target.botId); + ({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => { + if (space) { + onOpenInSpace( + space.id, + "botId" in target ? `/app/${target.botId}` : `/app/g/${target.groupId}`, + ); + } else if ("botId" in target) onOpenBot(target.botId); else onOpenGroup(target.groupId); }, - [onOpenBot, onOpenGroup], + [onOpenBot, onOpenGroup, onOpenInSpace], ); const answer = useCallback( @@ -72,14 +85,12 @@ export function MainInbox({ // Answering reads the ask, so the bot's thread should not come back as unread with the // same question. Mark it first: the reply to this answer marks it unread again. if ("botId" in item.target) { - await rpc.threads.markRead(item.target).catch(() => undefined); + await rpc.threads.markRead(item.target, itemContext(item)).catch(() => undefined); } - await rpc.threads.answer({ - ...item.target, - runId: item.runId, - messageId: item.messageId, - answer: text, - }); + await rpc.threads.answer( + { ...item.target, runId: item.runId, messageId: item.messageId, answer: text }, + itemContext(item), + ); const key = inboxItemKey(item); setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key)); } finally { @@ -105,10 +116,10 @@ export function MainInbox({
    open(item.target)} + onClick={() => open(item)} className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent" > - + {item.text ? ( {bot.name} + {space ? ( + + + {space.name} + + ) : null} ); } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index bf25ff9..a3b1e3a 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -4637,7 +4637,12 @@ export function ShellPage() { )} {activeGroup?.kind === "main" && !showProjectTimeline ? ( - + ) : null} {recordingSkill ? (
    diff --git a/packages/contracts/src/inbox.ts b/packages/contracts/src/inbox.ts index 7894cd2..3bd318d 100644 --- a/packages/contracts/src/inbox.ts +++ b/packages/contracts/src/inbox.ts @@ -13,6 +13,11 @@ const InboxItemBase = z.object({ /** The bot that needs the user, shown as the sender. */ bot: z.object({ id: Id, name: z.string(), color: z.string() }), target: InboxThreadTargetSchema, + /** + * Set when the item belongs to another of the user's spaces: call the thread procedures and + * open the thread in that space. + */ + space: z.object({ id: Id, name: z.string() }).optional(), createdAt: z.string(), }); @@ -24,8 +29,8 @@ const PendingAskItem = InboxItemBase.extend({ }); /** - * What a bot needs from the user outside the main conversation: an answer, an approval, the - * computer screen, or a look at an unread thread. + * What a bot needs from the user outside the main conversation, in any of the user's spaces: an + * answer, an approval, the computer screen, or a look at an unread thread. */ export const InboxItemSchema = z.discriminatedUnion("kind", [ PendingAskItem.extend({ kind: z.literal("ask") }), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..0d11a12 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -815,7 +815,7 @@ export const appContract = { .input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() })) .output(z.array(DelegatedTaskSchema)), }, - /** Asks, approvals, takeovers and unread threads outside the main conversation. */ + /** Asks, approvals, takeovers and unread threads outside the main conversation, in all spaces. */ inbox: { list: oc.output(InboxListOutputSchema), }, diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ce1e785..a4490cb 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -712,6 +712,16 @@ describeWithDatabase("API authorization and resource isolation", () => { .items, ).toContainEqual(expect.objectContaining({ kind: "ask", runId: run.id })); expect(await rpc(app, member, "inbox/list", {}, sharedSpaceId)).toEqual({ items: [] }); + // From another of the member's spaces the inbox spans the shared one too, still without + // the owner's runs. + const memberSpace = await rpc<{ id: string }>( + app, + member, + "spaces/create", + { name: "Member side" }, + sharedSpaceId, + ); + expect(await rpc(app, member, "inbox/list", {}, memberSpace.id)).toEqual({ items: [] }); }); it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => { diff --git a/packages/testkit/src/inbox.test.ts b/packages/testkit/src/inbox.test.ts index 2fe3d09..22dfc2f 100644 --- a/packages/testkit/src/inbox.test.ts +++ b/packages/testkit/src/inbox.test.ts @@ -94,7 +94,7 @@ describeInbox("inbox.list", () => { expect(await listInbox(app, cookie)).toEqual([]); }); - it("collects approvals, takeovers and unread threads of this space outside the main conversation", async () => { + it("collects approvals, takeovers and unread threads of every space outside the main conversation", async () => { const cookie = await signup(app, `inbox-mixed-${stamp}@rakazo.test`, "Inbox Mixed"); const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); const scout = await createBot(app, cookie, "Scout"); @@ -187,6 +187,14 @@ describeInbox("inbox.list", () => { text: "Sign in to continue.", createdAt: expect.any(String), }, + expect.objectContaining({ + kind: "ask", + bot: expect.objectContaining({ id: remote.id }), + target: { botId: remote.id }, + space: { id: remoteSpace.id, name: "Remote" }, + runId: remoteAsk.runId, + messageId: remoteAsk.messageId, + }), expect.objectContaining({ kind: "unread", bot: expect.objectContaining({ id: scout.id }), @@ -196,16 +204,74 @@ describeInbox("inbox.list", () => { ]); // The Pilot thread is unread too, but its takeover already covers it. expect(items.filter((item) => item.bot.id === pilot.id)).toHaveLength(1); + // Items of the current space carry no space. + expect(items.filter((item) => item.space).map((item) => item.bot.id)).toEqual([remote.id]); - expect(await listInbox(app, cookie, remoteSpace.id)).toEqual([ + // From the other space, the same items with the sides swapped. + const fromRemote = await listInbox(app, cookie, remoteSpace.id); + expect(fromRemote.map((item) => [item.kind, item.space?.id ?? null])).toEqual([ + ["approval", me.spaceId], + ["takeover", me.spaceId], + ["ask", null], + ["unread", me.spaceId], + ]); + }); + + it("answers an item from another space only in that space and only for its owner", async () => { + const cookie = await signup(app, `inbox-cross-${stamp}@rakazo.test`, "Inbox Cross"); + const intruder = await signup(app, `inbox-intruder-${stamp}@rakazo.test`, "Inbox Intruder"); + const me = await rpc<{ spaceId: string }>(app, cookie, "me"); + const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" }); + const scout = await createBot(app, cookie, "Scout", side.id); + + await rpc( + app, + cookie, + "threads/send", + { botId: scout.id, text: "ask me which city to use" }, + side.id, + ); + await waitForThread( + app, + cookie, + scout.id, + (snapshot) => snapshot.run?.status === "waiting_input", + side.id, + ); + + const [item] = await listInbox(app, cookie, me.spaceId); + if (item?.kind !== "ask") throw new Error("expected an ask item"); + expect(item).toMatchObject({ + space: { id: side.id, name: "Side" }, + target: { botId: scout.id }, + }); + const answer = { + ...item.target, + runId: item.runId, + messageId: item.messageId, + answer: "Paris", + }; + + // The thread is not in the current space, and the intruder belongs to neither space. + await expect(rpc(app, cookie, "threads/answer", answer, me.spaceId)).rejects.toThrow(); + await expect(rpc(app, intruder, "threads/answer", answer, side.id)).rejects.toThrow(); + await expect(rpc(app, intruder, "threads/answer", answer)).rejects.toThrow(); + expect(await listInbox(app, intruder)).toEqual([]); + expect((await listInbox(app, cookie, me.spaceId)).map((entry) => entry.kind)).toEqual(["ask"]); + + await rpc(app, cookie, "threads/answer", answer, item.space?.id); + await waitForThread(app, cookie, scout.id, (snapshot) => !snapshot.run, side.id); + expect(await listInbox(app, cookie, me.spaceId)).toEqual([ expect.objectContaining({ - kind: "ask", - bot: expect.objectContaining({ id: remote.id }), - target: { botId: remote.id }, - runId: remoteAsk.runId, - messageId: remoteAsk.messageId, + kind: "unread", + target: { botId: scout.id }, + space: { id: side.id, name: "Side" }, + text: expect.stringContaining("Paris"), }), ]); + + await rpc(app, cookie, "threads/markRead", { botId: scout.id }, side.id); + expect(await listInbox(app, cookie, me.spaceId)).toEqual([]); }); }); @@ -269,11 +335,12 @@ async function waitForThread( cookie: string, botId: string, done: (snapshot: ThreadSnapshot) => boolean, + spaceId?: string, ): Promise { const start = Date.now(); let last: ThreadSnapshot | null = null; while (Date.now() - start < 20_000) { - last = await rpc(app, cookie, "threads/get", { botId }); + last = await rpc(app, cookie, "threads/get", { botId }, spaceId); if (done(last)) return last; await new Promise((resolve) => setTimeout(resolve, 100)); } From 7a58bc7ba99be996a961cf93f225cdfe9b9554d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:42:24 +0000 Subject: [PATCH 5/5] fix(profile): keep the fact editor's Save label visible Small buttons in ui-web merge their text-ui-label size over the default variant's text color, so the small Save button rendered ink on ink. The editor now uses default-size controls, with the category select at the same height. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/src/components/UserFactEditor.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/UserFactEditor.tsx b/apps/web/src/components/UserFactEditor.tsx index a7b7e9a..17306e1 100644 --- a/apps/web/src/components/UserFactEditor.tsx +++ b/apps/web/src/components/UserFactEditor.tsx @@ -64,7 +64,7 @@ export function UserFactEditor({ />
    @@ -80,7 +80,7 @@ export function UserFactEditor({ {showSpaces ? ( } + render={ -