Merge memory append, profile editing and the cross-space inbox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:43:12 +00:00
commit dab9398c25
No known key found for this signature in database
38 changed files with 1363 additions and 150 deletions

View File

@ -18,9 +18,11 @@ function run(
status: "waiting_input" | "waiting_takeover",
thread: Partial<InboxRunRow["thread"]> = {},
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 } });
});
});

View File

@ -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<MessageBlock, { kind: "ask" }>;
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
type InboxBot = { id: string; name: string; color: string };
type InboxSpace = NonNullable<InboxItem["space"]>;
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<string, InboxSpace>;
}): InboxItem[] {
const spaceOf = (spaceId: string) => {
const space = input.otherSpaces?.get(spaceId);
return space ? { space } : {};
};
const messagesByRun = new Map<string, InboxMessageRow[]>();
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<Map<string, InboxSpace>> {
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<InboxItem[]> {
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<Inb
},
select: {
id: true,
spaceId: true,
status: true,
threadId: true,
updatedAt: true,
@ -197,12 +231,13 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
// Only specialists' own threads; group threads, the main conversation included, stay out.
prisma.thread.findMany({
where: {
spaceId: actor.spaceId,
spaceId: { in: spaceIds },
userId: actor.userId,
unread: true,
bot: { ...ownBot, kind: "specialist" },
},
select: {
spaceId: true,
createdAt: true,
bot: { select: botSelect },
messages: {
@ -232,5 +267,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
orderBy: { seq: "desc" },
select: { id: true, runId: true, blocks: true, createdAt: true },
});
return buildInboxItems({ runs, messages, unreadThreads });
return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
}

View File

@ -11,6 +11,7 @@ import type {
} from "@rakazo/adapter-kit";
import {
computerControlExpireJobKey,
MemoryDocumentTooLargeError,
messagingDeliverJob,
routineJobKey,
routineWakeupJob,
@ -2439,21 +2440,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",

View File

@ -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<T = unknown>(
page: Page,
spaceId: string,
procedure: string,
body: unknown,
): Promise<T> {
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,

View File

@ -114,6 +114,36 @@ test("memory and skills are readable and editable in the app", async ({ page },
await expect(botDocEditor).toHaveValue(new RegExp(botMarker));
await botMemory.getByRole("button", { name: "Cancel", exact: true }).click();
// Remembering appends a dated note and keeps the edit above.
await rpc(page, "threads/send", {
botId: activeBotId(page),
text: `remember that the tester ${stamp} prefers tea`,
});
await expect
.poll(
async () =>
(
await rpc<MemoryDocument[]>(page, "memory/list", {
botId: activeBotId(page),
scope: "bot",
})
).find((doc) => doc.path === "MEMORY.md")?.content,
{ timeout: 60_000 },
)
.toMatch(
new RegExp(
`^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`,
),
);
await expect
.poll(
async () =>
(await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ??
null,
{ timeout: 60_000 },
)
.toBeNull();
// Skills: create one through the editor, reopen it, edit, then delete it.
// Builtin catalog is currently empty; user skills still cover create/edit/delete.
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();

View File

@ -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<UserProfileState>(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: [],
});
});

View File

@ -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(<MainInbox bots={bots} onOpenBot={onOpenBot} onOpenGroup={onOpenGroup} />),
root.render(
<MainInbox
bots={bots}
onOpenBot={onOpenBot}
onOpenGroup={onOpenGroup}
onOpenInSpace={onOpenInSpace}
/>,
),
);
}
@ -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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLElement>("[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<HTMLButtonElement>("[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<HTMLElement>("[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();

View File

@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000;
type PendingAskItem = Extract<InboxItem, { kind: "ask" | "approval" }>;
/** 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<Bot, "id" | "status" | "unread" | "preview">[];
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<InboxItem[]>([]);
@ -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({
<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={() => open(item.target)}
onClick={() => open(item)}
className="w-fit rounded-md hover:underline"
>
<Sender bot={item.bot} />
<Sender bot={item.bot} space={item.space} />
</button>
<AskCard
block={item.ask}
@ -120,11 +131,11 @@ export function MainInbox({
) : (
<button
type="button"
onClick={() => 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"
>
<span className="min-w-0 flex-1">
<Sender bot={item.bot} />
<Sender bot={item.bot} space={item.space} />
{item.text ? (
<span
className="mt-0.5 line-clamp-2 text-ui-body-sm text-muted-foreground"
@ -154,16 +165,27 @@ export function MainInbox({
);
}
function Sender({ bot }: { bot: InboxItem["bot"] }) {
/** The bot, and the space it works in when that is not the current one. */
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
const color = resolvePersonaColorDef(bot.id, parseBotAvatar(bot.color).color).light;
return (
<span
className="flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
style={{ color }}
dir="auto"
>
<BotAvatar color={bot.color} identity={bot.id} size={22} className="shrink-0" />
<span className="truncate">{bot.name}</span>
{space ? (
<span
data-testid="main-inbox-space"
className="truncate font-normal text-muted-foreground"
dir="auto"
>
<span aria-hidden="true">· </span>
{space.name}
</span>
) : null}
</span>
);
}

View File

@ -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<UserFactCategory, string>;
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 (
<li data-testid="user-fact-editor">
<form
className="space-y-2"
onSubmit={(event) => {
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();
}
}}
>
<Input
autoFocus
aria-label={t`Fact`}
value={draft.value}
maxLength={USER_FACT_VALUE_MAX_LENGTH}
onChange={(event) => setDraft({ ...draft, value: event.target.value })}
/>
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
className="*:data-[slot=native-select]:h-control-md"
aria-label={t`Category`}
value={draft.category}
onChange={(event) =>
setDraft({ ...draft, category: event.target.value as UserFactCategory })
}
>
{USER_FACT_CATEGORIES.map((category) => (
<NativeSelectOption key={category} value={category}>
{categoryLabels[category]}
</NativeSelectOption>
))}
</NativeSelect>
{showSpaces ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" className="max-w-56" />}
>
<span className="truncate">
{factSpaceNames(draft.spaceIds, spaces).join(", ") || allSpaces}
</span>
<ChevronDownIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto min-w-44">
<DropdownMenuCheckboxItem
checked={draft.spaceIds === null}
onCheckedChange={() => setDraft({ ...draft, spaceIds: null })}
>
{allSpaces}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{spaces.map((space) => (
<DropdownMenuCheckboxItem
key={space.id}
checked={draft.spaceIds?.includes(space.id) ?? false}
onCheckedChange={(checked) =>
setDraft({
...draft,
spaceIds: toggleFactSpace(draft.spaceIds, space.id, checked),
})
}
>
<span className="truncate" dir="auto">
{space.name}
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="ms-auto flex items-center gap-1">
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={busy || !draft.value.trim()}>
<Trans>Save</Trans>
</Button>
</div>
</div>
</form>
</li>
);
}

View File

@ -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<UserProfileState | null>(null);
const [spaces, setSpaces] = useState<Array<{ id: string; name: string }>>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<UserProfileState>) {
if (busy) return;
async function apply(change: () => Promise<UserProfileState>): Promise<boolean> {
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]}
</h3>
<ul className="mt-3 space-y-3">
{group.facts.map((fact) => (
<Row
key={fact.id}
testId="user-fact"
status={fact.status}
value={fact.value}
detail={sourceLabels[fact.source]}
actions={(describedBy) => (
<>
{fact.status === "pending" ? (
{group.facts.map((fact) =>
fact.id === editingId ? (
<UserFactEditor
key={fact.id}
fact={fact}
spaces={spaces}
categoryLabels={categoryLabels}
busy={busy}
onCancel={() => 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);
});
}}
/>
) : (
<Row
key={fact.id}
testId="user-fact"
status={fact.status}
value={fact.value}
detail={[
sourceLabels[fact.source],
factSpaceNames(fact.allSpaces ? null : fact.spaceIds, spaces).join(", "),
]
.filter(Boolean)
.join(" · ")}
actions={(describedBy) => (
<>
{fact.status === "pending" ? (
<Button
variant="outline"
size="sm"
aria-describedby={describedBy}
disabled={busy}
onClick={() =>
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
}
>
<Trans>Confirm</Trans>
</Button>
) : null}
<Button
variant="outline"
size="sm"
variant="ghost"
size="icon-sm"
aria-label={t`Edit`}
aria-describedby={describedBy}
disabled={busy}
onClick={() =>
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
}
onClick={() => {
setError(null);
setEditingId(fact.id);
}}
>
<Trans>Confirm</Trans>
<PencilIcon />
</Button>
) : null}
{deleteButton(
describedBy,
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
)}
</>
)}
/>
))}
{deleteButton(
describedBy,
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
)}
</>
)}
/>
),
)}
</ul>
</section>
))}

