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 { + 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/inbox.spec.ts b/apps/web/e2e/inbox.spec.ts index b31ea48..683942c 100644 --- a/apps/web/e2e/inbox.spec.ts +++ b/apps/web/e2e/inbox.spec.ts @@ -39,6 +39,75 @@ test("answers a specialist's question from the main conversation", async ({ page await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); }); +test("answers a question from another space in the main conversation", async ({ + page, +}, testInfo) => { + 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/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/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/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/components/UserFactEditor.tsx b/apps/web/src/components/UserFactEditor.tsx new file mode 100644 index 0000000..17306e1 --- /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/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/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..901ba7e 100644 --- a/packages/adapters/src/memory-context.ts +++ b/packages/adapters/src/memory-context.ts @@ -1,6 +1,9 @@ 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] & { scope: "bot" | "user"; @@ -45,10 +48,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 +65,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/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 1dc59cb..9ca5e96 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -833,7 +833,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/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/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/authorization.test.ts b/packages/testkit/src/authorization.test.ts index c640e4d..329b4f6 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -718,6 +718,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)); } 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", 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) {