View File

@ -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<UserFact> & Pick<UserFact, "id">): 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([]);
});
});

View File

@ -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<UpdateUserProfile["editFacts"]>[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);
}

View File

@ -4637,7 +4637,12 @@ export function ShellPage() {
</>
)}
{activeGroup?.kind === "main" && !showProjectTimeline ? (
<MainInbox bots={bots} onOpenBot={openBot} onOpenGroup={openGroup} />
<MainInbox
bots={bots}
onOpenBot={openBot}
onOpenGroup={openGroup}
onOpenInSpace={openSpaceChat}
/>
) : null}
{recordingSkill ? (
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">

View File

@ -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.

View File

@ -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";

View File

@ -0,0 +1,13 @@
/**
* Largest Markdown memory document a commit may store, in UTF-8 bytes: half of what a run reads,
* so a bot's document and the space's shared one both reach the prompt.
*/
export const MEMORY_DOCUMENT_MAX_BYTES = 16 * 1024;
/** A commit would grow a memory document past MEMORY_DOCUMENT_MAX_BYTES. */
export class MemoryDocumentTooLargeError extends Error {
constructor(readonly path: string) {
super(`${path} would exceed ${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB`);
this.name = "MemoryDocumentTooLargeError";
}
}

View File

@ -250,6 +250,8 @@ export interface MemoryCommitRequest {
botId?: string;
path: string;
content: string;
/** "replace" (default) stores `content` as the whole document; "append" adds it at the end. */
mode?: "replace" | "append";
sourceRunId?: string;
sourceThreadId?: string;
}
@ -435,7 +437,7 @@ export interface ScriptedTurn {
ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> };
takeover?: { reason: string };
files?: Array<{ path: string; content: string }>;
memory?: Array<{ scope: "bot" | "user"; path: string; content: string }>;
memory?: Array<Pick<MemoryCommitRequest, "scope" | "path" | "content" | "mode">>;
complete?: boolean;
}

View File

@ -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"],
},

View File

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

View File

@ -27,6 +27,14 @@ export function formatCurrentTimeInstruction(
].join(" ");
}
/** Today's date as YYYY-MM-DD in the user's time zone, or in UTC when none is known. */
export function formatLocalDate(
timeZone: string | null | undefined,
now: Date = new Date(),
): string {
return zonedClock(now, knownTimeZone(timeZone) ?? "UTC").local.slice(0, 10);
}
/** The user's time zone from their profile when this runtime knows it. */
export async function loadUserTimeZone(
prisma: Pick<PrismaClient, "userProfile">,

View File

@ -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([

View File

@ -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,
},

View File

@ -51,6 +51,36 @@ describe("agent memory context", () => {
expect(result?.endsWith("</durable_memory>")).toBe(true);
});
it("keeps the newest notes of a document that does not fit, from a line start", async () => {
const notes = Array.from(
{ length: 40 },
(_, index) => `- 2026-10-0${index % 9}: note ${index}`,
);
const read = vi.fn(async ({ scope }: { scope: "bot" | "user" }) =>
snapshot(
scope === "bot"
? [
document(
"bot",
"MEMORY.md",
`# Chief\n${notes.join("\n")}\n`,
41,
"2026-10-03T12:00:00.000Z",
),
]
: [],
),
);
const result = await loadAgentMemoryContext(storeWith(read), "bot-1", context, 600);
expect(Buffer.byteLength(result ?? "", "utf8")).toBeLessThanOrEqual(600);
expect(result).toContain("## bot: MEMORY.md (revision 41)\n[earlier notes omitted]\n- ");
expect(result).toContain("note 39\n\n</durable_memory>");
expect(result).not.toContain("# Chief");
expect(result).not.toContain("note 0\n");
});
it("omits the memory block when neither scope has documents", async () => {
const read = vi.fn(async () => snapshot([]));

View File

@ -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[] = [];

View File

@ -0,0 +1,96 @@
import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit";
import { MemoryDocumentTooLargeError } from "@rakazo/adapter-kit";
import { describe, expect, it, vi } from "vitest";
import { datedMemoryNote, rememberFromTool } from "./remember.js";
const context: AdapterContext = {
operationId: "run-1",
traceId: "run-1",
spaceId: "space-1",
userId: "user-1",
signal: new AbortController().signal,
};
function remember(args: Record<string, unknown>, commit = vi.fn(async () => ({}) as never)) {
return {
commit,
result: rememberFromTool({ commit }, context, {
botId: "bot-1",
runId: "run-1",
threadId: "thread-1",
date: "2026-10-03",
args,
}),
};
}
describe("datedMemoryNote", () => {
it("is one dated bullet whose later lines stay inside it", () => {
expect(datedMemoryNote(" Prefers tea ", "2026-10-03")).toBe("- 2026-10-03: Prefers tea");
expect(datedMemoryNote("Trip plan\nBerlin first\n\nthen Vienna", "2026-10-03")).toBe(
"- 2026-10-03: Trip plan\n Berlin first\n\n then Vienna",
);
});
});
describe("rememberFromTool", () => {
it("appends a dated note to MEMORY.md by default", async () => {
const { commit, result } = remember({ content: "Prefers tea" });
await expect(result).resolves.toEqual({ ok: true });
expect(commit).toHaveBeenCalledWith(
{
scope: "bot",
botId: "bot-1",
path: "MEMORY.md",
content: "- 2026-10-03: Prefers tea",
mode: "append",
sourceRunId: "run-1",
sourceThreadId: "thread-1",
} satisfies MemoryCommitRequest,
context,
);
});
it("rewrites the whole document only when asked to", async () => {
const { commit, result } = remember({
content: "# Chief\n\n- Prefers tea\n",
path: "notes.md",
mode: "replace",
});
await expect(result).resolves.toEqual({ ok: true });
expect(commit).toHaveBeenCalledWith(
expect.objectContaining({
path: "notes.md",
content: "# Chief\n\n- Prefers tea\n",
mode: "replace",
}),
context,
);
});
it("refuses an empty note without writing", async () => {
const { commit, result } = remember({ content: " " });
await expect(result).resolves.toEqual({ error: "content is empty" });
expect(commit).not.toHaveBeenCalled();
});
it("tells the bot to condense a full document", async () => {
const commit = vi.fn(async () => {
throw new MemoryDocumentTooLargeError("MEMORY.md");
});
const { result } = remember({ content: "One more fact" }, commit);
await expect(result).resolves.toEqual({
error:
'MEMORY.md is full (16 KB). Condense it with mode "replace", then save this note again.',
});
});
it("lets other storage failures fail the call", async () => {
const commit = vi.fn(async () => {
throw new Error("database unavailable");
});
await expect(remember({ content: "fact" }, commit).result).rejects.toThrow(
"database unavailable",
);
});
});

View File

@ -0,0 +1,55 @@
import type { AdapterContext, MemoryStore } from "@rakazo/adapter-kit";
import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit";
const DEFAULT_MEMORY_PATH = "MEMORY.md";
/** One dated Markdown bullet; later lines are indented so the note stays a single list item. */
export function datedMemoryNote(content: string, date: string): string {
const [first = "", ...rest] = content.trim().split(/\r?\n/);
return [`- ${date}: ${first}`, ...rest.map((line) => (line.trim() ? ` ${line}` : ""))].join(
"\n",
);
}
/**
* The builtin `remember` tool: appends a dated note to the bot's memory document, or rewrites the
* whole document with `mode: "replace"`. Retries of the same call are replayed from its effect
* record by the executor, so a note is appended once.
*/
export async function rememberFromTool(
memory: Pick<MemoryStore, "commit">,
context: AdapterContext,
input: {
botId: string;
runId: string;
threadId: string;
/** The user's local date, YYYY-MM-DD. */
date: string;
args: Record<string, unknown>;
},
): Promise<{ ok: true } | { error: string }> {
const replace = input.args.mode === "replace";
const content = String(input.args.content ?? "");
const path = String(input.args.path ?? "").trim() || DEFAULT_MEMORY_PATH;
if (!replace && !content.trim()) return { error: "content is empty" };
try {
await memory.commit(
{
scope: "bot",
botId: input.botId,
path,
content: replace ? content : datedMemoryNote(content, input.date),
mode: replace ? "replace" : "append",
sourceRunId: input.runId,
sourceThreadId: input.threadId,
},
context,
);
} catch (error) {
if (!(error instanceof MemoryDocumentTooLargeError)) throw error;
return {
error: `${path} is full (${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB). Condense it with mode "replace", then save this note again.`,
};
}
return { ok: true };
}

View File

@ -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,
},
];

View File

@ -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") }),

View File

@ -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),
},

View File

@ -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");

View File

@ -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<void> {
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;
});

View File

@ -1,4 +1,5 @@
import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit";
import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit";
import { createDb, type PrismaClient } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { MarkdownMemoryStore } from "./index.js";
@ -193,6 +194,84 @@ describePostgres("memory commits (PostgreSQL)", () => {
},
);
it("appends to the current content with a revision per note, creating a missing document", async () => {
const path = "appended.md";
const append = (content: string): MemoryCommitRequest => ({
...request(path, content),
mode: "append",
});
const first = await store.commit(append("- first"), context);
expect(first).toMatchObject({ revision: 1, content: "- first\n" });
await store.commit(append("- second"), context);
const current = await document(path);
expect(current).toMatchObject({ revision: 2, content: "- first\n- second\n" });
expect(current.revisions.map(({ revision, content }) => ({ revision, content }))).toEqual([
{ revision: 1, content: "- first\n" },
{ revision: 2, content: "- first\n- second\n" },
]);
// A replace still rewrites the whole document.
await store.commit(request(path, "# Condensed\n"), context);
await store.commit(append("- third"), context);
expect((await document(path)).content).toBe("# Condensed\n\n- third\n");
});
it("keeps both notes when two appends overlap", async () => {
const path = "concurrent-append.md";
await store.commit(request(path, "# Notes\n"), context);
let reads = 0;
let release!: () => void;
const bothRead = new Promise<void>((resolve) => {
release = resolve;
});
const concurrentStore = new MarkdownMemoryStore(
prisma.$extends({
query: {
memoryDocument: {
async findFirst({ args, query }) {
const result = await query(args);
reads += 1;
if (reads === 2) release();
if (reads <= 2) await bothRead;
return result;
},
},
},
}) as PrismaClient,
);
const results = await Promise.allSettled([
concurrentStore.commit({ ...request(path, "- a"), mode: "append" }, context),
concurrentStore.commit({ ...request(path, "- b"), mode: "append" }, context),
]);
expect(results.map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]);
const current = await document(path);
expect(current.revision).toBe(3);
expect(current.content).toMatch(/^# Notes\n\n- [ab]\n- [ab]\n$/);
expect(current.content).toContain("- a\n");
expect(current.content).toContain("- b\n");
});
it("refuses a document past the size limit without writing anything", async () => {
const path = "full.md";
const nearlyFull = `${"x".repeat(MEMORY_DOCUMENT_MAX_BYTES - 10)}\n`;
await store.commit(request(path, nearlyFull), context);
await expect(
store.commit({ ...request(path, "- one note too many"), mode: "append" }, context),
).rejects.toBeInstanceOf(MemoryDocumentTooLargeError);
await expect(
store.commit(request(path, "é".repeat(MEMORY_DOCUMENT_MAX_BYTES / 2 + 1)), context),
).rejects.toBeInstanceOf(MemoryDocumentTooLargeError);
const current = await document(path);
expect(current).toMatchObject({ revision: 1, content: nearlyFull });
expect(current.revisions).toHaveLength(1);
await store.commit({ ...request(path, "- ok"), mode: "append" }, context);
expect((await document(path)).revision).toBe(2);
});
it("bounds conflict retries, preserves only committed saves, and allows a later retry", async () => {
const path = "exhausted.md";
await store.commit(request(path, "initial"), context);

View File

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

View File

@ -10,6 +10,7 @@ import type {
MemoryStore,
PortableFile,
} from "@rakazo/adapter-kit";
import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit";
import { Prisma, type PrismaClient, withTransactionRetry } from "@rakazo/db";
export class MarkdownMemoryStore implements MemoryStore {
@ -69,8 +70,8 @@ export class MarkdownMemoryStore implements MemoryStore {
}
async commit(request: MemoryCommitRequest, context: AdapterContext): Promise<MemoryRevision> {
// A save replaces the current content and appends its history atomically.
// Conflicts retry the whole transaction so revision numbers use fresh state.
// A save replaces (or appends to) the current content and appends its history atomically.
// Conflicts retry the whole transaction so revision numbers and appends use fresh state.
return withTransactionRetry(() =>
this.prisma.$transaction(
async (tx) => {
@ -83,10 +84,17 @@ export class MarkdownMemoryStore implements MemoryStore {
path: request.path,
},
});
const content =
request.mode === "append"
? appendMarkdown(existing?.content ?? "", request.content)
: request.content;
if (Buffer.byteLength(content, "utf8") > MEMORY_DOCUMENT_MAX_BYTES) {
throw new MemoryDocumentTooLargeError(request.path);
}
const doc = existing
? await tx.memoryDocument.update({
where: { id: existing.id },
data: { content: request.content, revision: existing.revision + 1 },
data: { content, revision: existing.revision + 1 },
})
: await tx.memoryDocument.create({
data: {
@ -95,14 +103,14 @@ export class MarkdownMemoryStore implements MemoryStore {
botId: request.botId,
scope: request.scope,
path: request.path,
content: request.content,
content,
},
});
await tx.memoryRevision.create({
data: {
documentId: doc.id,
revision: doc.revision,
content: request.content,
content,
sourceRunId: request.sourceRunId,
sourceThreadId: request.sourceThreadId,
},
@ -147,6 +155,20 @@ export class MarkdownMemoryStore implements MemoryStore {
}
}
/**
* `addition` after the existing content, ending with a newline: right below a list, so notes stay
* one list, and after a blank line below anything else, such as a heading.
*/
export function appendMarkdown(existing: string, addition: string): string {
const base = existing.trimEnd();
const next = addition.trim();
if (!next) return existing;
if (!base) return `${next}\n`;
const lastLine = base.slice(base.lastIndexOf("\n") + 1);
const inList = /^\s*(?:[-*+]|\d+\.)\s|^\s{2,}\S/.test(lastLine);
return `${base}${inList ? "\n" : "\n\n"}${next}\n`;
}
function snippet(content: string, q: string): string {
const idx = content.toLowerCase().indexOf(q);
if (idx < 0) return content.slice(0, 140);

View File

@ -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 () => {

View File

@ -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<ThreadSnapshot> {
const start = Date.now();
let last: ThreadSnapshot | null = null;
while (Date.now() - start < 20_000) {
last = await rpc<ThreadSnapshot>(app, cookie, "threads/get", { botId });
last = await rpc<ThreadSnapshot>(app, cookie, "threads/get", { botId }, spaceId);
if (done(last)) return last;
await new Promise((resolve) => setTimeout(resolve, 100));
}

View File

@ -382,6 +382,10 @@ describeJourneys("required product journeys", () => {
botId: coder.id,
});
expect(coderMem.some((m) => m.content.toLowerCase().includes("rust"))).toBe(true);
// Remembering appends a dated note below the seeded heading instead of replacing it.
expect(coderMem.map((m) => m.content)).toContainEqual(
expect.stringMatching(/^# Coder\n\n- \d{4}-\d{2}-\d{2}: remember that coder prefers rust\n$/),
);
const dedicated = await rpc<Bot>(app, ada, "bots/setComputer", {
botId: coder.id,
mode: "dedicated",

View File

@ -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<UserProfileState>(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<UserProfileState>(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<UserProfileState>(app, intruder, "profile/get")).facts).toEqual([]);
expect(
(await rpc<UserProfileState>(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) {