From 4875753d9caede91936e26f8ec2852d057291335 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:10:25 +0000 Subject: [PATCH 01/38] feat(memory): remember appends dated notes instead of replacing memory The remember tool rewrote the bot's whole MEMORY.md with each call, so every new fact erased the earlier ones unless the model re-sent the full document. - MemoryCommitRequest gains mode "append" (the store joins the note onto the current content inside the same serializable transaction and still writes a revision row) and every commit is capped at 16 KB per document. - remember appends a dated bullet ("- YYYY-MM-DD: ...", the user's local date) by default and rewrites only with mode "replace"; a full document returns an error that tells the bot to condense it. Retries of the same tool call stay single appends through the existing effect record. - Scripted runtime memory entries can append too; the scripted "remember" script now appends instead of overwriting the bot's memory. - When memory does not fit the run's 32 KB budget, the prompt keeps a document's newest lines instead of its oldest. - memory.update maps an oversized save to BAD_REQUEST. - docs/PRODUCT.md records that UserFact is the cross-space user model and the space MEMORY.md stays space notes (no migration). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/router.ts | 38 +++++--- apps/web/e2e/knowledge-panel.spec.ts | 30 ++++++ docs/PRODUCT.md | 13 +++ packages/adapter-kit/src/index.ts | 1 + packages/adapter-kit/src/memory.ts | 13 +++ packages/adapter-kit/src/types.ts | 4 +- packages/adapters/src/builtin-tools.ts | 10 +- packages/adapters/src/current-time.test.ts | 11 ++- packages/adapters/src/current-time.ts | 8 ++ .../src/executor-effect-idempotency.test.ts | 8 ++ packages/adapters/src/executor.ts | 26 ++--- packages/adapters/src/memory-context.test.ts | 30 ++++++ packages/adapters/src/memory-context.ts | 37 ++++++- packages/adapters/src/remember.test.ts | 96 +++++++++++++++++++ packages/adapters/src/remember.ts | 55 +++++++++++ packages/adapters/src/scripted-runtime.ts | 2 +- packages/memory/src/commit.postgres.test.ts | 79 +++++++++++++++ packages/memory/src/index.test.ts | 17 +++- packages/memory/src/index.ts | 32 ++++++- packages/testkit/src/journeys.test.ts | 4 + 20 files changed, 472 insertions(+), 42 deletions(-) create mode 100644 packages/adapter-kit/src/memory.ts create mode 100644 packages/adapters/src/remember.test.ts create mode 100644 packages/adapters/src/remember.ts diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..11ec389 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -11,6 +11,7 @@ import type { } from "@rakazo/adapter-kit"; import { computerControlExpireJobKey, + MemoryDocumentTooLargeError, messagingDeliverJob, routineJobKey, routineWakeupJob, @@ -2427,21 +2428,28 @@ export function createRouter(deps: RouterDeps) { }, }); if (!doc) throw new IsolationError(); - const updated = await deps.memory.commit( - { - scope: doc.scope as "bot" | "user", - botId: doc.botId ?? undefined, - path: doc.path, - content: input.content, - }, - { - operationId: "mem", - traceId: "mem", - spaceId: context.actor.spaceId, - userId: context.actor.userId, - signal: new AbortController().signal, - }, - ); + const updated = await deps.memory + .commit( + { + scope: doc.scope as "bot" | "user", + botId: doc.botId ?? undefined, + path: doc.path, + content: input.content, + }, + { + operationId: "mem", + traceId: "mem", + spaceId: context.actor.spaceId, + userId: context.actor.userId, + signal: new AbortController().signal, + }, + ) + .catch((error: unknown) => { + if (error instanceof MemoryDocumentTooLargeError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + }); return { id: updated.id, scope: doc.scope as "bot" | "user", diff --git a/apps/web/e2e/knowledge-panel.spec.ts b/apps/web/e2e/knowledge-panel.spec.ts index a504c9d..3ab2d19 100644 --- a/apps/web/e2e/knowledge-panel.spec.ts +++ b/apps/web/e2e/knowledge-panel.spec.ts @@ -114,6 +114,36 @@ test("memory and skills are readable and editable in the app", async ({ page }, await expect(botDocEditor).toHaveValue(new RegExp(botMarker)); await botMemory.getByRole("button", { name: "Cancel", exact: true }).click(); + // Remembering appends a dated note and keeps the edit above. + await rpc(page, "threads/send", { + botId: activeBotId(page), + text: `remember that the tester ${stamp} prefers tea`, + }); + await expect + .poll( + async () => + ( + await rpc(page, "memory/list", { + botId: activeBotId(page), + scope: "bot", + }) + ).find((doc) => doc.path === "MEMORY.md")?.content, + { timeout: 60_000 }, + ) + .toMatch( + new RegExp( + `^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`, + ), + ); + await expect + .poll( + async () => + (await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ?? + null, + { timeout: 60_000 }, + ) + .toBeNull(); + // Skills: create one through the editor, reopen it, edit, then delete it. // Builtin catalog is currently empty; user skills still cover create/edit/delete. await knowledge.getByRole("tab", { name: "Skills", exact: true }).click(); diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 8c69d87..4577151 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -127,6 +127,19 @@ Called from `Onboarding.tsx` after first bot ensure. Seeded in user `MEMORY.md` (`user-relationship-contract.ts`). Persona copy and prompts follow. Details: `docs/OO-RELATIONSHIP.md`. +## Memory and the user model + +| Store | Holds | Written by | +|-------|-------|------------| +| `UserProfile` + `UserFact` (Settings → About you) | The user model: who the user is, across spaces; a fact can be limited to chosen spaces | `update_user_profile`, onboarding, chat consolidation, the user | +| User-scoped `MEMORY.md` (Settings → Memory) | Space notes shared by the bots of one space | The user, onboarding harvest | +| Bot-scoped `MEMORY.md` (bot → Knowledge) | One bot's notes in one space | `remember` appends dated notes; `mode: "replace"` rewrites | + +Space notes are not migrated into facts: facts are short, consented statements about the user, +space notes are free-form context for one space. A memory document holds at most 16 KB, so a +bot's notes and the space notes both reach the prompt; when it is full, `remember` asks the bot +to condense it. + ## Capability vision — ambient & video (future roadmap) **This PR does not build surveillance.** Current Gesamterfahrung stays **Stage + sound + calm onboarding** (`VOICE.md`). The following is Ingo's long-term direction — optional layers only. diff --git a/packages/adapter-kit/src/index.ts b/packages/adapter-kit/src/index.ts index 43f7898..ac00d1b 100644 --- a/packages/adapter-kit/src/index.ts +++ b/packages/adapter-kit/src/index.ts @@ -1,5 +1,6 @@ export * from "./background-jobs.js"; export * from "./cloud-agents.js"; export * from "./interfaces.js"; +export * from "./memory.js"; export * from "./registry.js"; export * from "./types.js"; diff --git a/packages/adapter-kit/src/memory.ts b/packages/adapter-kit/src/memory.ts new file mode 100644 index 0000000..9e1ccd3 --- /dev/null +++ b/packages/adapter-kit/src/memory.ts @@ -0,0 +1,13 @@ +/** + * Largest Markdown memory document a commit may store, in UTF-8 bytes: half of what a run reads, + * so a bot's document and the space's shared one both reach the prompt. + */ +export const MEMORY_DOCUMENT_MAX_BYTES = 16 * 1024; + +/** A commit would grow a memory document past MEMORY_DOCUMENT_MAX_BYTES. */ +export class MemoryDocumentTooLargeError extends Error { + constructor(readonly path: string) { + super(`${path} would exceed ${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB`); + this.name = "MemoryDocumentTooLargeError"; + } +} diff --git a/packages/adapter-kit/src/types.ts b/packages/adapter-kit/src/types.ts index d77cb5e..8ca259f 100644 --- a/packages/adapter-kit/src/types.ts +++ b/packages/adapter-kit/src/types.ts @@ -250,6 +250,8 @@ export interface MemoryCommitRequest { botId?: string; path: string; content: string; + /** "replace" (default) stores `content` as the whole document; "append" adds it at the end. */ + mode?: "replace" | "append"; sourceRunId?: string; sourceThreadId?: string; } @@ -435,7 +437,7 @@ export interface ScriptedTurn { ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> }; takeover?: { reason: string }; files?: Array<{ path: string; content: string }>; - memory?: Array<{ scope: "bot" | "user"; path: string; content: string }>; + memory?: Array>; complete?: boolean; } diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 0ff52fa..8502640 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -378,12 +378,18 @@ export const builtinAgentTools: ConnectorTool[] = [ }, { name: "remember", - description: "Store a durable fact in this bot's explicit memory.", + description: + "Add a durable note to this bot's memory document; it is appended with today's date. Use mode replace only to rewrite the whole document, e.g. to condense it when it is full.", inputSchema: { type: "object", properties: { content: { type: "string" }, - path: { type: "string" }, + path: { type: "string", description: "Memory document, default MEMORY.md." }, + mode: { + type: "string", + enum: ["append", "replace"], + description: "append (default) adds a note; replace sets the whole document to content.", + }, }, required: ["content"], }, diff --git a/packages/adapters/src/current-time.test.ts b/packages/adapters/src/current-time.test.ts index 55d2676..88aaee2 100644 --- a/packages/adapters/src/current-time.test.ts +++ b/packages/adapters/src/current-time.test.ts @@ -1,6 +1,6 @@ import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; -import { formatCurrentTimeInstruction, loadUserTimeZone } from "./current-time.js"; +import { formatCurrentTimeInstruction, formatLocalDate, loadUserTimeZone } from "./current-time.js"; describe("formatCurrentTimeInstruction", () => { it("anchors the present moment in UTC with the weekday and no sub-second noise", () => { @@ -93,3 +93,12 @@ describe("loadUserTimeZone", () => { ).resolves.toBeUndefined(); }); }); + +describe("formatLocalDate", () => { + it("is the user's local date, or the UTC date without a known zone", () => { + const now = new Date("2026-01-01T03:00:00Z"); + expect(formatLocalDate("America/Los_Angeles", now)).toBe("2025-12-31"); + expect(formatLocalDate("Not/AZone", now)).toBe("2026-01-01"); + expect(formatLocalDate(null, now)).toBe("2026-01-01"); + }); +}); diff --git a/packages/adapters/src/current-time.ts b/packages/adapters/src/current-time.ts index e1dd058..ad57e41 100644 --- a/packages/adapters/src/current-time.ts +++ b/packages/adapters/src/current-time.ts @@ -27,6 +27,14 @@ export function formatCurrentTimeInstruction( ].join(" "); } +/** Today's date as YYYY-MM-DD in the user's time zone, or in UTC when none is known. */ +export function formatLocalDate( + timeZone: string | null | undefined, + now: Date = new Date(), +): string { + return zonedClock(now, knownTimeZone(timeZone) ?? "UTC").local.slice(0, 10); +} + /** The user's time zone from their profile when this runtime knows it. */ export async function loadUserTimeZone( prisma: Pick, diff --git a/packages/adapters/src/executor-effect-idempotency.test.ts b/packages/adapters/src/executor-effect-idempotency.test.ts index 374494a..9901102 100644 --- a/packages/adapters/src/executor-effect-idempotency.test.ts +++ b/packages/adapters/src/executor-effect-idempotency.test.ts @@ -319,6 +319,14 @@ describe("mutating tool effect idempotency keys", () => { ]); await f.run(); expect(f.memoryCommit).toHaveBeenCalledOnce(); + // remember appends, so only the effect record keeps a retry from adding the note twice. + expect(f.memoryCommit).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "append", + content: expect.stringMatching(/^- \d{4}-\d{2}-\d{2}: durable fact$/), + }), + expect.anything(), + ); expect(f.effects).toHaveLength(1); f.setCalls([ diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 5cb9181..7181880 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -207,7 +207,7 @@ import { observationToolResult, parseComputerActions } from "./computer-tools.js import { checkpointRunComputerWorkspace } from "./computer-workspace.js"; import { sanitizeConnectorError } from "./connector-safety.js"; import { createProject } from "./create-project.js"; -import { formatCurrentTimeInstruction } from "./current-time.js"; +import { formatCurrentTimeInstruction, formatLocalDate } from "./current-time.js"; import { delegateTask, loadDelegatedTaskInstruction, @@ -319,6 +319,7 @@ import { tryRecoverProseHandoff, workshopFallbackAfterBlockedHandoff, } from "./prose-handoff-recovery.js"; +import { datedMemoryNote, rememberFromTool } from "./remember.js"; import type { RemoteTransportDependencies } from "./remote-mcp.js"; import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js"; import { @@ -2546,18 +2547,15 @@ export function createRunExecutor(deps: ExecutorDeps) { }, finish); } if (name === "remember") { - await deps.memory.commit( - { - scope: "bot", + return finish( + await rememberFromTool(deps.memory, context, { botId: bot.id, - path: String(args.path ?? "MEMORY.md"), - content: String(args.content ?? ""), - sourceRunId: runId, - sourceThreadId: thread.id, - }, - context, + runId, + threadId: thread.id, + date: formatLocalDate(userContext.timeZone), + args, + }), ); - return finish({ ok: true }); } if (name === "update_user_profile") { return finish( @@ -4240,7 +4238,11 @@ export function createRunExecutor(deps: ExecutorDeps) { scope: mem.scope, botId: mem.scope === "bot" ? bot.id : undefined, path: mem.path, - content: mem.content, + content: + mem.mode === "append" + ? datedMemoryNote(mem.content, formatLocalDate(userContext.timeZone)) + : mem.content, + mode: mem.mode, sourceRunId: runId, sourceThreadId: thread.id, }, diff --git a/packages/adapters/src/memory-context.test.ts b/packages/adapters/src/memory-context.test.ts index 3df4d13..93bcd21 100644 --- a/packages/adapters/src/memory-context.test.ts +++ b/packages/adapters/src/memory-context.test.ts @@ -51,6 +51,36 @@ describe("agent memory context", () => { expect(result?.endsWith("")).toBe(true); }); + it("keeps the newest notes of a document that does not fit, from a line start", async () => { + const notes = Array.from( + { length: 40 }, + (_, index) => `- 2026-10-0${index % 9}: note ${index}`, + ); + const read = vi.fn(async ({ scope }: { scope: "bot" | "user" }) => + snapshot( + scope === "bot" + ? [ + document( + "bot", + "MEMORY.md", + `# Chief\n${notes.join("\n")}\n`, + 41, + "2026-10-03T12:00:00.000Z", + ), + ] + : [], + ), + ); + + const result = await loadAgentMemoryContext(storeWith(read), "bot-1", context, 600); + + expect(Buffer.byteLength(result ?? "", "utf8")).toBeLessThanOrEqual(600); + expect(result).toContain("## bot: MEMORY.md (revision 41)\n[earlier notes omitted]\n- "); + expect(result).toContain("note 39\n\n"); + expect(result).not.toContain("# Chief"); + expect(result).not.toContain("note 0\n"); + }); + it("omits the memory block when neither scope has documents", async () => { const read = vi.fn(async () => snapshot([])); diff --git a/packages/adapters/src/memory-context.ts b/packages/adapters/src/memory-context.ts index 1912225..e53d266 100644 --- a/packages/adapters/src/memory-context.ts +++ b/packages/adapters/src/memory-context.ts @@ -1,6 +1,7 @@ import type { AdapterContext, MemorySnapshot, MemoryStore } from "@rakazo/adapter-kit"; const MAX_AGENT_MEMORY_BYTES = 32 * 1024; +const OMITTED_MARKER = "[earlier notes omitted]\n"; type ScopedMemoryDocument = MemorySnapshot["documents"][number] & { scope: "bot" | "user"; @@ -45,10 +46,14 @@ export async function loadAgentMemoryContext( sections.push(heading); remainingBytes -= headingBytes; - const content = truncateUtf8(document.content, remainingBytes); - sections.push(content); - remainingBytes -= byteLength(content); - if (content !== document.content) break; + if (byteLength(document.content) <= remainingBytes) { + sections.push(document.content); + remainingBytes -= byteLength(document.content); + continue; + } + // Notes are appended, so the end of a document is its newest part. + sections.push(newestLines(document.content, remainingBytes)); + break; } return `${preamble}${sections.join("")}${closing}`; @@ -58,6 +63,30 @@ function byteLength(value: string): number { return Buffer.byteLength(value, "utf8"); } +/** The end of `content` within `maxBytes`, from a line start, after an omission marker. */ +function newestLines(content: string, maxBytes: number): string { + const markerBytes = byteLength(OMITTED_MARKER); + if (maxBytes <= markerBytes) return ""; + const tail = tailUtf8(content, maxBytes - markerBytes); + const startsLine = content.charAt(content.length - tail.length - 1) === "\n"; + const newline = tail.indexOf("\n"); + const partialFirstLine = !startsLine && newline >= 0 && newline < tail.length - 1; + return `${OMITTED_MARKER}${partialFirstLine ? tail.slice(newline + 1) : tail}`; +} + +function tailUtf8(value: string, maxBytes: number): string { + const characters = Array.from(value); + let bytes = 0; + let start = characters.length; + while (start > 0) { + const characterBytes = byteLength(characters[start - 1]!); + if (bytes + characterBytes > maxBytes) break; + bytes += characterBytes; + start -= 1; + } + return characters.slice(start).join(""); +} + function truncateUtf8(value: string, maxBytes: number): string { if (maxBytes <= 0) return ""; const characters: string[] = []; diff --git a/packages/adapters/src/remember.test.ts b/packages/adapters/src/remember.test.ts new file mode 100644 index 0000000..05a86d0 --- /dev/null +++ b/packages/adapters/src/remember.test.ts @@ -0,0 +1,96 @@ +import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit"; +import { MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; +import { describe, expect, it, vi } from "vitest"; +import { datedMemoryNote, rememberFromTool } from "./remember.js"; + +const context: AdapterContext = { + operationId: "run-1", + traceId: "run-1", + spaceId: "space-1", + userId: "user-1", + signal: new AbortController().signal, +}; + +function remember(args: Record, commit = vi.fn(async () => ({}) as never)) { + return { + commit, + result: rememberFromTool({ commit }, context, { + botId: "bot-1", + runId: "run-1", + threadId: "thread-1", + date: "2026-10-03", + args, + }), + }; +} + +describe("datedMemoryNote", () => { + it("is one dated bullet whose later lines stay inside it", () => { + expect(datedMemoryNote(" Prefers tea ", "2026-10-03")).toBe("- 2026-10-03: Prefers tea"); + expect(datedMemoryNote("Trip plan\nBerlin first\n\nthen Vienna", "2026-10-03")).toBe( + "- 2026-10-03: Trip plan\n Berlin first\n\n then Vienna", + ); + }); +}); + +describe("rememberFromTool", () => { + it("appends a dated note to MEMORY.md by default", async () => { + const { commit, result } = remember({ content: "Prefers tea" }); + await expect(result).resolves.toEqual({ ok: true }); + expect(commit).toHaveBeenCalledWith( + { + scope: "bot", + botId: "bot-1", + path: "MEMORY.md", + content: "- 2026-10-03: Prefers tea", + mode: "append", + sourceRunId: "run-1", + sourceThreadId: "thread-1", + } satisfies MemoryCommitRequest, + context, + ); + }); + + it("rewrites the whole document only when asked to", async () => { + const { commit, result } = remember({ + content: "# Chief\n\n- Prefers tea\n", + path: "notes.md", + mode: "replace", + }); + await expect(result).resolves.toEqual({ ok: true }); + expect(commit).toHaveBeenCalledWith( + expect.objectContaining({ + path: "notes.md", + content: "# Chief\n\n- Prefers tea\n", + mode: "replace", + }), + context, + ); + }); + + it("refuses an empty note without writing", async () => { + const { commit, result } = remember({ content: " " }); + await expect(result).resolves.toEqual({ error: "content is empty" }); + expect(commit).not.toHaveBeenCalled(); + }); + + it("tells the bot to condense a full document", async () => { + const commit = vi.fn(async () => { + throw new MemoryDocumentTooLargeError("MEMORY.md"); + }); + const { result } = remember({ content: "One more fact" }, commit); + await expect(result).resolves.toEqual({ + error: + 'MEMORY.md is full (16 KB). Condense it with mode "replace", then save this note again.', + }); + }); + + it("lets other storage failures fail the call", async () => { + const commit = vi.fn(async () => { + throw new Error("database unavailable"); + }); + await expect(remember({ content: "fact" }, commit).result).rejects.toThrow( + "database unavailable", + ); + }); +}); diff --git a/packages/adapters/src/remember.ts b/packages/adapters/src/remember.ts new file mode 100644 index 0000000..7f411ac --- /dev/null +++ b/packages/adapters/src/remember.ts @@ -0,0 +1,55 @@ +import type { AdapterContext, MemoryStore } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; + +const DEFAULT_MEMORY_PATH = "MEMORY.md"; + +/** One dated Markdown bullet; later lines are indented so the note stays a single list item. */ +export function datedMemoryNote(content: string, date: string): string { + const [first = "", ...rest] = content.trim().split(/\r?\n/); + return [`- ${date}: ${first}`, ...rest.map((line) => (line.trim() ? ` ${line}` : ""))].join( + "\n", + ); +} + +/** + * The builtin `remember` tool: appends a dated note to the bot's memory document, or rewrites the + * whole document with `mode: "replace"`. Retries of the same call are replayed from its effect + * record by the executor, so a note is appended once. + */ +export async function rememberFromTool( + memory: Pick, + context: AdapterContext, + input: { + botId: string; + runId: string; + threadId: string; + /** The user's local date, YYYY-MM-DD. */ + date: string; + args: Record; + }, +): Promise<{ ok: true } | { error: string }> { + const replace = input.args.mode === "replace"; + const content = String(input.args.content ?? ""); + const path = String(input.args.path ?? "").trim() || DEFAULT_MEMORY_PATH; + if (!replace && !content.trim()) return { error: "content is empty" }; + try { + await memory.commit( + { + scope: "bot", + botId: input.botId, + path, + content: replace ? content : datedMemoryNote(content, input.date), + mode: replace ? "replace" : "append", + sourceRunId: input.runId, + sourceThreadId: input.threadId, + }, + context, + ); + } catch (error) { + if (!(error instanceof MemoryDocumentTooLargeError)) throw error; + return { + error: `${path} is full (${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB). Condense it with mode "replace", then save this note again.`, + }; + } + return { ok: true }; +} diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index 6a19544..b75121f 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -481,7 +481,7 @@ export function inferScript( return [ { assistant: "noted — i will keep that in memory.", - memory: [{ scope: "bot", path: "MEMORY.md", content: `# Memory\n\n- ${prompt}\n` }], + memory: [{ scope: "bot", path: "MEMORY.md", content: prompt, mode: "append" }], complete: true, }, ]; diff --git a/packages/memory/src/commit.postgres.test.ts b/packages/memory/src/commit.postgres.test.ts index 9fdecfc..c6f07d1 100644 --- a/packages/memory/src/commit.postgres.test.ts +++ b/packages/memory/src/commit.postgres.test.ts @@ -1,4 +1,5 @@ import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; import { createDb, type PrismaClient } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { MarkdownMemoryStore } from "./index.js"; @@ -193,6 +194,84 @@ describePostgres("memory commits (PostgreSQL)", () => { }, ); + it("appends to the current content with a revision per note, creating a missing document", async () => { + const path = "appended.md"; + const append = (content: string): MemoryCommitRequest => ({ + ...request(path, content), + mode: "append", + }); + const first = await store.commit(append("- first"), context); + expect(first).toMatchObject({ revision: 1, content: "- first\n" }); + await store.commit(append("- second"), context); + const current = await document(path); + expect(current).toMatchObject({ revision: 2, content: "- first\n- second\n" }); + expect(current.revisions.map(({ revision, content }) => ({ revision, content }))).toEqual([ + { revision: 1, content: "- first\n" }, + { revision: 2, content: "- first\n- second\n" }, + ]); + + // A replace still rewrites the whole document. + await store.commit(request(path, "# Condensed\n"), context); + await store.commit(append("- third"), context); + expect((await document(path)).content).toBe("# Condensed\n\n- third\n"); + }); + + it("keeps both notes when two appends overlap", async () => { + const path = "concurrent-append.md"; + await store.commit(request(path, "# Notes\n"), context); + let reads = 0; + let release!: () => void; + const bothRead = new Promise((resolve) => { + release = resolve; + }); + const concurrentStore = new MarkdownMemoryStore( + prisma.$extends({ + query: { + memoryDocument: { + async findFirst({ args, query }) { + const result = await query(args); + reads += 1; + if (reads === 2) release(); + if (reads <= 2) await bothRead; + return result; + }, + }, + }, + }) as PrismaClient, + ); + + const results = await Promise.allSettled([ + concurrentStore.commit({ ...request(path, "- a"), mode: "append" }, context), + concurrentStore.commit({ ...request(path, "- b"), mode: "append" }, context), + ]); + + expect(results.map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]); + const current = await document(path); + expect(current.revision).toBe(3); + expect(current.content).toMatch(/^# Notes\n\n- [ab]\n- [ab]\n$/); + expect(current.content).toContain("- a\n"); + expect(current.content).toContain("- b\n"); + }); + + it("refuses a document past the size limit without writing anything", async () => { + const path = "full.md"; + const nearlyFull = `${"x".repeat(MEMORY_DOCUMENT_MAX_BYTES - 10)}\n`; + await store.commit(request(path, nearlyFull), context); + + await expect( + store.commit({ ...request(path, "- one note too many"), mode: "append" }, context), + ).rejects.toBeInstanceOf(MemoryDocumentTooLargeError); + await expect( + store.commit(request(path, "é".repeat(MEMORY_DOCUMENT_MAX_BYTES / 2 + 1)), context), + ).rejects.toBeInstanceOf(MemoryDocumentTooLargeError); + const current = await document(path); + expect(current).toMatchObject({ revision: 1, content: nearlyFull }); + expect(current.revisions).toHaveLength(1); + + await store.commit({ ...request(path, "- ok"), mode: "append" }, context); + expect((await document(path)).revision).toBe(2); + }); + it("bounds conflict retries, preserves only committed saves, and allows a later retry", async () => { const path = "exhausted.md"; await store.commit(request(path, "initial"), context); diff --git a/packages/memory/src/index.test.ts b/packages/memory/src/index.test.ts index 92f77fe..7cc98fc 100644 --- a/packages/memory/src/index.test.ts +++ b/packages/memory/src/index.test.ts @@ -1,6 +1,6 @@ import type { AdapterContext } from "@rakazo/adapter-kit"; import { describe, expect, it, vi } from "vitest"; -import { MarkdownMemoryStore } from "./index.js"; +import { appendMarkdown, MarkdownMemoryStore } from "./index.js"; const context: AdapterContext = { operationId: "read-memory", @@ -47,3 +47,18 @@ describe("memory store contract shape", () => { }); }); }); + +describe("appendMarkdown", () => { + it("adds the note below a heading or paragraph, and right below a list", () => { + expect(appendMarkdown("# Chief\n\n", "- 2026-10-03: Prefers tea")).toBe( + "# Chief\n\n- 2026-10-03: Prefers tea\n", + ); + expect(appendMarkdown("- a\n- b", "- c")).toBe("- a\n- b\n- c\n"); + expect(appendMarkdown("- a\n more about a\n", "- c")).toBe("- a\n more about a\n- c\n"); + expect(appendMarkdown("", "- first")).toBe("- first\n"); + }); + + it("leaves the document unchanged for an empty note", () => { + expect(appendMarkdown("# Chief\n", " ")).toBe("# Chief\n"); + }); +}); diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 937c00e..14931c1 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -10,6 +10,7 @@ import type { MemoryStore, PortableFile, } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; import { Prisma, type PrismaClient, withTransactionRetry } from "@rakazo/db"; export class MarkdownMemoryStore implements MemoryStore { @@ -69,8 +70,8 @@ export class MarkdownMemoryStore implements MemoryStore { } async commit(request: MemoryCommitRequest, context: AdapterContext): Promise { - // A save replaces the current content and appends its history atomically. - // Conflicts retry the whole transaction so revision numbers use fresh state. + // A save replaces (or appends to) the current content and appends its history atomically. + // Conflicts retry the whole transaction so revision numbers and appends use fresh state. return withTransactionRetry(() => this.prisma.$transaction( async (tx) => { @@ -83,10 +84,17 @@ export class MarkdownMemoryStore implements MemoryStore { path: request.path, }, }); + const content = + request.mode === "append" + ? appendMarkdown(existing?.content ?? "", request.content) + : request.content; + if (Buffer.byteLength(content, "utf8") > MEMORY_DOCUMENT_MAX_BYTES) { + throw new MemoryDocumentTooLargeError(request.path); + } const doc = existing ? await tx.memoryDocument.update({ where: { id: existing.id }, - data: { content: request.content, revision: existing.revision + 1 }, + data: { content, revision: existing.revision + 1 }, }) : await tx.memoryDocument.create({ data: { @@ -95,14 +103,14 @@ export class MarkdownMemoryStore implements MemoryStore { botId: request.botId, scope: request.scope, path: request.path, - content: request.content, + content, }, }); await tx.memoryRevision.create({ data: { documentId: doc.id, revision: doc.revision, - content: request.content, + content, sourceRunId: request.sourceRunId, sourceThreadId: request.sourceThreadId, }, @@ -147,6 +155,20 @@ export class MarkdownMemoryStore implements MemoryStore { } } +/** + * `addition` after the existing content, ending with a newline: right below a list, so notes stay + * one list, and after a blank line below anything else, such as a heading. + */ +export function appendMarkdown(existing: string, addition: string): string { + const base = existing.trimEnd(); + const next = addition.trim(); + if (!next) return existing; + if (!base) return `${next}\n`; + const lastLine = base.slice(base.lastIndexOf("\n") + 1); + const inList = /^\s*(?:[-*+]|\d+\.)\s|^\s{2,}\S/.test(lastLine); + return `${base}${inList ? "\n" : "\n\n"}${next}\n`; +} + function snippet(content: string, q: string): string { const idx = content.toLowerCase().indexOf(q); if (idx < 0) return content.slice(0, 140); diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index f5ae027..f236434 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -382,6 +382,10 @@ describeJourneys("required product journeys", () => { botId: coder.id, }); expect(coderMem.some((m) => m.content.toLowerCase().includes("rust"))).toBe(true); + // Remembering appends a dated note below the seeded heading instead of replacing it. + expect(coderMem.map((m) => m.content)).toContainEqual( + expect.stringMatching(/^# Coder\n\n- \d{4}-\d{2}-\d{2}: remember that coder prefers rust\n$/), + ); const dedicated = await rpc(app, ada, "bots/setComputer", { botId: coder.id, mode: "dedicated", From 7d7af8f3e946d9e9cd2552a8f943a4b73251a509 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:12:08 +0000 Subject: [PATCH 02/38] feat(coach): goals, habits, check-ins, daily briefs and check-in rhythm The main agent should coach the user, but nothing stored what the user wants to achieve or keep doing, and no routine ever checked in. This adds the data model and the interfaces the onboarding intake and the coach tools build on. - Schema: UserGoal, Habit, CheckIn (one per habit, goal or day and date) and DailyBrief, all keyed to the user's local calendar date, plus a coachKey on Routine that marks the main bot's coach check-ins. - Contracts (coach.ts): CoachRhythm, Goal, Habit, CheckIn, DailyBrief and CoachToday DTOs and the RPC inputs. - Repository (@rakazo/db coach.ts): goal, habit and check-in CRUD with per-user limits, upsertDailyBrief and loadCoachToday with streaks in the user's time zone. - coach-rhythm.ts: setCoachRhythm/getCoachRhythm keep up to three main bot routines (morning brief, evening check-in, weekly review) in the main conversation in step with the user's chosen times. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/coach-rhythm.ts | 145 ++++ packages/adapters/src/index.ts | 1 + packages/contracts/src/coach.ts | 212 ++++++ packages/contracts/src/index.ts | 1 + packages/core/src/coach.ts | 99 +++ packages/core/src/index.ts | 1 + .../migration.sql | 144 ++++ .../migration.sql | 3 + packages/db/prisma/schema.prisma | 103 +++ packages/db/src/coach.ts | 634 ++++++++++++++++++ packages/db/src/index.ts | 1 + 11 files changed, 1344 insertions(+) create mode 100644 packages/adapters/src/coach-rhythm.ts create mode 100644 packages/contracts/src/coach.ts create mode 100644 packages/core/src/coach.ts create mode 100644 packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql create mode 100644 packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql create mode 100644 packages/db/src/coach.ts diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts new file mode 100644 index 0000000..f9ee580 --- /dev/null +++ b/packages/adapters/src/coach-rhythm.ts @@ -0,0 +1,145 @@ +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; +import type { CoachRhythm } from "@rakazo/contracts"; +import { COACH_RHYTHM_OFF } from "@rakazo/contracts"; +import { nextCronDate } from "@rakazo/core"; +import type { PrismaClient } from "@rakazo/db"; +import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db"; + +export const COACH_KEYS = ["morning", "evening", "weekly"] as const; +export type CoachKey = (typeof COACH_KEYS)[number]; + +/** + * The main bot's check-ins. Names show in the main bot's routine list; prompts are its task when + * a check-in fires. Product text in English: the main bot answers in the user's language, and + * its instructions name the coach tools it has. + */ +export const COACH_ROUTINES: Record = { + morning: { + name: "Morning brief", + prompt: + "Morning check-in. Review the user's active goals and habits, and today's calendar if a calendar app is connected. Write today's brief: two to five short lines on what matters most today, and save it. Then post one short, friendly message with the gist. Leave out what is not relevant today.", + }, + evening: { + name: "Evening check-in", + prompt: + "Evening check-in. Log as check-ins what the user already told you about today. Then ask in one or two sentences how the day went and about habits not yet checked off. Keep it light, and do not ask when everything is already logged.", + }, + weekly: { + name: "Weekly review", + prompt: + "Weekly review. Look at the past week's check-ins and the progress on the user's active goals. Post a short summary: what moved, what stalled, and one concrete suggestion for next week. Offer to adjust, complete or drop goals, and change them only when the user agrees.", + }, +}; + +export interface CoachRhythmDeps { + prisma: PrismaClient; + jobs: JobPublisher; +} + +/** The cron for one check-in, or null when it is off. */ +export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null { + const slot = key === "weekly" ? rhythm.weekly : rhythm[key]; + if (!slot) return null; + const time = typeof slot === "string" ? slot : slot.time; + const [hour, minute] = time.split(":").map(Number); + return `${minute} ${hour} * * ${typeof slot === "string" ? "*" : slot.day}`; +} + +const pad = (value: string) => value.padStart(2, "0"); + +/** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */ +function rhythmSlot(key: CoachKey, crons: readonly string[]) { + const match = + crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null; + if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return null; + const time = `${pad(match[2]!)}:${pad(match[1]!)}`; + if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time }; + return match[3] === "*" ? time : null; +} + +function isCoachKey(value: string | null): value is CoachKey { + return (COACH_KEYS as readonly string[]).includes(value ?? ""); +} + +/** The user's check-in times, read from the main bot's active coach routines. */ +export async function getCoachRhythm(prisma: PrismaClient, userId: string): Promise { + const botId = await findMainBotId(prisma, userId); + if (!botId) return { ...COACH_RHYTHM_OFF }; + const routines = await prisma.routine.findMany({ + where: { botId, userId, active: true, coachKey: { not: null } }, + select: { coachKey: true, crons: true }, + }); + const rhythm: CoachRhythm = { ...COACH_RHYTHM_OFF }; + for (const routine of routines) { + if (!isCoachKey(routine.coachKey)) continue; + const slot = rhythmSlot(routine.coachKey, routine.crons); + if (routine.coachKey === "weekly") rhythm.weekly = slot as CoachRhythm["weekly"]; + else rhythm[routine.coachKey] = slot as string | null; + } + return rhythm; +} + +/** + * Makes the main bot's coach routines match `rhythm`: one routine per check-in that is on, + * waking in the main conversation in the user's time zone. Check-ins that are off are deleted. + * Calling it again with the same rhythm changes nothing. + */ +export async function setCoachRhythm( + deps: CoachRhythmDeps, + input: { userId: string; rhythm: CoachRhythm }, +): Promise { + const { prisma, jobs } = deps; + const { userId, rhythm } = input; + const anyOn = COACH_KEYS.some((key) => coachCron(key, rhythm) !== null); + const conversation = anyOn ? await ensureMainConversation(prisma, userId) : null; + const botId = conversation?.leadBotId ?? (await findMainBotId(prisma, userId)); + if (!botId) return { ...COACH_RHYTHM_OFF }; + const existing = await prisma.routine.findMany({ + where: { botId, userId, coachKey: { not: null } }, + }); + const timezone = anyOn ? await coachTimeZone(prisma, userId) : "UTC"; + for (const key of COACH_KEYS) { + const cron = coachCron(key, rhythm); + const current = existing.find((routine) => routine.coachKey === key); + if (!cron || !conversation) { + if (current) { + await prisma.routine.deleteMany({ where: { id: current.id } }); + await jobs.cancel(routineJobKey(current.id)); + } + continue; + } + const { name, prompt } = COACH_ROUTINES[key]; + const unchanged = + current?.active && + current.nextRunAt && + current.crons.length === 1 && + current.crons[0] === cron && + current.timezone === timezone && + current.threadId === conversation.threadId && + current.spaceId === conversation.spaceId && + current.name === name && + current.prompt === prompt; + if (unchanged) continue; + const nextRunAt = nextCronDate(cron, new Date(), timezone); + const fields = { + spaceId: conversation.spaceId, + threadId: conversation.threadId, + name, + prompt, + crons: [cron], + timezone, + active: true, + notify: true, + nextRunAt, + }; + const saved = await prisma.routine.upsert({ + where: { botId_coachKey: { botId, coachKey: key } }, + create: { ...fields, botId, userId, coachKey: key }, + update: fields, + }); + // The job reconciler re-arms an active routine whose wakeup could not be enqueued. + await jobs.enqueue(routineWakeupJob(saved.id, nextRunAt)); + } + return getCoachRhythm(prisma, userId); +} diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 7b4ecd0..effc7bf 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -23,6 +23,7 @@ export * from "./cloud-agent-provider-env.js"; export * from "./cloud-agent-service.js"; export * from "./cloud-agent-tools.js"; export * from "./cloud-agent-tools-select.js"; +export * from "./coach-rhythm.js"; export * from "./composio-catalog-cache.js"; export * from "./composio-connector.js"; export * from "./composio-emulator.js"; diff --git a/packages/contracts/src/coach.ts b/packages/contracts/src/coach.ts new file mode 100644 index 0000000..011944e --- /dev/null +++ b/packages/contracts/src/coach.ts @@ -0,0 +1,212 @@ +import * as z from "zod"; +import { Id } from "./ids.js"; + +export const COACH_TITLE_MAX_LENGTH = 200; +export const GOAL_DETAIL_MAX_LENGTH = 2_000; +export const CHECK_IN_NOTE_MAX_LENGTH = 1_000; +export const DAILY_BRIEF_MAX_LENGTH = 4_000; +/** Upper bounds per user, so a runaway writer cannot grow the coach data without end. */ +export const COACH_ACTIVE_GOAL_LIMIT = 100; +export const COACH_ACTIVE_HABIT_LIMIT = 100; + +export const GOAL_STATUSES = ["active", "done", "dropped"] as const; +export const GoalStatusSchema = z.enum(GOAL_STATUSES); +export type GoalStatus = z.infer; + +/** Who started tracking a goal or habit: the user, onboarding, or the main bot in a chat. */ +export const COACH_SOURCES = ["user", "onboarding", "chat"] as const; +export const CoachSourceSchema = z.enum(COACH_SOURCES); +export type CoachSource = z.infer; + +export const HABIT_CADENCES = ["daily", "weekdays", "weekly"] as const; +export const HabitCadenceSchema = z.enum(HABIT_CADENCES); +export type HabitCadence = z.infer; + +export const CHECK_IN_KINDS = ["habit", "goal", "daily"] as const; +export const CheckInKindSchema = z.enum(CHECK_IN_KINDS); +export type CheckInKind = z.infer; + +function isCalendarDate(value: string): boolean { + const date = new Date(`${value}T00:00:00Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +/** A calendar date in the user's time zone, e.g. "2026-10-03". */ +export const LocalDateSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD") + .refine(isCalendarDate, "Invalid date"); + +/** A 24-hour wall-clock time in the user's time zone, e.g. "07:30". */ +export const ClockTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM"); + +/** When the main bot checks in with the user; null turns a check-in off. */ +export const CoachRhythmSchema = z.object({ + morning: ClockTimeSchema.nullable(), + evening: ClockTimeSchema.nullable(), + /** day: 0 = Sunday … 6 = Saturday. */ + weekly: z.object({ day: z.number().int().min(0).max(6), time: ClockTimeSchema }).nullable(), +}); +export type CoachRhythm = z.infer; + +export const COACH_RHYTHM_OFF: CoachRhythm = { morning: null, evening: null, weekly: null }; + +export const GoalSchema = z.object({ + id: Id, + title: z.string(), + detail: z.string(), + status: GoalStatusSchema, + targetDate: LocalDateSchema.nullable(), + spaceId: Id.nullable(), + source: CoachSourceSchema, + createdByBotId: Id.nullable(), + completedAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type Goal = z.infer; + +export const HabitSchema = z.object({ + id: Id, + title: z.string(), + cadence: HabitCadenceSchema, + goalId: Id.nullable(), + active: z.boolean(), + source: CoachSourceSchema, + createdByBotId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type Habit = z.infer; + +const Rating = z.number().int().min(1).max(5); + +export const CheckInSchema = z.object({ + id: Id, + kind: CheckInKindSchema, + habitId: Id.nullable(), + goalId: Id.nullable(), + date: LocalDateSchema, + done: z.boolean().nullable(), + mood: Rating.nullable(), + energy: Rating.nullable(), + note: z.string(), + createdByBotId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type CheckIn = z.infer; + +export const DailyBriefSchema = z.object({ + id: Id, + date: LocalDateSchema, + text: z.string(), + botId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type DailyBrief = z.infer; + +export const HabitTodaySchema = HabitSchema.extend({ + /** Done in the current period: today, or this week for a weekly habit. */ + doneToday: z.boolean(), + /** Periods in a row the habit was done, counting the current one only once it is done. */ + streak: z.number().int().min(0), +}); +export type HabitToday = z.infer; + +/** The user's day as the coach sees it, in the user's time zone. */ +export const CoachTodaySchema = z.object({ + date: LocalDateSchema, + timeZone: z.string(), + brief: DailyBriefSchema.nullable(), + goals: z.array(GoalSchema), + habits: z.array(HabitTodaySchema), + /** Today's daily check-in (how the day went), if any. */ + checkIn: CheckInSchema.nullable(), +}); +export type CoachToday = z.infer; + +const Title = z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH); +const Detail = z.string().trim().max(GOAL_DETAIL_MAX_LENGTH); + +export const CreateGoalInput = z.object({ + title: Title, + detail: Detail.optional(), + targetDate: LocalDateSchema.nullable().optional(), + /** A space the goal belongs to; null or omitted for a personal goal. */ + spaceId: Id.nullable().optional(), +}); + +export const UpdateGoalInput = z.object({ + goalId: Id, + title: Title.optional(), + detail: Detail.optional(), + status: GoalStatusSchema.optional(), + targetDate: LocalDateSchema.nullable().optional(), + spaceId: Id.nullable().optional(), +}); + +export const ListGoalsInput = z.object({ status: GoalStatusSchema.optional() }); + +export const CreateHabitInput = z.object({ + title: Title, + cadence: HabitCadenceSchema.default("daily"), + goalId: Id.nullable().optional(), +}); + +export const UpdateHabitInput = z.object({ + habitId: Id, + title: Title.optional(), + cadence: HabitCadenceSchema.optional(), + goalId: Id.nullable().optional(), + active: z.boolean().optional(), +}); + +export const ListHabitsInput = z.object({ includeInactive: z.boolean().optional() }); + +/** + * Logs or updates the check-in for one day: a habit or goal check-in needs its id, the daily + * one needs neither. Fields left out keep their stored value. + */ +export const LogCheckInInput = z + .object({ + kind: CheckInKindSchema, + habitId: Id.optional(), + goalId: Id.optional(), + /** Defaults to today in the user's time zone. */ + date: LocalDateSchema.optional(), + done: z.boolean().nullable().optional(), + mood: Rating.nullable().optional(), + energy: Rating.nullable().optional(), + note: z.string().trim().max(CHECK_IN_NOTE_MAX_LENGTH).optional(), + }) + .superRefine((input, ctx) => { + const wantsHabit = input.kind === "habit"; + const wantsGoal = input.kind === "goal"; + if (wantsHabit !== Boolean(input.habitId)) { + ctx.addIssue({ + code: "custom", + message: wantsHabit + ? "A habit check-in needs habitId" + : "Only a habit check-in takes habitId", + path: ["habitId"], + }); + } + if (wantsGoal !== Boolean(input.goalId)) { + ctx.addIssue({ + code: "custom", + message: wantsGoal ? "A goal check-in needs goalId" : "Only a goal check-in takes goalId", + path: ["goalId"], + }); + } + }); +export type LogCheckIn = z.infer; + +export const ListCheckInsInput = z.object({ + from: LocalDateSchema.optional(), + to: LocalDateSchema.optional(), + kind: CheckInKindSchema.optional(), + habitId: Id.optional(), + goalId: Id.optional(), +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3865e1b..5f37595 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./ai-consent.js"; export * from "./attachments.js"; export * from "./bot-secrets.js"; +export * from "./coach.js"; export * from "./desktop.js"; export * from "./domain.js"; export * from "./events.js"; diff --git a/packages/core/src/coach.ts b/packages/core/src/coach.ts new file mode 100644 index 0000000..caddf24 --- /dev/null +++ b/packages/core/src/coach.ts @@ -0,0 +1,99 @@ +import type { HabitCadence } from "@rakazo/contracts"; + +const DAY_MS = 86_400_000; + +function utcMidnight(date: string): number { + return Date.parse(`${date}T00:00:00Z`); +} + +/** The calendar date at `now` in `timeZone`, e.g. "2026-10-03". */ +export function localDateIn(timeZone: string, now: Date = new Date()): string { + const parts = new Map( + new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .formatToParts(now) + .map((part) => [part.type, part.value]), + ); + return `${parts.get("year")}-${parts.get("month")}-${parts.get("day")}`; +} + +/** `date` moved by `days` calendar days. */ +export function addDays(date: string, days: number): string { + return new Date(utcMidnight(date) + days * DAY_MS).toISOString().slice(0, 10); +} + +/** Day of the week, 0 = Sunday … 6 = Saturday. */ +export function weekdayOf(date: string): number { + return new Date(utcMidnight(date)).getUTCDay(); +} + +/** The Monday that starts the week containing `date`. */ +export function weekStartOf(date: string): string { + return addDays(date, -((weekdayOf(date) + 6) % 7)); +} + +function isWeekday(date: string): boolean { + const day = weekdayOf(date); + return day >= 1 && day <= 5; +} + +/** The latest Monday–Friday date strictly before `date`. */ +function previousWeekday(date: string): string { + let cursor = addDays(date, -1); + while (!isWeekday(cursor)) cursor = addDays(cursor, -1); + return cursor; +} + +/** Whether the habit is done in the period that contains `today`. */ +export function habitDoneInPeriod( + cadence: HabitCadence, + doneDates: ReadonlySet, + today: string, +): boolean { + if (cadence !== "weekly") return doneDates.has(today); + const start = weekStartOf(today); + for (let date = start; date <= today; date = addDays(date, 1)) { + if (doneDates.has(date)) return true; + } + return false; +} + +/** + * Periods in a row the habit was done, ending today. The current period counts once it is done; + * until then it stays open and does not break the streak. Weekday habits skip weekends, weekly + * habits count Monday-to-Sunday weeks with at least one done day. + */ +export function habitStreak( + cadence: HabitCadence, + doneDates: ReadonlySet, + today: string, +): number { + let streak = 0; + if (cadence === "weekly") { + const weeks = new Set([...doneDates].map(weekStartOf)); + const current = weekStartOf(today); + for (let week = weeks.has(current) ? current : addDays(current, -7); weeks.has(week); ) { + streak += 1; + week = addDays(week, -7); + } + return streak; + } + if (cadence === "weekdays") { + let cursor = isWeekday(today) && doneDates.has(today) ? today : previousWeekday(today); + while (doneDates.has(cursor)) { + streak += 1; + cursor = previousWeekday(cursor); + } + return streak; + } + let cursor = doneDates.has(today) ? today : addDays(today, -1); + while (doneDates.has(cursor)) { + streak += 1; + cursor = addDays(cursor, -1); + } + return streak; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c870847..c2cbc44 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from "./bot-messages.js"; export * from "./bot-reply-sanitize.js"; export * from "./bot-sections.js"; export * from "./cloud-agent.js"; +export * from "./coach.js"; export * from "./compose-update.js"; export * from "./composer-mention-picker.js"; export * from "./composer-mentions.js"; diff --git a/packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql b/packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql new file mode 100644 index 0000000..4be9942 --- /dev/null +++ b/packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql @@ -0,0 +1,144 @@ +-- The main bot coaches the user: goals, habits, daily check-ins and a short brief per day. Dates +-- are the user's local calendar dates. Like the other provenance ids, createdByBotId and botId +-- have no foreign key. + +-- AlterTable +ALTER TABLE "routines" ADD COLUMN "coachKey" TEXT; + +-- CreateTable +CREATE TABLE "user_goals" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "detail" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'active', + "targetDate" DATE, + "spaceId" TEXT, + "source" TEXT NOT NULL, + "createdByBotId" TEXT, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_goals_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "habits" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "cadence" TEXT NOT NULL DEFAULT 'daily', + "goalId" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "source" TEXT NOT NULL, + "createdByBotId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "habits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "check_ins" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "habitId" TEXT, + "goalId" TEXT, + "date" DATE NOT NULL, + "done" BOOLEAN, + "mood" INTEGER, + "energy" INTEGER, + "note" TEXT NOT NULL DEFAULT '', + "createdByBotId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "check_ins_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "daily_briefs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "date" DATE NOT NULL, + "text" TEXT NOT NULL, + "botId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "daily_briefs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "routines_botId_coachKey_key" ON "routines"("botId", "coachKey"); + +-- CreateIndex +CREATE INDEX "user_goals_userId_status_createdAt_idx" ON "user_goals"("userId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "user_goals_spaceId_idx" ON "user_goals"("spaceId"); + +-- CreateIndex +CREATE INDEX "habits_userId_active_createdAt_idx" ON "habits"("userId", "active", "createdAt"); + +-- CreateIndex +CREATE INDEX "habits_goalId_idx" ON "habits"("goalId"); + +-- CreateIndex +CREATE INDEX "check_ins_userId_date_idx" ON "check_ins"("userId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "check_ins_habitId_date_key" ON "check_ins"("habitId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "check_ins_goalId_date_key" ON "check_ins"("goalId", "date"); + +-- One daily check-in per user and date. +CREATE UNIQUE INDEX "check_ins_userId_date_daily_key" ON "check_ins"("userId", "date") WHERE "kind" = 'daily'; + +-- CreateIndex +CREATE UNIQUE INDEX "daily_briefs_userId_date_key" ON "daily_briefs"("userId", "date"); + +-- AddForeignKey +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "habits" ADD CONSTRAINT "habits_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "habits" ADD CONSTRAINT "habits_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "user_goals"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_habitId_fkey" FOREIGN KEY ("habitId") REFERENCES "habits"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "user_goals"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "daily_briefs" ADD CONSTRAINT "daily_briefs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The CHECK constraint on the existing routines table is added NOT VALID and validated in the +-- next migration. +ALTER TABLE "routines" ADD CONSTRAINT "routines_coachKey_chk" CHECK ("coachKey" IS NULL OR "coachKey" IN ('morning', 'evening', 'weekly')) NOT VALID; + +-- New tables are empty, so their constraints apply immediately. +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_status_chk" CHECK ("status" IN ('active', 'done', 'dropped')); +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_source_chk" CHECK ("source" IN ('user', 'onboarding', 'chat')); +ALTER TABLE "habits" ADD CONSTRAINT "habits_cadence_chk" CHECK ("cadence" IN ('daily', 'weekdays', 'weekly')); +ALTER TABLE "habits" ADD CONSTRAINT "habits_source_chk" CHECK ("source" IN ('user', 'onboarding', 'chat')); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_kind_chk" CHECK ("kind" IN ('habit', 'goal', 'daily')); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_target_chk" CHECK ( + ("kind" = 'habit' AND "habitId" IS NOT NULL AND "goalId" IS NULL) OR + ("kind" = 'goal' AND "goalId" IS NOT NULL AND "habitId" IS NULL) OR + ("kind" = 'daily' AND "habitId" IS NULL AND "goalId" IS NULL) +); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_mood_chk" CHECK ("mood" IS NULL OR ("mood" >= 1 AND "mood" <= 5)); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_energy_chk" CHECK ("energy" IS NULL OR ("energy" >= 1 AND "energy" <= 5)); diff --git a/packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql b/packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql new file mode 100644 index 0000000..dbadb5b --- /dev/null +++ b/packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql @@ -0,0 +1,3 @@ +-- Kept separate from creation so PostgreSQL validates existing rows with a SHARE UPDATE +-- EXCLUSIVE lock (reads and writes continue) after the NOT VALID constraint has committed. +ALTER TABLE "routines" VALIDATE CONSTRAINT "routines_coachKey_chk"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a6dc085..b1dd0f9 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -47,6 +47,10 @@ model User { agentSecrets AgentSecret[] profile UserProfile? facts UserFact[] + goals UserGoal[] + habits Habit[] + checkIns CheckIn[] + dailyBriefs DailyBrief[] @@unique([email]) @@map("user") @@ -214,6 +218,7 @@ model Space { externalConversations ExternalConversation[] agentSecrets AgentSecret[] userFactVisibility UserFactSpace[] + userGoals UserGoal[] delegatedTasks DelegatedTask[] semanticMemoryItems SemanticMemoryItem[] @@ -766,15 +771,19 @@ model Routine { webhookEnabled Boolean @default(false) githubEnabled Boolean @default(false) messageProvider String? + /// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise. + coachKey String? lastRunAt DateTime? nextRunAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt runs Run[] + @@unique([botId, coachKey]) @@index([active, nextRunAt]) @@index([spaceId, botId]) @@index([createdByBotId]) + // CHECK constraint routines_coachKey_chk is created in SQL. @@map("routines") } @@ -1468,6 +1477,100 @@ model UserFactSpace { @@map("user_fact_spaces") } +/// Something the user wants to get done; the main bot coaches them toward it. +model UserGoal { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + detail String @default("") + status String @default("active") + /// The user's local date the goal is due, if any. + targetDate DateTime? @db.Date + spaceId String? + space Space? @relation(fields: [spaceId], references: [id], onDelete: SetNull) + source String + /// Like the other provenance ids it has no foreign key. + createdByBotId String? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + habits Habit[] + checkIns CheckIn[] + + @@index([userId, status, createdAt]) + @@index([spaceId]) + // CHECK constraints user_goals_status_chk and user_goals_source_chk are created in SQL. + @@map("user_goals") +} + +/// A recurring practice the user wants to keep, checked off per day or week. +model Habit { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + cadence String @default("daily") + goalId String? + goal UserGoal? @relation(fields: [goalId], references: [id], onDelete: SetNull) + active Boolean @default(true) + source String + createdByBotId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + checkIns CheckIn[] + + @@index([userId, active, createdAt]) + @@index([goalId]) + // CHECK constraints habits_cadence_chk and habits_source_chk are created in SQL. + @@map("habits") +} + +/// What the user reported for one day: a habit kept or missed, goal progress, or how the day went. +model CheckIn { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + kind String + habitId String? + habit Habit? @relation(fields: [habitId], references: [id], onDelete: Cascade) + goalId String? + goal UserGoal? @relation(fields: [goalId], references: [id], onDelete: Cascade) + /// The user's local date. + date DateTime @db.Date + done Boolean? + mood Int? + energy Int? + note String @default("") + createdByBotId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([habitId, date]) + @@unique([goalId, date]) + @@index([userId, date]) + // CHECK constraints check_ins_kind_chk, check_ins_target_chk, check_ins_mood_chk and + // check_ins_energy_chk and the partial unique index check_ins_userId_date_daily_key are + // created in SQL. + @@map("check_ins") +} + +/// The main bot's short brief for one of the user's days. +model DailyBrief { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// The user's local date. + date DateTime @db.Date + text String + botId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, date]) + @@map("daily_briefs") +} + /// Work the main bot hands to a specialist, reported back in the main conversation. model DelegatedTask { id String @id @default(cuid()) diff --git a/packages/db/src/coach.ts b/packages/db/src/coach.ts new file mode 100644 index 0000000..69438d0 --- /dev/null +++ b/packages/db/src/coach.ts @@ -0,0 +1,634 @@ +import type { + CheckIn, + CheckInKind, + CoachSource, + CoachToday, + DailyBrief, + Goal, + GoalStatus, + Habit, + HabitCadence, + HabitToday, +} from "@rakazo/contracts"; +import { + CHECK_IN_NOTE_MAX_LENGTH, + COACH_ACTIVE_GOAL_LIMIT, + COACH_ACTIVE_HABIT_LIMIT, + COACH_TITLE_MAX_LENGTH, + DAILY_BRIEF_MAX_LENGTH, + GOAL_DETAIL_MAX_LENGTH, + LocalDateSchema, +} from "@rakazo/contracts"; +import { + addDays, + habitDoneInPeriod, + habitStreak, + knownTimeZone, + localDateIn, + weekStartOf, +} from "@rakazo/core"; +import type { PrismaClient } from "./client.js"; +import { Prisma } from "./client.js"; + +/** How far back a check-in may be logged, and how far back streaks are counted. */ +export const CHECK_IN_MAX_AGE_DAYS = 366; + +export class CoachNotFoundError extends Error { + constructor(what: "Goal" | "Habit") { + super(`${what} not found`); + this.name = "CoachNotFoundError"; + } +} + +export class InvalidCoachInputError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidCoachInputError"; + } +} + +export interface NewGoal { + title: string; + detail?: string; + /** The user's local date, "YYYY-MM-DD". */ + targetDate?: string | null; + spaceId?: string | null; + source: CoachSource; + createdByBotId?: string | null; +} + +export interface GoalPatch { + title?: string; + detail?: string; + status?: GoalStatus; + targetDate?: string | null; + spaceId?: string | null; +} + +export interface NewHabit { + title: string; + cadence?: HabitCadence; + goalId?: string | null; + source: CoachSource; + createdByBotId?: string | null; +} + +export interface HabitPatch { + title?: string; + cadence?: HabitCadence; + goalId?: string | null; + active?: boolean; +} + +export interface CheckInEntry { + kind: CheckInKind; + habitId?: string; + goalId?: string; + /** The user's local date; defaults to today in their time zone. */ + date?: string; + done?: boolean | null; + mood?: number | null; + energy?: number | null; + note?: string; + createdByBotId?: string | null; +} + +export interface CheckInFilter { + from?: string; + to?: string; + kind?: CheckInKind; + habitId?: string; + goalId?: string; +} + +export interface DailyBriefEntry { + text: string; + /** The user's local date; defaults to today in their time zone. */ + date?: string; + botId?: string | null; +} + +/** Active goals and habits as the main bot sees them in its prompt. */ +export interface CoachSummary { + date: string; + timeZone: string; + goals: Goal[]; + habits: HabitToday[]; +} + +const CHECK_IN_LIST_LIMIT = 500; + +type Tx = Prisma.TransactionClient; +type GoalRow = Prisma.UserGoalGetPayload; +type HabitRow = Prisma.HabitGetPayload; +type CheckInRow = Prisma.CheckInGetPayload; +type BriefRow = Prisma.DailyBriefGetPayload; + +function dateString(value: Date): string { + return value.toISOString().slice(0, 10); +} + +function dateValue(value: string): Date { + return new Date(`${value}T00:00:00Z`); +} + +function goalDto(row: GoalRow): Goal { + return { + id: row.id, + title: row.title, + detail: row.detail, + status: row.status as GoalStatus, + targetDate: row.targetDate ? dateString(row.targetDate) : null, + spaceId: row.spaceId, + source: row.source as CoachSource, + createdByBotId: row.createdByBotId, + completedAt: row.completedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function habitDto(row: HabitRow): Habit { + return { + id: row.id, + title: row.title, + cadence: row.cadence as HabitCadence, + goalId: row.goalId, + active: row.active, + source: row.source as CoachSource, + createdByBotId: row.createdByBotId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function checkInDto(row: CheckInRow): CheckIn { + return { + id: row.id, + kind: row.kind as CheckInKind, + habitId: row.habitId, + goalId: row.goalId, + date: dateString(row.date), + done: row.done, + mood: row.mood, + energy: row.energy, + note: row.note, + createdByBotId: row.createdByBotId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function briefDto(row: BriefRow): DailyBrief { + return { + id: row.id, + date: dateString(row.date), + text: row.text, + botId: row.botId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function title(value: string): string { + const text = value.trim(); + if (!text) throw new InvalidCoachInputError("Title is required"); + if (text.length > COACH_TITLE_MAX_LENGTH) { + throw new InvalidCoachInputError(`Title is longer than ${COACH_TITLE_MAX_LENGTH} characters`); + } + return text; +} + +function boundedText(value: string, max: number, label: string): string { + const text = value.trim(); + if (text.length > max) + throw new InvalidCoachInputError(`${label} is longer than ${max} characters`); + return text; +} + +function localDate(value: string): string { + if (!LocalDateSchema.safeParse(value).success) { + throw new InvalidCoachInputError("Dates use YYYY-MM-DD"); + } + return value; +} + +function rating(value: number | null | undefined, label: string): number | null | undefined { + if (value === null || value === undefined) return value; + if (!Number.isInteger(value) || value < 1 || value > 5) { + throw new InvalidCoachInputError(`${label} is a whole number from 1 to 5`); + } + return value; +} + +/** + * Serializes one user's coach writes so the active limits and the one-check-in-per-day rule hold + * under concurrency. + */ +async function lockCoach(tx: Tx, userId: string): Promise { + const key = `coach:${userId}`; + await tx.$queryRaw(Prisma.sql` + SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text AS "lock" + `); +} + +/** The user's time zone from their profile, UTC when unknown. Every coach date uses it. */ +export async function coachTimeZone( + prisma: Pick, + userId: string, +): Promise { + const profile = await prisma.userProfile.findUnique({ + where: { userId }, + select: { timeZone: true }, + }); + return knownTimeZone(profile?.timeZone) ?? "UTC"; +} + +async function assertMemberSpace(tx: Tx, userId: string, spaceId: string | null | undefined) { + if (!spaceId) return; + const member = await tx.spaceMember.findFirst({ + where: { userId, spaceId }, + select: { id: true }, + }); + if (!member) throw new InvalidCoachInputError("Space not found"); +} + +async function ownedGoal(tx: Tx, userId: string, goalId: string): Promise { + const goal = await tx.userGoal.findFirst({ where: { id: goalId, userId } }); + if (!goal) throw new CoachNotFoundError("Goal"); + return goal; +} + +async function ownedHabit(tx: Tx, userId: string, habitId: string): Promise { + const habit = await tx.habit.findFirst({ where: { id: habitId, userId } }); + if (!habit) throw new CoachNotFoundError("Habit"); + return habit; +} + +async function assertActiveGoalRoom(tx: Tx, userId: string): Promise { + const active = await tx.userGoal.count({ where: { userId, status: "active" } }); + if (active >= COACH_ACTIVE_GOAL_LIMIT) { + throw new InvalidCoachInputError(`Keep at most ${COACH_ACTIVE_GOAL_LIMIT} active goals`); + } +} + +async function assertActiveHabitRoom(tx: Tx, userId: string): Promise { + const active = await tx.habit.count({ where: { userId, active: true } }); + if (active >= COACH_ACTIVE_HABIT_LIMIT) { + throw new InvalidCoachInputError(`Keep at most ${COACH_ACTIVE_HABIT_LIMIT} active habits`); + } +} + +export async function createGoal( + prisma: PrismaClient, + userId: string, + input: NewGoal, +): Promise { + const data = { + title: title(input.title), + detail: boundedText(input.detail ?? "", GOAL_DETAIL_MAX_LENGTH, "Detail"), + targetDate: input.targetDate ? dateValue(localDate(input.targetDate)) : null, + }; + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + await assertActiveGoalRoom(tx, userId); + await assertMemberSpace(tx, userId, input.spaceId); + const row = await tx.userGoal.create({ + data: { + ...data, + userId, + spaceId: input.spaceId ?? null, + source: input.source, + createdByBotId: input.createdByBotId ?? null, + }, + }); + return goalDto(row); + }); +} + +/** Changes a goal; marking it done stamps completedAt, any other status clears it. */ +export async function updateGoal( + prisma: PrismaClient, + userId: string, + goalId: string, + patch: GoalPatch, +): Promise { + const data: Prisma.UserGoalUncheckedUpdateInput = {}; + if (patch.title !== undefined) data.title = title(patch.title); + if (patch.detail !== undefined) { + data.detail = boundedText(patch.detail, GOAL_DETAIL_MAX_LENGTH, "Detail"); + } + if (patch.targetDate !== undefined) { + data.targetDate = patch.targetDate ? dateValue(localDate(patch.targetDate)) : null; + } + if (patch.spaceId !== undefined) data.spaceId = patch.spaceId; + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const goal = await ownedGoal(tx, userId, goalId); + if (patch.spaceId !== undefined) await assertMemberSpace(tx, userId, patch.spaceId); + if (patch.status !== undefined && patch.status !== goal.status) { + if (patch.status === "active") await assertActiveGoalRoom(tx, userId); + data.status = patch.status; + data.completedAt = patch.status === "done" ? new Date() : null; + } + const row = await tx.userGoal.update({ where: { id: goal.id }, data }); + return goalDto(row); + }); +} + +/** Deletes a goal with its check-ins; habits that served it stay, unlinked. */ +export async function removeGoal(prisma: PrismaClient, userId: string, goalId: string) { + const { count } = await prisma.userGoal.deleteMany({ where: { id: goalId, userId } }); + if (count === 0) throw new CoachNotFoundError("Goal"); +} + +export async function listGoals( + prisma: PrismaClient, + userId: string, + filter: { status?: GoalStatus } = {}, +): Promise { + const rows = await prisma.userGoal.findMany({ + where: { userId, ...(filter.status ? { status: filter.status } : {}) }, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], + }); + return rows.map(goalDto); +} + +export async function createHabit( + prisma: PrismaClient, + userId: string, + input: NewHabit, +): Promise { + const habitTitle = title(input.title); + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + await assertActiveHabitRoom(tx, userId); + if (input.goalId) await ownedGoal(tx, userId, input.goalId); + const row = await tx.habit.create({ + data: { + userId, + title: habitTitle, + cadence: input.cadence ?? "daily", + goalId: input.goalId ?? null, + source: input.source, + createdByBotId: input.createdByBotId ?? null, + }, + }); + return habitDto(row); + }); +} + +/** Changes a habit; `active: false` stops tracking it but keeps its history. */ +export async function updateHabit( + prisma: PrismaClient, + userId: string, + habitId: string, + patch: HabitPatch, +): Promise { + const data: Prisma.HabitUncheckedUpdateInput = {}; + if (patch.title !== undefined) data.title = title(patch.title); + if (patch.cadence !== undefined) data.cadence = patch.cadence; + if (patch.goalId !== undefined) data.goalId = patch.goalId; + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const habit = await ownedHabit(tx, userId, habitId); + if (patch.goalId) await ownedGoal(tx, userId, patch.goalId); + if (patch.active !== undefined && patch.active !== habit.active) { + if (patch.active) await assertActiveHabitRoom(tx, userId); + data.active = patch.active; + } + const row = await tx.habit.update({ where: { id: habit.id }, data }); + return habitDto(row); + }); +} + +export async function removeHabit(prisma: PrismaClient, userId: string, habitId: string) { + const { count } = await prisma.habit.deleteMany({ where: { id: habitId, userId } }); + if (count === 0) throw new CoachNotFoundError("Habit"); +} + +export async function listHabits( + prisma: PrismaClient, + userId: string, + filter: { includeInactive?: boolean } = {}, +): Promise { + const rows = await prisma.habit.findMany({ + where: { userId, ...(filter.includeInactive ? {} : { active: true }) }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }); + return rows.map(habitDto); +} + +/** + * Logs or updates one day's check-in: one per habit, one per goal and one daily check-in per + * date. Fields left out keep their stored value. Unchecking a weekly habit clears the whole + * week, so the habit reads as not done this week. + */ +export async function logCheckIn( + prisma: PrismaClient, + userId: string, + entry: CheckInEntry, + now: Date = new Date(), +): Promise { + if ((entry.kind === "habit") !== Boolean(entry.habitId)) { + throw new InvalidCoachInputError("A habit check-in needs habitId, and only it"); + } + if ((entry.kind === "goal") !== Boolean(entry.goalId)) { + throw new InvalidCoachInputError("A goal check-in needs goalId, and only it"); + } + const fields: Prisma.CheckInUncheckedUpdateInput = {}; + if (entry.done !== undefined) fields.done = entry.done; + const mood = rating(entry.mood, "Mood"); + if (mood !== undefined) fields.mood = mood; + const energy = rating(entry.energy, "Energy"); + if (energy !== undefined) fields.energy = energy; + if (entry.note !== undefined) { + fields.note = boundedText(entry.note, CHECK_IN_NOTE_MAX_LENGTH, "Note"); + } + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const today = localDateIn(await coachTimeZone(tx, userId), now); + const date = entry.date ? localDate(entry.date) : today; + if (date > today) throw new InvalidCoachInputError("Check-ins cannot be in the future"); + if (date < addDays(today, -CHECK_IN_MAX_AGE_DAYS)) { + throw new InvalidCoachInputError(`Check-ins go back at most ${CHECK_IN_MAX_AGE_DAYS} days`); + } + const habit = entry.habitId ? await ownedHabit(tx, userId, entry.habitId) : null; + if (entry.goalId) await ownedGoal(tx, userId, entry.goalId); + const day = dateValue(date); + const existing = habit + ? await tx.checkIn.findUnique({ where: { habitId_date: { habitId: habit.id, date: day } } }) + : entry.goalId + ? await tx.checkIn.findUnique({ + where: { goalId_date: { goalId: entry.goalId, date: day } }, + }) + : await tx.checkIn.findFirst({ where: { userId, kind: "daily", date: day } }); + const row = existing + ? await tx.checkIn.update({ where: { id: existing.id }, data: fields }) + : await tx.checkIn.create({ + data: { + userId, + kind: entry.kind, + habitId: habit?.id ?? null, + goalId: entry.goalId ?? null, + date: day, + done: entry.done ?? null, + mood: mood ?? null, + energy: energy ?? null, + note: typeof fields.note === "string" ? fields.note : "", + createdByBotId: entry.createdByBotId ?? null, + }, + }); + if (habit && entry.done === false && habit.cadence === "weekly") { + await tx.checkIn.updateMany({ + where: { + habitId: habit.id, + done: true, + date: { + gte: dateValue(weekStartOf(date)), + lte: dateValue(addDays(weekStartOf(date), 6)), + }, + }, + data: { done: false }, + }); + } + return checkInDto(row); + }); +} + +export async function listCheckIns( + prisma: PrismaClient, + userId: string, + filter: CheckInFilter = {}, +): Promise { + const rows = await prisma.checkIn.findMany({ + where: { + userId, + ...(filter.kind ? { kind: filter.kind } : {}), + ...(filter.habitId ? { habitId: filter.habitId } : {}), + ...(filter.goalId ? { goalId: filter.goalId } : {}), + ...(filter.from || filter.to + ? { + date: { + ...(filter.from ? { gte: dateValue(localDate(filter.from)) } : {}), + ...(filter.to ? { lte: dateValue(localDate(filter.to)) } : {}), + }, + } + : {}), + }, + orderBy: [{ date: "desc" }, { createdAt: "desc" }, { id: "asc" }], + take: CHECK_IN_LIST_LIMIT, + }); + return rows.map(checkInDto); +} + +/** Saves the brief for one of the user's days, replacing an earlier one for that date. */ +export async function upsertDailyBrief( + prisma: PrismaClient, + userId: string, + entry: DailyBriefEntry, + now: Date = new Date(), +): Promise { + const text = boundedText(entry.text, DAILY_BRIEF_MAX_LENGTH, "Brief"); + if (!text) throw new InvalidCoachInputError("Brief is empty"); + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const today = localDateIn(await coachTimeZone(tx, userId), now); + const date = entry.date ? localDate(entry.date) : today; + if (date > addDays(today, 1)) { + throw new InvalidCoachInputError("A brief is for today or tomorrow at the latest"); + } + const row = await tx.dailyBrief.upsert({ + where: { userId_date: { userId, date: dateValue(date) } }, + create: { userId, date: dateValue(date), text, botId: entry.botId ?? null }, + update: { text, botId: entry.botId ?? null }, + }); + return briefDto(row); + }); +} + +async function activeGoals(prisma: PrismaClient, userId: string): Promise { + const rows = await prisma.userGoal.findMany({ + where: { userId, status: "active" }, + orderBy: [{ targetDate: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }, { id: "asc" }], + }); + return rows.map(goalDto); +} + +async function activeHabitsOn( + prisma: PrismaClient, + userId: string, + today: string, +): Promise { + const habits = await prisma.habit.findMany({ + where: { userId, active: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }); + if (habits.length === 0) return []; + const done = await prisma.checkIn.findMany({ + where: { + userId, + kind: "habit", + done: true, + habitId: { in: habits.map((habit) => habit.id) }, + date: { gte: dateValue(addDays(today, -CHECK_IN_MAX_AGE_DAYS)), lte: dateValue(today) }, + }, + select: { habitId: true, date: true }, + }); + const doneDates = new Map>(); + for (const row of done) { + if (!row.habitId) continue; + const dates = doneDates.get(row.habitId) ?? new Set(); + dates.add(dateString(row.date)); + doneDates.set(row.habitId, dates); + } + return habits.map((row) => { + const habit = habitDto(row); + const dates = doneDates.get(row.id) ?? new Set(); + return { + ...habit, + doneToday: habitDoneInPeriod(habit.cadence, dates, today), + streak: habitStreak(habit.cadence, dates, today), + }; + }); +} + +/** Active goals and habits with streaks, for the main bot's prompt. */ +export async function loadCoachSummary( + prisma: PrismaClient, + userId: string, + now: Date = new Date(), +): Promise { + const timeZone = await coachTimeZone(prisma, userId); + const date = localDateIn(timeZone, now); + const [goals, habits] = await Promise.all([ + activeGoals(prisma, userId), + activeHabitsOn(prisma, userId, date), + ]); + return { date, timeZone, goals, habits }; +} + +/** The user's day: today's brief, active goals, habits with streaks and the daily check-in. */ +export async function loadCoachToday( + prisma: PrismaClient, + userId: string, + now: Date = new Date(), +): Promise { + const timeZone = await coachTimeZone(prisma, userId); + const date = localDateIn(timeZone, now); + const day = dateValue(date); + const [brief, goals, habits, checkIn] = await Promise.all([ + prisma.dailyBrief.findUnique({ where: { userId_date: { userId, date: day } } }), + activeGoals(prisma, userId), + activeHabitsOn(prisma, userId, date), + prisma.checkIn.findFirst({ where: { userId, kind: "daily", date: day } }), + ]); + return { + date, + timeZone, + brief: brief ? briefDto(brief) : null, + goals, + habits, + checkIn: checkIn ? checkInDto(checkIn) : null, + }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a251fa..79a5b9a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,6 +1,7 @@ export * from "./bootstrap-user.js"; export * from "./cancel-runs.js"; export * from "./client.js"; +export * from "./coach.js"; export * from "./computers.js"; export * from "./credential-secrets.js"; export * from "./events.js"; From a16344746137525f741eb86a82e478025cb50674 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:13:22 +0000 Subject: [PATCH 03/38] feat(notifications): add push subscriptions, notification settings and web push interfaces Push could only reach one mobile device per user (a token file), quiet hours did not exist, and the web app had no service worker, so a closed tab never heard about finished work. This lands the shared interfaces first so onboarding can build on them: - push_subscriptions (web push endpoints and Expo tokens, one row per device) and user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a per-day nudge counter), with CHECK constraints. - NotificationSettingsSchema and web push contracts, notifications.settings.get/update and notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only). - getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers. - VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted, and an SSRF-guarded web push sender built on the web-push package. - The web app links its manifest, registers a push-only service worker where supported (secure context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/ disableWebPush. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/app.ts | 6 +- apps/api/src/router.ts | 49 +++- apps/web/index.html | 1 + apps/web/public/site.webmanifest | 4 +- apps/web/public/sw.js | 61 +++++ apps/web/src/lib/web-push.ts | 102 ++++++++ apps/web/src/main.tsx | 2 + packages/adapters/package.json | 2 + packages/adapters/src/index.ts | 1 + packages/adapters/src/web-push.ts | 220 ++++++++++++++++++ packages/adapters/src/web-ssrf.ts | 2 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/notifications.ts | 71 ++++++ packages/contracts/src/rpc.ts | 18 ++ packages/core/src/index.ts | 1 + packages/core/src/quiet-hours.ts | 48 ++++ .../migration.sql | 52 +++++ packages/db/prisma/schema.prisma | 39 ++++ packages/db/src/index.ts | 2 + packages/db/src/notification-settings.ts | 121 ++++++++++ packages/db/src/push-subscriptions.ts | 88 +++++++ packages/testkit/src/authorization.test.ts | 13 ++ pnpm-lock.yaml | 54 +++++ 23 files changed, 953 insertions(+), 5 deletions(-) create mode 100644 apps/web/public/sw.js create mode 100644 apps/web/src/lib/web-push.ts create mode 100644 packages/adapters/src/web-push.ts create mode 100644 packages/contracts/src/notifications.ts create mode 100644 packages/core/src/quiet-hours.ts create mode 100644 packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql create mode 100644 packages/db/src/notification-settings.ts create mode 100644 packages/db/src/push-subscriptions.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 948d735..c8b5057 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -65,6 +65,8 @@ import { SmtpEmailProvider, SpaceMemoryProviderResolver, toTeamChatInbound, + VapidKeys, + vapidEnvFromProcess, } from "@rakazo/adapters"; import { blockedAuthPaths, createAuth } from "@rakazo/auth"; import { signupPolicyFromEnv } from "@rakazo/core"; @@ -310,6 +312,7 @@ export async function createApp( sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined, }); const notifications = new ExpoPushProvider(env.dataDir); + const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env)); const auth = createAuth(prisma, { secret: env.authSecret, baseURL: env.authUrl, @@ -463,6 +466,7 @@ export async function createApp( secrets, oauthLogins, integrationSettings, + vapid, mcpOAuth, composio: stack.composio, connectors: stack.connector, @@ -567,7 +571,7 @@ export async function createApp( } const { matched, response } = await rpc.handle(c.req.raw, { prefix: "/rpc", - context: { actor, signal: c.req.raw.signal }, + context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") }, }); if (matched) return c.newResponse(response.body, response); await next(); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..b6c7bc6 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -27,6 +27,7 @@ import type { MemoryProviderResolver, PiOAuthLogins, RemoteConnectorDependencies, + VapidKeys, } from "@rakazo/adapters"; import { acquireComputerExecutionLease, @@ -52,6 +53,7 @@ import { filterTeamWorkspaceEntries, hasActiveComputerControl, isAutoReviewCheckerConfigured, + isBlockedHostname, isComputerScreenUnavailable, isProjectWorkspacePath, isSandboxGoneError, @@ -123,6 +125,7 @@ import { findModelCredential, findSpaceModelTier, formatMessagingLinkCode, + getNotificationSettings, InvalidProjectWorkspacePathError, InvalidSpaceNameError, IsolationError, @@ -135,6 +138,7 @@ import { parseComputerMode, projectWorkspaceDto, releaseSpaceDeletionClaim, + removePushSubscription, renewSpaceDeletionClaim, requireMembership, SPACE_DELETION_CLAIM_TIMEOUT_MS, @@ -142,8 +146,10 @@ import { SpaceLimitError, SpaceNotEmptyError, SpaceNotFoundError, + savePushSubscription, selectSpaceModelPreference, selectSpaceVoicePreference, + updateNotificationSettings, wireProjectWorkspace, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; @@ -457,6 +463,8 @@ export interface RouterDeps { secrets: EncryptedSecretStore; oauthLogins: PiOAuthLogins; integrationSettings?: IntegrationProviderSettings; + /** The deployment's web push key pair; without it browsers cannot subscribe. */ + vapid?: Pick; composio?: ComposioProvider; mcpOAuth?: McpOAuthBroker; connectors: ConnectorRegistry; @@ -504,7 +512,11 @@ function mapSpaceLifecycleError(error: unknown): unknown { } export function createRouter(deps: RouterDeps) { - const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>(); + const os = implement(appContract).$context<{ + actor: Actor | null; + signal?: AbortSignal; + userAgent?: string; + }>(); const repos = createRepos(deps.prisma); const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors }; const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets); @@ -4758,6 +4770,41 @@ export function createRouter(deps: RouterDeps) { await deletePushToken(deps.dataDir, context.actor.userId); return { ok: true as const }; }), + settings: { + get: authed.notifications.settings.get.handler(({ context }) => + getNotificationSettings(deps.prisma, context.actor.userId), + ), + update: authed.notifications.settings.update.handler(({ context, input }) => + updateNotificationSettings(deps.prisma, context.actor.userId, input), + ), + }, + webPush: { + config: authed.notifications.webPush.config.handler(async () => { + const publicKey = (await deps.vapid?.publicKey()) ?? null; + return { available: publicKey !== null, publicKey }; + }), + subscribe: authed.notifications.webPush.subscribe.handler(async ({ context, input }) => { + if (isBlockedHostname(new URL(input.endpoint).hostname.replace(/^\[|\]$/g, ""))) { + throw new ORPCError("BAD_REQUEST", { + message: "Endpoint must be a public push service", + }); + } + await savePushSubscription(deps.prisma, context.actor.userId, { + kind: "webpush", + endpoint: input.endpoint, + p256dh: input.keys.p256dh, + auth: input.keys.auth, + userAgent: context.userAgent, + }); + return { ok: true as const }; + }), + unsubscribe: authed.notifications.webPush.unsubscribe.handler( + async ({ context, input }) => { + await removePushSubscription(deps.prisma, context.actor.userId, input.endpoint); + return { ok: true as const }; + }, + ), + }, }, search: { query: authed.search.query.handler(async ({ context, input }) => ({ diff --git a/apps/web/index.html b/apps/web/index.html index c626fbd..df71503 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,7 @@ + evOo diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest index 90dd32d..c57083f 100644 --- a/apps/web/public/site.webmanifest +++ b/apps/web/public/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "EvOo", - "short_name": "EvOo", + "name": "evOo", + "short_name": "evOo", "description": "Deine Oos — immer da, immer wach.", "start_url": "/", "display": "standalone", diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..374172c --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,61 @@ +// Shows the server's web push notifications and opens the conversation they belong to. +// No fetch handler: the app always loads from the network. + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +function appPath(value) { + return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") + ? value + : "/app"; +} + +self.addEventListener("push", (event) => { + event.waitUntil( + (async () => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = {}; + } + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + // The app in front of the user already shows what happened. + if (windows.some((client) => client.focused && client.visibilityState === "visible")) return; + await self.registration.showNotification( + typeof data.title === "string" && data.title ? data.title : "evOo", + { + body: typeof data.body === "string" ? data.body : "", + // Same tag as the in-app notification, so one replaces the other. + tag: typeof data.tag === "string" && data.tag ? data.tag : undefined, + icon: "/icon-192.png", + data: { url: appPath(data.url) }, + }, + ); + })(), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil( + (async () => { + const target = new URL(appPath(event.notification.data?.url), self.location.origin).href; + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const open = + windows.find((client) => client.url === target) ?? + windows.find((client) => new URL(client.url).origin === self.location.origin); + if (!open) { + await self.clients.openWindow(target); + return; + } + const focused = await open.focus(); + if (focused.url !== target) await focused.navigate(target).catch(() => undefined); + })(), + ); +}); diff --git a/apps/web/src/lib/web-push.ts b/apps/web/src/lib/web-push.ts new file mode 100644 index 0000000..2dd999d --- /dev/null +++ b/apps/web/src/lib/web-push.ts @@ -0,0 +1,102 @@ +import { desktopBridge } from "./desktop"; +import { rpc } from "./rpc"; + +const SERVICE_WORKER_URL = "/sw.js"; +const SERVICE_WORKER_SCOPE = "/"; + +/** + * Web push needs the Push API, a service worker and a secure context (https or localhost). The + * desktop app keeps its in-app notifications instead. + */ +export function webPushSupported(): boolean { + return ( + typeof window !== "undefined" && + window.isSecureContext && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window && + !desktopBridge() + ); +} + +/** Registers the push service worker where web push works; safe to call on every start. */ +export async function registerPushServiceWorker(): Promise { + if (!webPushSupported()) return; + await navigator.serviceWorker + .register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }) + .catch(() => undefined); +} + +async function currentSubscription(): Promise { + const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE); + return (await registration?.pushManager.getSubscription()) ?? null; +} + +/** True when this browser has granted notifications and holds a push subscription. */ +export async function webPushEnabled(): Promise { + if (!webPushSupported() || Notification.permission !== "granted") return false; + return Boolean(await currentSubscription().catch(() => null)); +} + +function base64UrlToBytes(value: string): Uint8Array { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function sameKey(current: ArrayBuffer | null, expected: Uint8Array): boolean { + if (!current || current.byteLength !== expected.byteLength) return false; + const bytes = new Uint8Array(current); + return bytes.every((byte, index) => byte === expected[index]); +} + +/** + * Turns on push for this browser and registers it with the server. Call it straight from a click: + * browsers only show the permission prompt during a user gesture. Throws when the server cannot + * be reached. + */ +export async function enableWebPush(): Promise<"enabled" | "denied" | "unsupported"> { + if (!webPushSupported()) return "unsupported"; + const permission = + Notification.permission === "default" + ? await Notification.requestPermission() + : Notification.permission; + if (permission !== "granted") return "denied"; + const config = await rpc.notifications.webPush.config(); + if (!config.available || !config.publicKey) return "unsupported"; + const applicationServerKey = base64UrlToBytes(config.publicKey); + let subscription: PushSubscription; + try { + await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }); + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + // A subscription made for other server keys cannot receive this server's pushes. + if (existing && !sameKey(existing.options.applicationServerKey, applicationServerKey)) { + await existing.unsubscribe(); + } + subscription = + existing && sameKey(existing.options.applicationServerKey, applicationServerKey) + ? existing + : await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); + } catch { + // Browsers without a push service (or with it switched off) reject the subscription. + return "unsupported"; + } + const keys = subscription.toJSON().keys ?? {}; + await rpc.notifications.webPush.subscribe({ + endpoint: subscription.endpoint, + keys: { p256dh: keys.p256dh ?? "", auth: keys.auth ?? "" }, + }); + return "enabled"; +} + +/** Stops push for this browser, on the server first so no push arrives after it returns. */ +export async function disableWebPush(): Promise { + if (!webPushSupported()) return; + const subscription = await currentSubscription(); + if (!subscription) return; + await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint }); + await subscription.unsubscribe().catch(() => undefined); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1b03bf7..6060637 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import { markAfterPaint, markOnce } from "./lib/performance"; import { installPreloadRecovery } from "./lib/preload-recovery"; import { applyUiAppearance, watchSystemAppearance } from "./lib/ui-appearance"; import { resolveUiLocale } from "./lib/ui-locale"; +import { registerPushServiceWorker } from "./lib/web-push"; import "./styles.css"; markOnce("rk:renderer:module-evaluated"); @@ -19,6 +20,7 @@ try { installPreloadRecovery(); applyUiDirection(resolveUiLocale()); applyUiAppearance(); +void registerPushServiceWorker(); function PerformanceProbe() { useLayoutEffect(() => { diff --git a/packages/adapters/package.json b/packages/adapters/package.json index c9b7855..701d625 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -42,6 +42,7 @@ "sharp": "0.35.4", "shell-quote": "1.10.0", "undici": "8.10.2", + "web-push": "3.6.7", "zod": "4.6.2" }, "devDependencies": { @@ -50,6 +51,7 @@ "@types/jsdom": "30.0.0", "@types/nodemailer": "8.0.1", "@types/pg": "8.23.1", + "@types/web-push": "3.6.4", "typescript": "7.0.2", "vitest": "4.1.11" } diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 7b4ecd0..ecf21b9 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -124,5 +124,6 @@ export * from "./voice-factory.js"; export * from "./wakeup.js"; export * from "./web-limits.js"; export * from "./web-provider-factory.js"; +export * from "./web-push.js"; export * from "./web-ssrf.js"; export * from "./web-tools.js"; diff --git a/packages/adapters/src/web-push.ts b/packages/adapters/src/web-push.ts new file mode 100644 index 0000000..3abb071 --- /dev/null +++ b/packages/adapters/src/web-push.ts @@ -0,0 +1,220 @@ +import { lookup } from "node:dns/promises"; +import type { NotificationMessage } from "@rakazo/adapter-kit"; +import type { PrismaClient } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; +import { Agent } from "undici"; +import webPush from "web-push"; +import { combineSignals } from "./connector-safety.js"; +import { createAddressCheckedLookup, type ResolveHostname } from "./network-address.js"; +import type { EncryptedSecretStore } from "./secrets.js"; +import { dispatcherFetch } from "./undici-fetch.js"; +import { assertPublicAddresses, assertSafeWebUrl } from "./web-ssrf.js"; + +/** Generated keys live encrypted in the deployment-wide settings table under this id. */ +const VAPID_RECORD_ID = "web-push-vapid"; +/** RFC 8292 wants a contact; deployments set VAPID_SUBJECT or serve the web app over https. */ +const FALLBACK_SUBJECT = "mailto:admin@localhost"; +const WEB_PUSH_TIMEOUT_MS = 15_000; +/** Undelivered pushes expire after a day; older news belongs in the inbox. */ +const WEB_PUSH_TTL_SECONDS = 86_400; + +export interface VapidDetails { + subject: string; + publicKey: string; + privateKey: string; +} + +export interface VapidEnv { + publicKey?: string; + privateKey?: string; + subject?: string; +} + +/** VAPID settings from the environment; the web origin is the contact when it is https. */ +export function vapidEnvFromProcess(source: Record): VapidEnv { + const value = (raw: string | undefined) => raw?.trim() || undefined; + const origin = value(source.WEB_ORIGIN); + return { + publicKey: value(source.VAPID_PUBLIC_KEY), + privateKey: value(source.VAPID_PRIVATE_KEY), + subject: value(source.VAPID_SUBJECT) ?? (origin?.startsWith("https://") ? origin : undefined), + }; +} + +function assertVapidDetails(details: VapidDetails): VapidDetails { + // Validates subject and both keys; the error messages never contain the private key. + webPush.getVapidHeaders( + "https://push.invalid", + details.subject, + details.publicKey, + details.privateKey, + "aes128gcm", + ); + return details; +} + +/** + * The deployment's VAPID key pair: the configured one, or one generated on first use and stored + * encrypted, so the API and every worker sign with the same keys. + */ +export class VapidKeys { + private pending: Promise | undefined; + + constructor( + private readonly prisma: PrismaClient, + private readonly secrets: EncryptedSecretStore, + private readonly env: VapidEnv = {}, + ) {} + + details(): Promise { + this.pending ??= this.resolve().catch((error: unknown) => { + this.pending = undefined; + throw error; + }); + return this.pending; + } + + /** The public key browsers subscribe with, or null when no usable key pair exists. */ + async publicKey(): Promise { + try { + return (await this.details()).publicKey; + } catch (error) { + getLogger().error("web push keys unavailable", error); + return null; + } + } + + private async resolve(): Promise { + const subject = this.env.subject ?? FALLBACK_SUBJECT; + if (this.env.publicKey && this.env.privateKey) { + return assertVapidDetails({ + subject, + publicKey: this.env.publicKey, + privateKey: this.env.privateKey, + }); + } + if (this.env.publicKey || this.env.privateKey) { + getLogger().warn("VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be set together; ignoring"); + } + const stored = await this.load(); + if (stored) return assertVapidDetails({ subject, ...stored }); + const generated = webPush.generateVAPIDKeys(); + const sealed = await this.secrets.put( + JSON.stringify({ publicKey: generated.publicKey, privateKey: generated.privateKey }), + { + operationId: "web-push.keys", + traceId: "web-push.keys", + spaceId: "", + userId: "", + signal: AbortSignal.timeout(WEB_PUSH_TIMEOUT_MS), + }, + VAPID_RECORD_ID, + ); + // When the API and a worker generate at once, the first insert wins and both read it back. + await this.prisma.integrationProviderConfig.createMany({ + data: [{ id: VAPID_RECORD_ID, ciphertext: sealed.ciphertext }], + skipDuplicates: true, + }); + const winner = await this.load(); + if (!winner) throw new Error("Web push keys could not be stored"); + return assertVapidDetails({ subject, ...winner }); + } + + private async load(): Promise<{ publicKey: string; privateKey: string } | null> { + const row = await this.prisma.integrationProviderConfig.findUnique({ + where: { id: VAPID_RECORD_ID }, + select: { ciphertext: true }, + }); + if (!row) return null; + const parsed = JSON.parse(this.secrets.load(row.ciphertext, VAPID_RECORD_ID)) as { + publicKey?: unknown; + privateKey?: unknown; + }; + if (typeof parsed.publicKey !== "string" || typeof parsed.privateKey !== "string") { + throw new Error("Stored web push keys are malformed"); + } + return { publicKey: parsed.publicKey, privateKey: parsed.privateKey }; + } +} + +/** What a device shows: the notification plus where a tap leads (a path in the web app). */ +export interface PushPayload extends NotificationMessage { + url: string; +} + +/** "gone": the push service no longer knows this device, so its registration can be dropped. */ +export type PushOutcome = "sent" | "gone"; + +export interface PushTarget { + endpoint: string; + p256dh: string | null; + auth: string | null; +} + +export interface WebPushSenderOptions { + fetch?: typeof globalThis.fetch; + resolveHostname?: ResolveHostname; + timeoutMs?: number; +} + +const defaultResolveHostname: ResolveHostname = (hostname) => + lookup(hostname, { all: true, verbatim: true }); + +/** + * Sends encrypted web push messages (RFC 8030/8291/8292). The endpoint comes from a browser, so + * the request only ever connects to public addresses, pinned at connect time. + */ +export class WebPushSender { + constructor( + private readonly vapid: Pick, + private readonly options: WebPushSenderOptions = {}, + ) {} + + async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise { + if (!target.p256dh || !target.auth) return "gone"; + const request = webPush.generateRequestDetails( + { endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } }, + JSON.stringify({ + title: payload.title, + body: payload.body, + url: payload.url, + tag: payload.threadId, + kind: payload.kind, + }), + { vapidDetails: await this.vapid.details(), TTL: WEB_PUSH_TTL_SECONDS }, + ); + const resolve = this.options.resolveHostname ?? defaultResolveHostname; + const deadline = combineSignals( + signal, + AbortSignal.timeout(this.options.timeoutMs ?? WEB_PUSH_TIMEOUT_MS), + ); + const url = await assertSafeWebUrl(request.endpoint, resolve, deadline); + if (url.protocol !== "https:") throw new Error("Web push endpoint must use https"); + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (name.toLowerCase() !== "content-length") headers.set(name, String(value)); + } + const dispatcher = new Agent({ + connect: { lookup: createAddressCheckedLookup(resolve, assertPublicAddresses) }, + }); + try { + const response = await (this.options.fetch ?? dispatcherFetch)(url.href, { + method: "POST", + headers, + body: request.body, + redirect: "manual", + signal: deadline, + dispatcher, + } as RequestInit & { dispatcher: Agent }); + await response.body?.cancel().catch(() => undefined); + if (response.status === 404 || response.status === 410) return "gone"; + if (response.status < 200 || response.status >= 300) { + // Status only: push service bodies and endpoints identify the device. + throw new Error(`Web push failed (${response.status})`); + } + return "sent"; + } finally { + await dispatcher.close().catch(() => undefined); + } + } +} diff --git a/packages/adapters/src/web-ssrf.ts b/packages/adapters/src/web-ssrf.ts index 869cb80..25b7c3d 100644 --- a/packages/adapters/src/web-ssrf.ts +++ b/packages/adapters/src/web-ssrf.ts @@ -229,7 +229,7 @@ export async function readBodyCapped( }); } -function assertPublicAddresses(addresses: ResolvedAddress[]): void { +export function assertPublicAddresses(addresses: ResolvedAddress[]): void { if (addresses.length === 0 || addresses.some((entry) => isPrivateAddress(entry.address))) { throw new Error("URL resolves to a private address"); } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3865e1b..f562a16 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,6 +10,7 @@ export * from "./integration-settings.js"; export * from "./local-settings.js"; export * from "./mcp.js"; export * from "./model-tiers.js"; +export * from "./notifications.js"; export * from "./openai-compatible-ui.js"; export * from "./project-goals.js"; export * from "./project-sessions.js"; diff --git a/packages/contracts/src/notifications.ts b/packages/contracts/src/notifications.ts new file mode 100644 index 0000000..5cb6287 --- /dev/null +++ b/packages/contracts/src/notifications.ts @@ -0,0 +1,71 @@ +import * as z from "zod"; + +/** How many nudges the main agent may send per day in the user's time zone. */ +export const NUDGES_PER_DAY = 3; + +const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** A 24-hour wall-clock time, e.g. "22:30". */ +export const ClockTimeSchema = z.string().regex(CLOCK_TIME, "Use HH:MM"); + +/** Minutes after midnight for a "HH:MM" time. */ +export function clockTimeToMinutes(value: string): number { + const match = CLOCK_TIME.exec(value); + if (!match) throw new Error("Use HH:MM"); + return Number(match[1]) * 60 + Number(match[2]); +} + +/** "HH:MM" for minutes after midnight. */ +export function minutesToClockTime(minutes: number): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; +} + +/** A daily range in the user's profile time zone; it may cross midnight ("22:00"–"07:00"). */ +export const QuietHoursSchema = z + .object({ start: ClockTimeSchema, end: ClockTimeSchema }) + .refine((range) => range.start !== range.end, "Start and end must differ"); +export type QuietHours = z.infer; + +export const NotificationSettingsSchema = z.object({ + /** Every push is held back during quiet hours; the inbox still lists what happened. */ + quietHours: QuietHoursSchema.nullable(), + /** Whether the main agent may notify the user on its own initiative. */ + nudges: z.boolean(), +}); +export type NotificationSettings = z.infer; + +export const UpdateNotificationSettingsInput = NotificationSettingsSchema.partial(); +export type NotificationSettingsPatch = z.infer; + +/** What a browser needs to subscribe; `available` is false when the server cannot send. */ +export const WebPushConfigSchema = z.object({ + available: z.boolean(), + publicKey: z.string().nullable(), +}); +export type WebPushConfig = z.infer; + +/** A push service URL. Browsers only hand out https endpoints. */ +export const WebPushEndpointSchema = z + .string() + .max(2048) + .refine((value) => { + try { + const url = new URL(value); + return url.protocol === "https:" && !url.username && !url.password; + } catch { + return false; + } + }, "Endpoint must be an https URL"); + +/** The JSON form of a browser PushSubscription (RFC 8291 keys, base64url). */ +export const WebPushSubscriptionInput = z.object({ + endpoint: WebPushEndpointSchema, + keys: z.object({ + /** An uncompressed P-256 public key: 65 bytes. */ + p256dh: z.string().regex(/^[A-Za-z0-9_-]{87}=?$/, "Invalid p256dh key"), + /** A 16-byte authentication secret. */ + auth: z.string().regex(/^[A-Za-z0-9_-]{22}(==)?$/, "Invalid auth secret"), + }), +}); +export type WebPushSubscriptionJson = z.infer; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..1dc59cb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -83,6 +83,13 @@ import { IntegrationSetupStateSchema, } from "./integration-settings.js"; import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js"; +import { + NotificationSettingsSchema, + UpdateNotificationSettingsInput, + WebPushConfigSchema, + WebPushEndpointSchema, + WebPushSubscriptionInput, +} from "./notifications.js"; import { CreateProjectGoalInput, ProjectGoalSchema, @@ -803,6 +810,17 @@ export const appContract = { .input(z.object({ token: z.string().min(8).max(512) })) .output(z.object({ ok: z.literal(true) })), unregisterPush: oc.output(z.object({ ok: z.literal(true) })), + settings: { + get: oc.output(NotificationSettingsSchema), + update: oc.input(UpdateNotificationSettingsInput).output(NotificationSettingsSchema), + }, + webPush: { + config: oc.output(WebPushConfigSchema), + subscribe: oc.input(WebPushSubscriptionInput).output(z.object({ ok: z.literal(true) })), + unsubscribe: oc + .input(z.object({ endpoint: WebPushEndpointSchema })) + .output(z.object({ ok: z.literal(true) })), + }, }, search: { query: oc.input(z.object({ q: z.string().max(200) })).output(SearchQueryOutputSchema), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c870847..eb5edb1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,6 +30,7 @@ export * from "./messaging-prompts.js"; export * from "./model-oauth.js"; export * from "./model-probe.js"; export * from "./model-providers.js"; +export * from "./quiet-hours.js"; export * from "./response-bytes.js"; export * from "./run-state.js"; export * from "./sandbox-command.js"; diff --git a/packages/core/src/quiet-hours.ts b/packages/core/src/quiet-hours.ts new file mode 100644 index 0000000..11b7c4d --- /dev/null +++ b/packages/core/src/quiet-hours.ts @@ -0,0 +1,48 @@ +import { knownTimeZone } from "./cron.js"; + +/** Minutes after local midnight; `start` > `end` means the range crosses midnight. */ +export interface MinuteRange { + start: number; + end: number; +} + +/** The local calendar date ("YYYY-MM-DD") and minute of day at `now`, UTC when the zone is unknown. */ +export function localDateAndMinute( + now: Date, + timeZone: string | null | undefined, +): { date: string; minute: number } { + const parts = new Map( + new Intl.DateTimeFormat("en-US", { + timeZone: knownTimeZone(timeZone) ?? "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }) + .formatToParts(now) + .map((part) => [part.type, part.value]), + ); + return { + date: `${parts.get("year")}-${parts.get("month")}-${parts.get("day")}`, + minute: Number(parts.get("hour")) * 60 + Number(parts.get("minute")), + }; +} + +/** True when `minute` lies in the range; the start is inclusive, the end exclusive. */ +export function isQuietMinute(range: MinuteRange | null, minute: number): boolean { + if (!range || range.start === range.end) return false; + return range.start < range.end + ? minute >= range.start && minute < range.end + : minute >= range.start || minute < range.end; +} + +/** True when `now` falls in the quiet hours of a user in `timeZone`. */ +export function isQuietTime( + range: MinuteRange | null, + timeZone: string | null | undefined, + now: Date, +): boolean { + return isQuietMinute(range, localDateAndMinute(now, timeZone).minute); +} diff --git a/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql new file mode 100644 index 0000000..c684498 --- /dev/null +++ b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql @@ -0,0 +1,52 @@ +-- CreateTable +CREATE TABLE "push_subscriptions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "p256dh" TEXT, + "auth" TEXT, + "userAgent" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + + CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_notification_settings" ( + "userId" TEXT NOT NULL, + "quietHoursStart" INTEGER, + "quietHoursEnd" INTEGER, + "nudges" BOOLEAN NOT NULL DEFAULT true, + "nudgeDate" DATE, + "nudgeCount" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_notification_settings_pkey" PRIMARY KEY ("userId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "push_subscriptions_endpoint_key" ON "push_subscriptions"("endpoint"); + +-- CreateIndex +CREATE INDEX "push_subscriptions_userId_idx" ON "push_subscriptions"("userId"); + +-- AddForeignKey +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Both tables are new and empty, so their constraints are valid from the start. +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_kind_chk" CHECK ("kind" IN ('webpush', 'expo')); +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_webpush_keys_chk" CHECK ("kind" <> 'webpush' OR ("p256dh" IS NOT NULL AND "auth" IS NOT NULL)); +ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_quiet_hours_chk" CHECK ( + ("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL) + OR ( + "quietHoursStart" BETWEEN 0 AND 1439 + AND "quietHoursEnd" BETWEEN 0 AND 1439 + AND "quietHoursStart" <> "quietHoursEnd" + ) +); +ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_nudge_count_chk" CHECK ("nudgeCount" >= 0); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a6dc085..19bede8 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -47,6 +47,8 @@ model User { agentSecrets AgentSecret[] profile UserProfile? facts UserFact[] + pushSubscriptions PushSubscription[] + notificationSettings UserNotificationSettings? @@unique([email]) @@map("user") @@ -1111,6 +1113,43 @@ model NotificationPreference { @@map("notification_preferences") } +/// One device that receives the user's push notifications: a browser (web push) or the mobile +/// app (an Expo push token). An endpoint belongs to the account that registered it last. +model PushSubscription { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + kind String + /// The web push endpoint URL or the Expo push token. + endpoint String @unique + p256dh String? + auth String? + userAgent String @default("") + createdAt DateTime @default(now()) + lastUsedAt DateTime? + + @@index([userId]) + // CHECK constraints push_subscriptions_kind_chk and push_subscriptions_webpush_keys_chk are created in SQL. + @@map("push_subscriptions") +} + +/// When the user may be interrupted. No row means no quiet hours and nudges on. +model UserNotificationSettings { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// Minutes after local midnight in the profile time zone; both set or both null. + quietHoursStart Int? + quietHoursEnd Int? + nudges Boolean @default(true) + /// The user's local date of the last nudge and how many were sent that day (daily cap). + nudgeDate DateTime? @db.Date + nudgeCount Int @default(0) + updatedAt DateTime @updatedAt + + // CHECK constraints user_notification_settings_quiet_hours_chk and _nudge_count_chk are created in SQL. + @@map("user_notification_settings") +} + model SpaceMemoryConfig { id String @id @default(cuid()) spaceId String @unique diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a251fa..3ed7e3a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -12,8 +12,10 @@ export * from "./messages.js"; export * from "./messaging.js"; export * from "./model-credentials.js"; export * from "./model-tiers.js"; +export * from "./notification-settings.js"; export * from "./project-goals.js"; export * from "./project-workspace.js"; +export * from "./push-subscriptions.js"; export * from "./repos.js"; export * from "./scope.js"; export * from "./spaces.js"; diff --git a/packages/db/src/notification-settings.ts b/packages/db/src/notification-settings.ts new file mode 100644 index 0000000..050f61e --- /dev/null +++ b/packages/db/src/notification-settings.ts @@ -0,0 +1,121 @@ +import type { NotificationSettings, NotificationSettingsPatch } from "@rakazo/contracts"; +import { + clockTimeToMinutes, + minutesToClockTime, + NUDGES_PER_DAY, + UpdateNotificationSettingsInput, +} from "@rakazo/contracts"; +import { isQuietMinute, localDateAndMinute } from "@rakazo/core"; +import type { PrismaClient } from "./client.js"; + +const settingsSelect = { quietHoursStart: true, quietHoursEnd: true, nudges: true } as const; + +type SettingsRow = { + quietHoursStart: number | null; + quietHoursEnd: number | null; + nudges: boolean; +}; + +function quietRange(row: SettingsRow | null) { + return row && row.quietHoursStart !== null && row.quietHoursEnd !== null + ? { start: row.quietHoursStart, end: row.quietHoursEnd } + : null; +} + +function toSettings(row: SettingsRow | null): NotificationSettings { + const range = quietRange(row); + return { + quietHours: range + ? { start: minutesToClockTime(range.start), end: minutesToClockTime(range.end) } + : null, + nudges: row?.nudges ?? true, + }; +} + +/** The user's notification settings; without a stored row: no quiet hours, nudges on. */ +export async function getNotificationSettings( + prisma: PrismaClient, + userId: string, +): Promise { + return toSettings( + await prisma.userNotificationSettings.findUnique({ where: { userId }, select: settingsSelect }), + ); +} + +/** Applies the fields present in `patch`; `quietHours: null` turns quiet hours off. */ +export async function updateNotificationSettings( + prisma: PrismaClient, + userId: string, + patch: NotificationSettingsPatch, +): Promise { + const valid = UpdateNotificationSettingsInput.parse(patch); + const data: { quietHoursStart?: number | null; quietHoursEnd?: number | null; nudges?: boolean } = + {}; + if (valid.quietHours !== undefined) { + data.quietHoursStart = valid.quietHours ? clockTimeToMinutes(valid.quietHours.start) : null; + data.quietHoursEnd = valid.quietHours ? clockTimeToMinutes(valid.quietHours.end) : null; + } + if (valid.nudges !== undefined) data.nudges = valid.nudges; + return toSettings( + await prisma.userNotificationSettings.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + select: settingsSelect, + }), + ); +} + +export interface NotificationPolicy { + /** True during the user's quiet hours: no push may go out. */ + quiet: boolean; + nudges: boolean; + /** The user's local date ("YYYY-MM-DD"), the day the nudge cap counts against. */ + localDate: string; +} + +/** What may reach the user at `now`, judged in their profile time zone (UTC when unknown). */ +export async function loadNotificationPolicy( + prisma: PrismaClient, + userId: string, + now: Date = new Date(), +): Promise { + const [row, profile] = await Promise.all([ + prisma.userNotificationSettings.findUnique({ where: { userId }, select: settingsSelect }), + prisma.userProfile.findUnique({ where: { userId }, select: { timeZone: true } }), + ]); + const local = localDateAndMinute(now, profile?.timeZone); + return { + quiet: isQuietMinute(quietRange(row), local.minute), + nudges: row?.nudges ?? true, + localDate: local.date, + }; +} + +/** + * Counts one nudge against the user's daily cap and returns whether it may go out. Each step is + * a single conditional UPDATE, so concurrent nudges cannot exceed the cap. + */ +export async function claimDailyNudge( + prisma: PrismaClient, + userId: string, + localDate: string, + limit: number = NUDGES_PER_DAY, +): Promise { + const day = new Date(`${localDate}T00:00:00.000Z`); + await prisma.userNotificationSettings.createMany({ data: [{ userId }], skipDuplicates: true }); + // Two rounds: a concurrent first nudge of the day can move the date between the statements. + for (let round = 0; round < 2; round += 1) { + const sameDay = await prisma.userNotificationSettings.updateMany({ + where: { userId, nudgeDate: day, nudgeCount: { lt: limit } }, + data: { nudgeCount: { increment: 1 } }, + }); + if (sameDay.count === 1) return true; + const newDay = await prisma.userNotificationSettings.updateMany({ + where: { userId, OR: [{ nudgeDate: null }, { nudgeDate: { not: day } }] }, + data: { nudgeDate: day, nudgeCount: 1 }, + }); + if (newDay.count === 1) return true; + } + return false; +} diff --git a/packages/db/src/push-subscriptions.ts b/packages/db/src/push-subscriptions.ts new file mode 100644 index 0000000..885f337 --- /dev/null +++ b/packages/db/src/push-subscriptions.ts @@ -0,0 +1,88 @@ +import type { PrismaClient } from "./client.js"; + +export type PushSubscriptionKind = "webpush" | "expo"; + +/** Devices one user keeps registered; the least recently registered ones are dropped first. */ +export const PUSH_SUBSCRIPTION_LIMIT = 20; + +export interface StoredPushSubscription { + id: string; + kind: PushSubscriptionKind; + endpoint: string; + p256dh: string | null; + auth: string | null; +} + +export interface NewPushSubscription { + kind: PushSubscriptionKind; + /** Web push endpoint URL or Expo push token. */ + endpoint: string; + p256dh?: string | null; + auth?: string | null; + userAgent?: string; +} + +/** + * Registers a device for the user. An endpoint registered by another account moves to this one: + * a device delivers to whoever signed in on it last. + */ +export async function savePushSubscription( + prisma: PrismaClient, + userId: string, + input: NewPushSubscription, +): Promise { + const data = { + userId, + kind: input.kind, + p256dh: input.p256dh ?? null, + auth: input.auth ?? null, + userAgent: (input.userAgent ?? "").slice(0, 256), + }; + await prisma.pushSubscription.upsert({ + where: { endpoint: input.endpoint }, + create: { endpoint: input.endpoint, ...data }, + // A renewed registration counts as the newest device for the per-user limit. + update: { ...data, createdAt: new Date() }, + }); + const stale = await prisma.pushSubscription.findMany({ + where: { userId }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + skip: PUSH_SUBSCRIPTION_LIMIT, + select: { id: true }, + }); + if (stale.length > 0) { + await prisma.pushSubscription.deleteMany({ + where: { id: { in: stale.map((row) => row.id) } }, + }); + } +} + +/** Removes one of the user's devices; another user's endpoint is left alone. */ +export async function removePushSubscription( + prisma: PrismaClient, + userId: string, + endpoint: string, +): Promise { + await prisma.pushSubscription.deleteMany({ where: { userId, endpoint } }); +} + +/** Removes every device of one kind, e.g. all mobile app tokens on sign-out. */ +export async function removePushSubscriptionsOfKind( + prisma: PrismaClient, + userId: string, + kind: PushSubscriptionKind, +): Promise { + await prisma.pushSubscription.deleteMany({ where: { userId, kind } }); +} + +export async function listPushSubscriptions( + prisma: PrismaClient, + userId: string, +): Promise { + const rows = await prisma.pushSubscription.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { id: true, kind: true, endpoint: true, p256dh: true, auth: true }, + }); + return rows.map((row) => ({ ...row, kind: row.kind as PushSubscriptionKind })); +} diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ce1e785..c640e4d 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -192,6 +192,12 @@ describeWithDatabase("API authorization and resource isolation", () => { ["usage/summary"], ["export/bot", { botId: "missing-bot" }], ["notifications/registerPush", { token: "ExponentPushToken[not-real]" }], + ["notifications/unregisterPush"], + ["notifications/settings/get"], + ["notifications/settings/update", { nudges: false }], + ["notifications/webPush/config"], + ["notifications/webPush/subscribe", webPushSubscriptionInput("unauthenticated")], + ["notifications/webPush/unsubscribe", { endpoint: "https://push.example.test/none" }], ["search/query", { q: "anything" }], ["tasks/list", {}], ["inbox/list"], @@ -2358,6 +2364,13 @@ interface Bot { id: string; } +function webPushSubscriptionInput(device: string) { + return { + endpoint: `https://push.example.test/${device}`, + keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }, + }; +} + function exhaustiveProtectedCalls< const Calls extends ReadonlyArray, >(calls: Calls & (ProtectedRpcPath extends Calls[number][0] ? unknown : never)): Calls { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2acb6e..5736d79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -618,6 +618,9 @@ importers: undici: specifier: 8.10.2 version: 8.10.2 + web-push: + specifier: 3.6.7 + version: 3.6.7 zod: specifier: 4.6.2 version: 4.6.2 @@ -637,6 +640,9 @@ importers: '@types/pg': specifier: 8.23.1 version: 8.23.1 + '@types/web-push': + specifier: 3.6.4 + version: 3.6.4 typescript: specifier: 7.0.2 version: 7.0.2 @@ -4343,6 +4349,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -4759,6 +4768,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -5020,6 +5032,9 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + bn.js@4.12.5: + resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -6752,6 +6767,10 @@ packages: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -7620,6 +7639,9 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -9759,6 +9781,11 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -14103,6 +14130,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/web-push@3.6.4': + dependencies: + '@types/node': 24.13.3 + '@types/ws@8.18.1': dependencies: '@types/node': 24.13.3 @@ -14605,6 +14636,13 @@ snapshots: asap@2.0.6: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.5 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -14947,6 +14985,8 @@ snapshots: bluebird@3.7.2: {} + bn.js@4.12.5: {} + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -17016,6 +17056,8 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + http_ece@1.2.0: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -18116,6 +18158,8 @@ snapshots: mimic-response@3.1.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -20377,6 +20421,16 @@ snapshots: dependencies: defaults: 1.0.4 + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + web-streams-polyfill@3.3.3: {} web-vitals@6.2.1: {} From e1ce6f4090e68e6e5ab62f8a1f37bd9f18a974b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:15:06 +0000 Subject: [PATCH 04/38] test(coach): repository, streak and rhythm suites Covers what the coach interfaces promise before anything builds on them: goal and habit ownership and limits, one check-in per habit, goal and day dated in the user's time zone, streaks across weekends and weeks, and a rhythm that keeps exactly one main-bot routine per check-in and is idempotent when set again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .../src/coach-rhythm.postgres.test.ts | 182 ++++++++++ packages/core/src/coach.test.ts | 66 ++++ packages/db/src/coach.postgres.test.ts | 327 ++++++++++++++++++ packages/testkit/src/cli/harness.ts | 2 + 4 files changed, 577 insertions(+) create mode 100644 packages/adapters/src/coach-rhythm.postgres.test.ts create mode 100644 packages/core/src/coach.test.ts create mode 100644 packages/db/src/coach.postgres.test.ts diff --git a/packages/adapters/src/coach-rhythm.postgres.test.ts b/packages/adapters/src/coach-rhythm.postgres.test.ts new file mode 100644 index 0000000..73136e2 --- /dev/null +++ b/packages/adapters/src/coach-rhythm.postgres.test.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import { routineJobKey } from "@rakazo/adapter-kit"; +import type { PrismaClient } from "@rakazo/db"; +import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("coach rhythm (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(timeZone: string | null = "Europe/Vienna") { + const id = `cr${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); + if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } }); + return id; + } + + function fakeJobs() { + return { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }; + } + + function coachRoutines(userId: string) { + return prisma.routine.findMany({ + where: { userId, coachKey: { not: null } }, + orderBy: { coachKey: "asc" }, + }); + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("keeps one main-bot routine per check-in in the main conversation and user's time zone", async () => { + const userId = await newUser("Europe/Vienna"); + const jobs = fakeJobs(); + const rhythm = { morning: "07:30", evening: null, weekly: { day: 0, time: "18:00" } }; + + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + const conversation = await ensureMainConversation(prisma, userId); + const created = await coachRoutines(userId); + expect( + created.map( + ({ coachKey, botId, threadId, spaceId, crons, timezone, active, notify, name }) => ({ + coachKey, + botId, + threadId, + spaceId, + crons, + timezone, + active, + notify, + name, + }), + ), + ).toEqual([ + { + coachKey: "morning", + botId: conversation.leadBotId, + threadId: conversation.threadId, + spaceId: conversation.spaceId, + crons: ["30 7 * * *"], + timezone: "Europe/Vienna", + active: true, + notify: true, + name: COACH_ROUTINES.morning.name, + }, + { + coachKey: "weekly", + botId: conversation.leadBotId, + threadId: conversation.threadId, + spaceId: conversation.spaceId, + crons: ["0 18 * * 0"], + timezone: "Europe/Vienna", + active: true, + notify: true, + name: COACH_ROUTINES.weekly.name, + }, + ]); + for (const routine of created) { + expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + expect(jobs.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + name: "routine.wakeup", + replaceKey: routineJobKey(routine.id), + availableAt: routine.nextRunAt, + }), + ); + } + expect(created[0]?.prompt).toBe(COACH_ROUTINES.morning.prompt); + + // The same rhythm again changes nothing. + jobs.enqueue.mockClear(); + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + expect(jobs.enqueue).not.toHaveBeenCalled(); + expect(await coachRoutines(userId)).toEqual(created); + + // Moving, adding and turning off check-ins. + const next = { morning: "06:45", evening: "21:00", weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: next })).toEqual(next); + const updated = await coachRoutines(userId); + expect(updated.map((routine) => [routine.coachKey, routine.crons[0]])).toEqual([ + ["evening", "0 21 * * *"], + ["morning", "45 6 * * *"], + ]); + expect(updated.find((routine) => routine.coachKey === "morning")?.id).toBe(created[0]?.id); + expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(created[1]!.id)); + expect(await getCoachRhythm(prisma, userId)).toEqual(next); + }); + + it("reads paused or reshaped routines as off and turns them back on", async () => { + const userId = await newUser("UTC"); + const jobs = fakeJobs(); + await setCoachRhythm( + { prisma, jobs }, + { userId, rhythm: { morning: "08:00", evening: "20:00", weekly: null } }, + ); + const routines = await coachRoutines(userId); + await prisma.routine.update({ where: { id: routines[0]!.id }, data: { active: false } }); + await prisma.routine.update({ + where: { id: routines[1]!.id }, + data: { crons: ["0 8 * * 1-5"] }, + }); + expect(await getCoachRhythm(prisma, userId)).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + + const rhythm = { morning: "08:00", evening: "20:00", weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + expect((await coachRoutines(userId)).map((routine) => routine.id)).toEqual( + routines.map((routine) => routine.id), + ); + }); + + it("turning everything off removes the routines and never creates a main bot", async () => { + const userId = await newUser(null); + const jobs = fakeJobs(); + const off = { morning: null, evening: null, weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off); + expect(await prisma.bot.count({ where: { userId, kind: "main" } })).toBe(0); + expect(await getCoachRhythm(prisma, userId)).toEqual(off); + + await setCoachRhythm( + { prisma, jobs }, + { userId, rhythm: { morning: "09:15", evening: null, weekly: null } }, + ); + const [routine] = await coachRoutines(userId); + expect(routine).toMatchObject({ timezone: "UTC", crons: ["15 9 * * *"] }); + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off); + expect(await coachRoutines(userId)).toEqual([]); + expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(routine!.id)); + }); +}); diff --git a/packages/core/src/coach.test.ts b/packages/core/src/coach.test.ts new file mode 100644 index 0000000..4f8f816 --- /dev/null +++ b/packages/core/src/coach.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + addDays, + habitDoneInPeriod, + habitStreak, + localDateIn, + weekdayOf, + weekStartOf, +} from "./coach.js"; + +const dates = (...values: string[]) => new Set(values); + +describe("calendar dates", () => { + it("reads the date in the user's time zone, not the server's", () => { + const instant = new Date("2026-10-03T23:30:00Z"); + expect(localDateIn("UTC", instant)).toBe("2026-10-03"); + expect(localDateIn("Europe/Vienna", instant)).toBe("2026-10-04"); + expect(localDateIn("America/Los_Angeles", instant)).toBe("2026-10-03"); + expect(localDateIn("Pacific/Kiritimati", new Date("2026-10-03T10:30:00Z"))).toBe("2026-10-04"); + }); + + it("moves across months, years and daylight saving changes by whole days", () => { + expect(addDays("2026-10-31", 1)).toBe("2026-11-01"); + expect(addDays("2027-01-01", -1)).toBe("2026-12-31"); + expect(addDays("2026-03-28", 2)).toBe("2026-03-30"); + expect(weekdayOf("2026-10-03")).toBe(6); + expect(weekStartOf("2026-10-03")).toBe("2026-09-28"); + expect(weekStartOf("2026-10-04")).toBe("2026-09-28"); + expect(weekStartOf("2026-10-05")).toBe("2026-10-05"); + }); +}); + +describe("habit streaks", () => { + const saturday = "2026-10-03"; + + it("counts consecutive days and keeps today open until it is done", () => { + const done = dates("2026-09-30", "2026-10-01", "2026-10-02"); + expect(habitStreak("daily", done, saturday)).toBe(3); + expect(habitStreak("daily", new Set([...done, saturday]), saturday)).toBe(4); + expect(habitStreak("daily", dates("2026-09-30", "2026-10-01"), saturday)).toBe(0); + expect(habitStreak("daily", dates(), saturday)).toBe(0); + }); + + it("skips weekends for weekday habits", () => { + const monday = "2026-10-05"; + const done = dates("2026-10-01", "2026-10-02"); + expect(habitStreak("weekdays", done, saturday)).toBe(2); + expect(habitStreak("weekdays", done, monday)).toBe(2); + expect(habitStreak("weekdays", new Set([...done, monday]), monday)).toBe(3); + expect(habitStreak("weekdays", dates("2026-10-01"), monday)).toBe(0); + }); + + it("counts weeks with at least one done day for weekly habits", () => { + const done = dates("2026-09-15", "2026-09-26", "2026-09-29"); + expect(habitStreak("weekly", done, saturday)).toBe(3); + expect(habitStreak("weekly", dates("2026-09-15", "2026-09-26"), saturday)).toBe(2); + expect(habitStreak("weekly", dates("2026-09-15"), saturday)).toBe(0); + }); + + it("reads a weekly habit as done for the rest of the week", () => { + expect(habitDoneInPeriod("weekly", dates("2026-09-29"), saturday)).toBe(true); + expect(habitDoneInPeriod("weekly", dates("2026-09-27"), saturday)).toBe(false); + expect(habitDoneInPeriod("daily", dates("2026-10-02"), saturday)).toBe(false); + expect(habitDoneInPeriod("daily", dates(saturday), saturday)).toBe(true); + }); +}); diff --git a/packages/db/src/coach.postgres.test.ts b/packages/db/src/coach.postgres.test.ts new file mode 100644 index 0000000..b4bb7a1 --- /dev/null +++ b/packages/db/src/coach.postgres.test.ts @@ -0,0 +1,327 @@ +import { randomUUID } from "node:crypto"; +import { COACH_ACTIVE_GOAL_LIMIT, COACH_ACTIVE_HABIT_LIMIT } from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootstrapUserSpace } from "./bootstrap-user.js"; +import type { PrismaClient } from "./client.js"; +import { createDb } from "./client.js"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + listCheckIns, + listGoals, + listHabits, + loadCoachSummary, + loadCoachToday, + logCheckIn, + removeGoal, + removeHabit, + updateGoal, + updateHabit, + upsertDailyBrief, +} from "./coach.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; +/** Saturday 2026-10-03, 23:30 UTC: already Sunday in Vienna. */ +const NOW = new Date("2026-10-03T23:30:00Z"); + +describePostgres("coach repository (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(timeZone: string | null = "Europe/Vienna") { + const id = `co${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, { + claimDeploymentOwner: false, + }); + if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } }); + return { userId: id, spaceId }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("creates, completes, reopens and removes goals, scoped to their owner", async () => { + const { userId, spaceId } = await newUser(); + const other = await newUser(); + const goal = await createGoal(prisma, userId, { + title: " Ship the beta ", + detail: "Invite ten testers", + targetDate: "2026-11-01", + spaceId, + source: "onboarding", + }); + expect(goal).toMatchObject({ + title: "Ship the beta", + detail: "Invite ten testers", + status: "active", + targetDate: "2026-11-01", + spaceId, + source: "onboarding", + createdByBotId: null, + completedAt: null, + }); + + const done = await updateGoal(prisma, userId, goal.id, { status: "done" }); + expect(done.status).toBe("done"); + expect(done.completedAt).not.toBeNull(); + const reopened = await updateGoal(prisma, userId, goal.id, { + status: "active", + targetDate: null, + }); + expect(reopened).toMatchObject({ status: "active", completedAt: null, targetDate: null }); + + await expect(updateGoal(prisma, other.userId, goal.id, { title: "Mine" })).rejects.toThrow( + CoachNotFoundError, + ); + await expect(removeGoal(prisma, other.userId, goal.id)).rejects.toThrow(CoachNotFoundError); + await expect( + createGoal(prisma, other.userId, { title: "Theirs", spaceId, source: "user" }), + ).rejects.toThrow("Space not found"); + expect(await listGoals(prisma, other.userId)).toEqual([]); + expect((await listGoals(prisma, userId, { status: "active" })).map((g) => g.id)).toEqual([ + goal.id, + ]); + + await removeGoal(prisma, userId, goal.id); + expect(await listGoals(prisma, userId)).toEqual([]); + }); + + it("validates titles, dates and the active limits", async () => { + const { userId } = await newUser(); + await expect(createGoal(prisma, userId, { title: " ", source: "user" })).rejects.toThrow( + InvalidCoachInputError, + ); + await expect( + createGoal(prisma, userId, { title: "x".repeat(201), source: "user" }), + ).rejects.toThrow("longer than 200"); + await expect( + createGoal(prisma, userId, { title: "Bad date", targetDate: "2026-02-30", source: "user" }), + ).rejects.toThrow("YYYY-MM-DD"); + + await prisma.userGoal.createMany({ + data: Array.from({ length: COACH_ACTIVE_GOAL_LIMIT }, (_, index) => ({ + userId, + title: `Goal ${index}`, + source: "user", + })), + }); + await expect(createGoal(prisma, userId, { title: "One more", source: "user" })).rejects.toThrow( + `at most ${COACH_ACTIVE_GOAL_LIMIT} active goals`, + ); + const [first] = await listGoals(prisma, userId); + await updateGoal(prisma, userId, first!.id, { status: "dropped" }); + await createGoal(prisma, userId, { title: "One more", source: "user" }); + await expect(updateGoal(prisma, userId, first!.id, { status: "active" })).rejects.toThrow( + "active goals", + ); + + await prisma.habit.createMany({ + data: Array.from({ length: COACH_ACTIVE_HABIT_LIMIT }, (_, index) => ({ + userId, + title: `Habit ${index}`, + source: "user", + })), + }); + await expect( + createHabit(prisma, userId, { title: "One more", source: "user" }), + ).rejects.toThrow(`at most ${COACH_ACTIVE_HABIT_LIMIT} active habits`); + }); + + it("links habits to goals, pauses them and keeps their history", async () => { + const { userId } = await newUser(); + const other = await newUser(); + const goal = await createGoal(prisma, userId, { title: "Run a 10k", source: "chat" }); + const habit = await createHabit(prisma, userId, { + title: "Run", + cadence: "weekdays", + goalId: goal.id, + source: "chat", + createdByBotId: "bot-1", + }); + expect(habit).toMatchObject({ cadence: "weekdays", goalId: goal.id, active: true }); + await expect( + createHabit(prisma, other.userId, { title: "Steal", goalId: goal.id, source: "user" }), + ).rejects.toThrow(CoachNotFoundError); + + const paused = await updateHabit(prisma, userId, habit.id, { active: false, title: "Jog" }); + expect(paused).toMatchObject({ active: false, title: "Jog" }); + expect(await listHabits(prisma, userId)).toEqual([]); + expect((await listHabits(prisma, userId, { includeInactive: true })).length).toBe(1); + + // Removing the goal keeps the habit, unlinked. + await removeGoal(prisma, userId, goal.id); + expect((await listHabits(prisma, userId, { includeInactive: true }))[0]?.goalId).toBeNull(); + await expect(removeHabit(prisma, other.userId, habit.id)).rejects.toThrow(CoachNotFoundError); + await removeHabit(prisma, userId, habit.id); + expect(await listHabits(prisma, userId, { includeInactive: true })).toEqual([]); + }); + + it("keeps one check-in per habit, goal and day, dated in the user's time zone", async () => { + const { userId } = await newUser("Europe/Vienna"); + const other = await newUser(); + const habit = await createHabit(prisma, userId, { title: "Stretch", source: "user" }); + const goal = await createGoal(prisma, userId, { title: "Learn Spanish", source: "user" }); + + const first = await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: habit.id, done: true }, + NOW, + ); + // 23:30 UTC on Saturday is Sunday in Vienna. + expect(first).toMatchObject({ kind: "habit", date: "2026-10-04", done: true, note: "" }); + const again = await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: habit.id, note: "10 minutes", createdByBotId: "bot-1" }, + NOW, + ); + expect(again).toMatchObject({ id: first.id, done: true, note: "10 minutes" }); + // Who created the row stays as it was. + expect(again.createdByBotId).toBeNull(); + + const daily = await logCheckIn(prisma, userId, { kind: "daily", mood: 4 }, NOW); + const dailyAgain = await logCheckIn(prisma, userId, { kind: "daily", energy: 2 }, NOW); + expect(dailyAgain).toMatchObject({ id: daily.id, mood: 4, energy: 2 }); + const goalCheckIn = await logCheckIn( + prisma, + userId, + { kind: "goal", goalId: goal.id, note: "Finished unit 3", date: "2026-10-02" }, + NOW, + ); + expect(goalCheckIn.date).toBe("2026-10-02"); + + await expect( + logCheckIn(prisma, userId, { kind: "daily", date: "2026-10-05" }, NOW), + ).rejects.toThrow("future"); + await expect( + logCheckIn(prisma, userId, { kind: "daily", date: "2025-01-01" }, NOW), + ).rejects.toThrow("go back at most"); + await expect(logCheckIn(prisma, userId, { kind: "daily", mood: 6 }, NOW)).rejects.toThrow( + "1 to 5", + ); + await expect( + logCheckIn(prisma, userId, { kind: "habit", goalId: goal.id }, NOW), + ).rejects.toThrow(InvalidCoachInputError); + await expect( + logCheckIn(prisma, other.userId, { kind: "habit", habitId: habit.id, done: true }, NOW), + ).rejects.toThrow(CoachNotFoundError); + + expect((await listCheckIns(prisma, userId)).map((row) => row.kind).sort()).toEqual([ + "daily", + "goal", + "habit", + ]); + expect(await listCheckIns(prisma, userId, { from: "2026-10-03" })).toHaveLength(2); + expect(await listCheckIns(prisma, userId, { goalId: goal.id })).toHaveLength(1); + expect(await listCheckIns(prisma, other.userId)).toEqual([]); + }); + + it("reports today's brief, goals, habits with streaks and the daily check-in", async () => { + const { userId } = await newUser("Europe/Vienna"); + const daily = await createHabit(prisma, userId, { title: "Walk", source: "user" }); + const weekly = await createHabit(prisma, userId, { + title: "Long run", + cadence: "weekly", + source: "user", + }); + const later = await createGoal(prisma, userId, { title: "Later", source: "user" }); + const soon = await createGoal(prisma, userId, { + title: "Soon", + targetDate: "2026-10-10", + source: "user", + }); + await createGoal(prisma, userId, { title: "Old", source: "user" }).then((goal) => + updateGoal(prisma, userId, goal.id, { status: "done" }), + ); + for (const date of ["2026-10-01", "2026-10-02", "2026-10-03"]) { + await logCheckIn(prisma, userId, { kind: "habit", habitId: daily.id, date, done: true }, NOW); + } + await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: weekly.id, date: "2026-09-30", done: true }, + NOW, + ); + + let today = await loadCoachToday(prisma, userId, NOW); + expect(today).toMatchObject({ date: "2026-10-04", timeZone: "Europe/Vienna", brief: null }); + expect(today.checkIn).toBeNull(); + expect(today.goals.map((goal) => goal.id)).toEqual([soon.id, later.id]); + expect( + today.habits.map(({ title, doneToday, streak }) => ({ title, doneToday, streak })), + ).toEqual([ + { title: "Walk", doneToday: false, streak: 3 }, + { title: "Long run", doneToday: true, streak: 1 }, + ]); + + const brief = await upsertDailyBrief( + prisma, + userId, + { text: "Rest day. Plan the week.", botId: "bot-1" }, + NOW, + ); + expect(brief).toMatchObject({ date: "2026-10-04", botId: "bot-1" }); + const rewritten = await upsertDailyBrief(prisma, userId, { text: "Rest day." }, NOW); + expect(rewritten).toMatchObject({ id: brief.id, text: "Rest day.", botId: null }); + await expect( + upsertDailyBrief(prisma, userId, { text: "Far ahead", date: "2026-10-20" }, NOW), + ).rejects.toThrow("today or tomorrow"); + await expect(upsertDailyBrief(prisma, userId, { text: " " }, NOW)).rejects.toThrow("empty"); + + await logCheckIn(prisma, userId, { kind: "habit", habitId: daily.id, done: true }, NOW); + await logCheckIn(prisma, userId, { kind: "daily", mood: 5 }, NOW); + // Unchecking a weekly habit clears it for the whole week. + await logCheckIn(prisma, userId, { kind: "habit", habitId: weekly.id, done: false }, NOW); + today = await loadCoachToday(prisma, userId, NOW); + expect(today.brief?.text).toBe("Rest day."); + expect(today.checkIn).toMatchObject({ kind: "daily", mood: 5 }); + expect(today.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([ + { doneToday: true, streak: 4 }, + { doneToday: false, streak: 0 }, + ]); + + const summary = await loadCoachSummary(prisma, userId, NOW); + expect(summary).toMatchObject({ date: "2026-10-04", timeZone: "Europe/Vienna" }); + expect(summary.habits).toEqual(today.habits); + expect(summary.goals).toEqual(today.goals); + }); + + it("uses UTC dates when the user has no time zone", async () => { + const { userId } = await newUser(null); + expect(await loadCoachToday(prisma, userId, NOW)).toMatchObject({ + date: "2026-10-03", + timeZone: "UTC", + goals: [], + habits: [], + }); + }); +}); diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..ffe58e9 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -109,6 +109,7 @@ async function main() { "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", + "packages/db/src/coach.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts", "packages/adapters/src/wakeup.postgres.test.ts", @@ -117,6 +118,7 @@ async function main() { "packages/adapters/src/cloud-agent.postgres.test.ts", "packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/profile-consolidation.postgres.test.ts", + "packages/adapters/src/coach-rhythm.postgres.test.ts", ]; // Each app reconciles all durable work in its database, including intentionally // unfinished fixture runs. Clone the pristine migrated schema so one suite From 74503ad663019788a4270a7d7de0516c6b058829 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:16:27 +0000 Subject: [PATCH 05/38] refactor(contracts): share one clock-time schema The coach rhythm and the quiet hours both parse "HH:MM" wall-clock times and each defined its own ClockTimeSchema, which the contracts index cannot export twice. Both now import it from one module. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/contracts/src/clock-time.ts | 19 +++++++++++++++++++ packages/contracts/src/coach.ts | 2 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/notifications.ts | 19 +------------------ 4 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 packages/contracts/src/clock-time.ts diff --git a/packages/contracts/src/clock-time.ts b/packages/contracts/src/clock-time.ts new file mode 100644 index 0000000..e8d1464 --- /dev/null +++ b/packages/contracts/src/clock-time.ts @@ -0,0 +1,19 @@ +import * as z from "zod"; + +const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** A 24-hour wall-clock time, e.g. "22:30". */ +export const ClockTimeSchema = z.string().regex(CLOCK_TIME, "Use HH:MM"); + +/** Minutes after midnight for a "HH:MM" time. */ +export function clockTimeToMinutes(value: string): number { + const match = CLOCK_TIME.exec(value); + if (!match) throw new Error("Use HH:MM"); + return Number(match[1]) * 60 + Number(match[2]); +} + +/** "HH:MM" for minutes after midnight. */ +export function minutesToClockTime(minutes: number): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; +} diff --git a/packages/contracts/src/coach.ts b/packages/contracts/src/coach.ts index 011944e..17f777e 100644 --- a/packages/contracts/src/coach.ts +++ b/packages/contracts/src/coach.ts @@ -1,4 +1,5 @@ import * as z from "zod"; +import { ClockTimeSchema } from "./clock-time.js"; import { Id } from "./ids.js"; export const COACH_TITLE_MAX_LENGTH = 200; @@ -38,7 +39,6 @@ export const LocalDateSchema = z .refine(isCalendarDate, "Invalid date"); /** A 24-hour wall-clock time in the user's time zone, e.g. "07:30". */ -export const ClockTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM"); /** When the main bot checks in with the user; null turns a check-in off. */ export const CoachRhythmSchema = z.object({ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 034dcd5..197dfd5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./ai-consent.js"; export * from "./attachments.js"; export * from "./bot-secrets.js"; +export * from "./clock-time.js"; export * from "./coach.js"; export * from "./desktop.js"; export * from "./domain.js"; diff --git a/packages/contracts/src/notifications.ts b/packages/contracts/src/notifications.ts index 5cb6287..5b09a13 100644 --- a/packages/contracts/src/notifications.ts +++ b/packages/contracts/src/notifications.ts @@ -1,26 +1,9 @@ import * as z from "zod"; +import { ClockTimeSchema } from "./clock-time.js"; /** How many nudges the main agent may send per day in the user's time zone. */ export const NUDGES_PER_DAY = 3; -const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/; - -/** A 24-hour wall-clock time, e.g. "22:30". */ -export const ClockTimeSchema = z.string().regex(CLOCK_TIME, "Use HH:MM"); - -/** Minutes after midnight for a "HH:MM" time. */ -export function clockTimeToMinutes(value: string): number { - const match = CLOCK_TIME.exec(value); - if (!match) throw new Error("Use HH:MM"); - return Number(match[1]) * 60 + Number(match[2]); -} - -/** "HH:MM" for minutes after midnight. */ -export function minutesToClockTime(minutes: number): string { - const pad = (value: number) => String(value).padStart(2, "0"); - return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; -} - /** A daily range in the user's profile time zone; it may cross midnight ("22:00"–"07:00"). */ export const QuietHoursSchema = z .object({ start: ClockTimeSchema, end: ClockTimeSchema }) From f4e02eb2485f99f841757672b2f74ba8efadeda0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:17:06 +0000 Subject: [PATCH 06/38] feat(onboarding): ask the onboarding questions in the main conversation The wizard asked for the account name again and a "Tell us about yourself" form, and the focus answer in the main conversation was never saved. The main bot now gets to know the user in the main conversation instead, one card after each answer. - UserOnboarding keeps the pending step, the focus and when onboarding finished. - onboarding-intake.ts declares the steps (focus, goal, rhythm, quiet hours, notifications) as cards plus what their answers change; onboarding.ts commits an answer, the focus app suggestions and the next card or the closing line in one transaction. - The choice block gains a step, an optional text input and the typed answer; choose takes the step, the text and whether the device can still turn push on. - The focus answer becomes a goals fact; the goal, rhythm and quiet-hours effects and the push seam are placeholders until the coach and notification modules are merged. - The wizard drops the preferences step and fills time zone, locale and language from the browser; the main bot's prompt notes open onboarding cards; agent history shows a card's question and answer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/onboarding-intake.ts | 318 +++++++++++++ apps/api/src/onboarding.ts | 443 ++++++++++-------- apps/api/src/router.ts | 2 +- apps/web/src/lib/onboarding-profile.test.ts | 45 +- apps/web/src/lib/onboarding-profile.ts | 64 +-- apps/web/src/lib/onboarding-push.ts | 14 + apps/web/src/pages/Onboarding.tsx | 224 +-------- apps/web/src/pages/shell/message-cards.tsx | 191 ++++++-- packages/adapters/src/executor-prompt.test.ts | 1 + packages/adapters/src/executor-prompt.ts | 7 + packages/adapters/src/executor.ts | 6 + packages/contracts/src/events.ts | 21 + packages/contracts/src/rpc.ts | 28 +- packages/core/src/attachments.test.ts | 24 + packages/core/src/attachments.ts | 6 + .../migration.sql | 17 + packages/db/prisma/schema.prisma | 16 + packages/db/src/main-agent.ts | 8 +- 18 files changed, 876 insertions(+), 559 deletions(-) create mode 100644 apps/api/src/onboarding-intake.ts create mode 100644 apps/web/src/lib/onboarding-push.ts create mode 100644 packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts new file mode 100644 index 0000000..036126a --- /dev/null +++ b/apps/api/src/onboarding-intake.ts @@ -0,0 +1,318 @@ +import type { MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; +import { knownTimeZone, nextCronDateAcross } from "@rakazo/core"; +import { type Prisma, type PrismaClient, updateUserProfile } from "@rakazo/db"; + +/** + * The questions the main bot asks a new user in the main conversation, one card at a time. + * Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order + * the user sees. Everything here is deterministic, so no model tokens are spent. + */ + +export type ChoiceBlock = Extract; +type ChoiceOption = ChoiceBlock["options"][number]; + +/** When the main bot checks in; the same shape the coach rhythm stores. */ +export interface CheckInRhythm { + morning: string | null; + evening: string | null; + weekly: { day: number; time: string } | null; +} + +export interface QuietHours { + start: string; + end: string; +} + +/** A validated answer: an option of the card, or the user's own words. */ +export type IntakeAnswer = + | { option: ChoiceOption; text?: undefined } + | { option?: undefined; text: string }; + +/** What a card may adapt to: times are written in the user's locale. */ +export interface IntakeContext { + locale: string; + timeZone: string; + now: Date; +} + +export interface IntakeEffects { + prisma: PrismaClient; + tx: Prisma.TransactionClient; + userId: string; +} + +interface IntakeStep { + id: OnboardingStep; + /** Asked only when this holds; otherwise onboarding moves past the step. */ + asks?: (device: { canEnablePush: boolean }) => boolean; + card: (context: IntakeContext) => ChoiceBlock; + /** Records a validated answer. Runs while the main conversation is locked. */ + save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise; +} + +export interface FocusOption { + id: string; + letter: string; + label: string; + summary: string; + apps: string[]; + /** Stored as a `goals` fact, so it stays a plain English statement. */ + fact: string; +} + +export const FOCUS_OPTIONS: FocusOption[] = [ + { + id: "day", + letter: "A", + label: "Day-to-day work", + summary: "Slack, calendar, and email", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants help with day-to-day work first", + }, + { + id: "inbox", + letter: "B", + label: "Inbox & email", + summary: "email and calendar", + apps: ["gmail", "googlecalendar", "slack"], + fact: "Wants help with inbox and email first", + }, + { + id: "research", + letter: "C", + label: "Research & writing", + summary: "the web, notes, and docs", + apps: ["hackernews", "notion", "googledocs"], + fact: "Wants help with research and writing first", + }, + { + id: "crew", + letter: "D", + label: "Specialist crew", + summary: "a coordinated team of focused Oos", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants a coordinated crew of specialist bots", + }, +]; + +const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" }; + +/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */ +export function focusCard(step?: "focus"): ChoiceBlock { + return { + kind: "choice", + question: "What do you want me on first?", + options: [ + ...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })), + ...(step ? [SKIP] : []), + ], + ...(step ? { step } : {}), + }; +} + +const RHYTHMS: Record = { + morning: { morning: "08:00", evening: null, weekly: null }, + morning_evening: { morning: "08:00", evening: "18:00", weekly: null }, + weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } }, + none: { morning: null, evening: null, weekly: null }, +}; + +const QUIET_HOURS: Record = { + "22-07": { start: "22:00", end: "07:00" }, + "23-08": { start: "23:00", end: "08:00" }, +}; + +// Coach and notification effects. They land with the coach (goals, rhythm) and notification +// settings modules; until then the answer stays on its card. +async function saveGoal(_effects: IntakeEffects, _title: string): Promise {} +async function saveRhythm(_effects: IntakeEffects, _rhythm: CheckInRhythm): Promise {} +async function saveQuietHours(_effects: IntakeEffects, _quietHours: QuietHours): Promise {} + +export const INTAKE_STEPS: IntakeStep[] = [ + { + id: "focus", + card: () => focusCard("focus"), + save: async ({ prisma, tx, userId }, answer) => { + const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id); + if (!option) return; + await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } }); + await updateUserProfile(prisma, userId, { + addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }], + }); + }, + }, + { + id: "goal", + card: () => ({ + kind: "choice", + step: "goal", + question: "What’s one thing you want to get done in the next few weeks?", + textInput: { placeholder: "Your goal" }, + options: [SKIP], + }), + save: async (effects, answer) => { + if (answer.text) await saveGoal(effects, answer.text); + }, + }, + { + id: "rhythm", + card: () => ({ + kind: "choice", + step: "rhythm", + question: "When should I check in with you?", + options: [ + { id: "morning", letter: "A", label: "Every morning" }, + { id: "morning_evening", letter: "B", label: "Morning and evening" }, + { id: "weekly", letter: "C", label: "Once a week" }, + { id: "none", letter: "", label: "Not now" }, + ], + }), + save: async (effects, answer) => { + const rhythm = answer.option && RHYTHMS[answer.option.id]; + if (rhythm) await saveRhythm(effects, rhythm); + }, + }, + { + id: "quiet_hours", + card: (context) => ({ + kind: "choice", + step: "quiet_hours", + question: "When should I stay quiet?", + options: [ + ...Object.entries(QUIET_HOURS).map(([id, range], index) => ({ + id, + letter: String.fromCharCode(65 + index), + label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`, + })), + SKIP, + ], + }), + save: async (effects, answer) => { + const quietHours = answer.option && QUIET_HOURS[answer.option.id]; + if (quietHours) await saveQuietHours(effects, quietHours); + }, + }, + { + // The browser subscribes when the user taps "Turn on"; the answer only moves on. + id: "notifications", + asks: ({ canEnablePush }) => canEnablePush, + card: () => ({ + kind: "choice", + step: "notifications", + question: "Can I send you notifications?", + options: [ + { id: "on", letter: "A", label: "Turn on" }, + { id: "later", letter: "", label: "Not now" }, + ], + }), + save: async () => {}, + }, +]; + +export function intakeStep(id: OnboardingStep): IntakeStep { + const step = INTAKE_STEPS.find((entry) => entry.id === id); + if (!step) throw new Error(`Unknown onboarding step: ${id}`); + return step; +} + +/** The step after `id` that applies to this device, or null when onboarding is done. */ +export function nextIntakeStep( + id: OnboardingStep, + device: { canEnablePush: boolean }, +): IntakeStep | null { + const index = INTAKE_STEPS.findIndex((entry) => entry.id === id); + return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null; +} + +/** Checks an answer against the card it answers; null when the card does not offer it. */ +export function readIntakeAnswer( + card: ChoiceBlock, + optionId: string, + text: string | undefined, +): IntakeAnswer | null { + if (optionId === CHOICE_TEXT_ANSWER_ID) { + const words = text?.trim(); + return card.textInput && words ? { text: words } : null; + } + const option = card.options.find((entry) => entry.id === optionId); + return option ? { option } : null; +} + +/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */ +export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined { + return card.options.find((option) => !option.letter); +} + +export function rhythmForAnswer(answerId: string | undefined): CheckInRhythm | null { + return (answerId && RHYTHMS[answerId]) || null; +} + +/** Card times and the closing line follow the user's profile, falling back to English and UTC. */ +export function intakeContext( + profile: { locale: string | null; timeZone: string | null } | null, + now: Date, +): IntakeContext { + let locale = "en"; + try { + if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en"; + } catch { + // Not a language tag; keep English. + } + return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now }; +} + +function formatClock(time: string, locale: string): string { + const [hour = 0, minute = 0] = time.split(":").map(Number); + return new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + }).format(new Date(Date.UTC(2000, 0, 1, hour, minute))); +} + +function rhythmCrons(rhythm: CheckInRhythm): string[] { + const cron = (time: string, day = "*") => { + const [hour, minute] = time.split(":").map(Number); + return `${minute} ${hour} * * ${day}`; + }; + return [ + ...(rhythm.morning ? [cron(rhythm.morning)] : []), + ...(rhythm.evening ? [cron(rhythm.evening)] : []), + ...(rhythm.weekly ? [cron(rhythm.weekly.time, String(rhythm.weekly.day))] : []), + ]; +} + +function localDay(date: Date, timeZone: string): number { + const [year, month, day] = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .format(date) + .split("-") + .map(Number); + return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000; +} + +/** One line that says what happens next: the first check-in, when there is one. */ +export function closingLine(rhythm: CheckInRhythm | null, context: IntakeContext): string { + const next = rhythm + ? nextCronDateAcross(rhythmCrons(rhythm), context.now, context.timeZone) + : null; + if (!next) return "All set. I’m here whenever you need me."; + const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone); + const day = + days === 0 + ? "today" + : days === 1 + ? "tomorrow" + : `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`; + const time = new Intl.DateTimeFormat(context.locale, { + hour: "numeric", + minute: "2-digit", + timeZone: context.timeZone, + }).format(next); + return `All set. I’ll check in ${day} at ${time}.`; +} diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index b0bf565..8163991 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -1,5 +1,7 @@ +import { ORPCError } from "@orpc/server"; import type { ConnectorRegistry } from "@rakazo/adapters"; -import type { Actor, MessageBlock } from "@rakazo/contracts"; +import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { featuredConnectorProvidersMatch } from "@rakazo/core"; import { appendEventInTransaction, @@ -10,12 +12,27 @@ import { type PrismaClient, type ThreadEvents, } from "@rakazo/db"; +import { + type ChoiceBlock, + closingLine, + FOCUS_OPTIONS, + type FocusOption, + focusCard, + type IntakeAnswer, + type IntakeContext, + intakeContext, + intakeStep, + nextIntakeStep, + readIntakeAnswer, + rhythmForAnswer, + secondaryOption, +} from "./onboarding-intake.js"; /** - * First-run conversational onboarding, seeded deterministically into the bot's - * thread: greeting, a focus choice, and available app cards the user authorizes - * inline. Focus must not rename the bot. No model tokens are spent. The main bot - * talks in the main conversation, where onboarding lands. + * Conversational onboarding, posted deterministically into a bot's thread; no model tokens are + * spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main + * conversation, one card after each answer. Any other bot gets one focus card that suggests + * apps the user authorizes inline. Focus never renames a bot. */ type OnboardingDeps = { @@ -24,44 +41,7 @@ type OnboardingDeps = { connectors: ConnectorRegistry; }; -type FocusOption = { - id: string; - letter: string; - label: string; - summary: string; - apps: string[]; -}; - -const FOCUS_OPTIONS: FocusOption[] = [ - { - id: "day", - letter: "A", - label: "Day-to-day work", - summary: "Slack, calendar, and email", - apps: ["slack", "gmail", "googlecalendar"], - }, - { - id: "inbox", - letter: "B", - label: "Inbox & email", - summary: "email and calendar", - apps: ["gmail", "googlecalendar", "slack"], - }, - { - id: "research", - letter: "C", - label: "Research & writing", - summary: "the web, notes, and docs", - apps: ["hackernews", "notion", "googledocs"], - }, - { - id: "crew", - letter: "D", - label: "Specialist crew", - summary: "a coordinated team of focused Oos", - apps: ["slack", "gmail", "googlecalendar"], - }, -]; +type Target = { spaceId: string; botId: string; threadId: string }; const APP_DESCRIPTIONS: Record = { slack: "Search, read, and send messages.", @@ -85,74 +65,98 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin return { bot, thread: bot.thread }; } -async function post( - deps: OnboardingDeps, - target: { spaceId: string; botId: string; threadId: string }, +async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise { + await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR UPDATE`; +} + +async function postInTransaction( + tx: Prisma.TransactionClient, + target: Target, blocks: MessageBlock[], -): Promise { - const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - const message = await createThreadMessageInTransaction(tx, { - threadId: target.threadId, - role: "bot", - blocks, - botId: target.botId, - }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.created", - payload: { messageId: message.id, role: "bot", blocks }, - }); - return { message, event }; +): Promise<{ seq: number }> { + const message = await createThreadMessageInTransaction(tx, { + threadId: target.threadId, + role: "bot", + blocks, + botId: target.botId, + }); + return appendEventInTransaction(tx, { + spaceId: target.spaceId, + threadId: target.threadId, + botId: target.botId, + type: "thread.message.created", + payload: { messageId: message.id, role: "bot", blocks }, + }); +} + +async function updateBlocksInTransaction( + tx: Prisma.TransactionClient, + target: Target, + messageId: string, + blocks: MessageBlock[], +): Promise<{ seq: number }> { + await tx.message.update({ where: { id: messageId }, data: { blocks } }); + return appendEventInTransaction(tx, { + spaceId: target.spaceId, + threadId: target.threadId, + botId: target.botId, + type: "thread.message.updated", + payload: { messageId, role: "bot", blocks }, }); - await deps.events.notify(target.threadId, committed.event.seq); - return committed.message.id; } async function updateBlocks( deps: OnboardingDeps, - target: { spaceId: string; botId: string; threadId: string }, + target: Target, messageId: string, blocks: MessageBlock[], ): Promise { - const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.message.update({ where: { id: messageId }, data: { blocks } }); - return appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId, role: "bot", blocks }, - }); - }); + const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) => + updateBlocksInTransaction(tx, target, messageId, blocks), + ); await deps.events.notify(target.threadId, event.seq); } /** Sentinel answerId for a focus card the user dismissed without choosing. */ export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed"; +/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */ export async function startOnboarding( deps: OnboardingDeps, actor: Actor, botId: string, ): Promise { - const { thread } = await requireBotThread(deps, actor, botId); - const existing = await deps.prisma.message.count({ where: { threadId: thread.id } }); - if (existing > 0) return; - // Fresh chats start empty (same as web). The focus card is posted later via - // promptFocus so non-first bots can wait ~10s for free typing, or skip if the - // user already engaged. + await requireBotThread(deps, actor, botId); } -function messageHasChoice(blocks: MessageBlock[]): boolean { - return blocks.some((block) => block.kind === "choice"); +function isChoice(block: MessageBlock): block is ChoiceBlock { + return block.kind === "choice"; } -function messageHasPendingChoice(blocks: MessageBlock[]): boolean { - return blocks.some((block) => block.kind === "choice" && !block.answerId); +function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined { + return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId); } +/** The newest card in the thread that still waits for an answer. */ +async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) { + const messages = await client.message.findMany({ + where: { threadId, role: "bot" }, + select: { id: true, blocks: true }, + orderBy: { seq: "desc" }, + take: 50, + }); + for (const message of messages) { + const blocks = message.blocks as MessageBlock[]; + const card = pendingChoice(blocks); + if (card) return { messageId: message.id, blocks, card }; + } + return null; +} + +/** + * Posts the first card once. The main bot starts the onboarding questions, one set per user; + * any other bot posts its focus card only while its thread is still idle. + */ export async function promptFocus( deps: OnboardingDeps, actor: Actor, @@ -160,44 +164,31 @@ export async function promptFocus( ): Promise { const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; - const blocks: MessageBlock[] = [ - { - kind: "choice", - question: "What do you want me on first?", - options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })), - }, - ]; - // Check + insert + event in one transaction so concurrent promptFocus calls - // cannot duplicate cards, and a concurrent user send cannot publish first. + const main = bot.kind === "main"; + // Check + insert + event in one transaction so concurrent callers cannot duplicate cards, + // and a concurrent user send cannot publish first. const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - // Serialize concurrent promptFocus callers on this thread before the gate check. - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; + await lockThread(tx, thread.id); + if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) { + return null; + } const recent = await tx.message.findMany({ where: { threadId: thread.id }, select: { role: true, blocks: true }, - orderBy: { createdAt: "asc" }, }); - if (recent.some((message) => message.role === "user")) return null; - if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null; - const message = await createThreadMessageInTransaction(tx, { - threadId: target.threadId, - role: "bot", - blocks, - botId: target.botId, - }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.created", - payload: { messageId: message.id, role: "bot", blocks }, - }); - return { message, event }; + if (!main && recent.some((message) => message.role === "user")) return null; + if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null; + if (!main) return postInTransaction(tx, target, [focusCard()]); + const first = intakeStep("focus"); + await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } }); + return postInTransaction(tx, target, [ + first.card(await loadIntakeContext(tx, actor.userId, new Date())), + ]); }); - if (!committed) return; - await deps.events.notify(target.threadId, committed.event.seq); + if (committed) await deps.events.notify(target.threadId, committed.seq); } +/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */ export async function dismissFocus( deps: OnboardingDeps, actor: Actor, @@ -205,81 +196,143 @@ export async function dismissFocus( ): Promise { const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; - const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; - const recent = await tx.message.findMany({ - where: { threadId: thread.id }, - orderBy: { createdAt: "asc" }, - }); - const pending = recent.find((message) => - messageHasPendingChoice(message.blocks as MessageBlock[]), + const pending = await findPendingCard(deps.prisma, thread.id); + const skip = pending?.card.step ? secondaryOption(pending.card) : undefined; + if (pending?.card.step && skip) { + await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id }); + return; + } + const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await lockThread(tx, thread.id); + const current = await findPendingCard(tx, thread.id); + if (!current || current.card.step) return null; + const blocks = current.blocks.map((block) => + block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block, ); - if (!pending) return null; - const blocks = (pending.blocks as MessageBlock[]).map((block) => - block.kind === "choice" && !block.answerId - ? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID } - : block, - ); - await tx.message.update({ where: { id: pending.id }, data: { blocks } }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId: pending.id, role: "bot", blocks }, - }); - return { messageId: pending.id, blocks, event }; + return updateBlocksInTransaction(tx, target, current.messageId, blocks); }); - if (!claimed) return; - await deps.events.notify(target.threadId, claimed.event.seq); + if (event) await deps.events.notify(target.threadId, event.seq); } +export interface ChooseInput { + step?: OnboardingStep; + optionId: string; + text?: string; + canEnablePush?: boolean; +} + +/** + * Answers the pending card. The answer is checked against that card, a stale answer (another + * card is pending by now) is ignored, and everything that follows is committed together with + * it: the focus card's app suggestions, then the next onboarding question or the closing line. + */ export async function chooseFocus( deps: OnboardingDeps, actor: Actor, botId: string, - optionId: string, + input: ChooseInput, ): Promise { - const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId); - if (!option) throw new IsolationError(); const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; + const pending = await findPendingCard(deps.prisma, thread.id); + if (!pending || pending.card.step !== input.step) return; + const answer = readIntakeAnswer(pending.card, input.optionId, input.text); + if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" }); + const focus = + !pending.card.step || pending.card.step === "focus" + ? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id) + : undefined; + if (!pending.card.step && !focus) { + throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" }); + } + // Fetched before the transaction: the connector catalog can take seconds. + const followUp = focus + ? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step)) + : []; - const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; - const recent = await tx.message.findMany({ - where: { threadId: thread.id }, - orderBy: { createdAt: "asc" }, - }); - const pending = recent.find((message) => - messageHasPendingChoice(message.blocks as MessageBlock[]), - ); - if (!pending) return null; - const blocks = (pending.blocks as MessageBlock[]).map((block) => - block.kind === "choice" ? { ...block, answerId: option.id } : block, - ); - await tx.message.update({ where: { id: pending.id }, data: { blocks } }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId: pending.id, role: "bot", blocks }, - }); - return { messageId: pending.id, blocks, event }; + const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await lockThread(tx, thread.id); + const current = await findPendingCard(tx, thread.id); + if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null; + const onboarding = current.card.step + ? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }) + : null; + if (current.card.step) { + if (onboarding?.step !== current.card.step) return null; + await intakeStep(current.card.step).save( + { prisma: deps.prisma, tx, userId: actor.userId }, + answer, + ); + } + const answered = answeredCard(current.card, answer); + const blocks = current.blocks.map((block) => (block === current.card ? answered : block)); + let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks); + for (const message of followUp) last = await postInTransaction(tx, target, message); + if (current.card.step) { + last = await askNext(tx, target, actor.userId, current.card.step, input.canEnablePush); + } + return last; }); - if (!claimed) return; - await deps.events.notify(target.threadId, claimed.event.seq); + if (event) await deps.events.notify(target.threadId, event.seq); +} - // Keep the name and title the user chose when creating the bot; the focus - // step only suggests apps, it must not rename the bot. - await post(deps, target, [ - { - kind: "text", - text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`, +function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock { + return answer.option + ? { ...card, answerId: answer.option.id } + : { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text }; +} + +async function loadIntakeContext( + tx: Prisma.TransactionClient, + userId: string, + now: Date, +): Promise { + const profile = await tx.userProfile.findUnique({ + where: { userId }, + select: { locale: true, timeZone: true }, + }); + return intakeContext(profile, now); +} + +/** Posts the next onboarding question, or the closing line once every step is answered. */ +async function askNext( + tx: Prisma.TransactionClient, + target: Target, + userId: string, + answeredStep: OnboardingStep, + canEnablePush = false, +): Promise<{ seq: number }> { + const now = new Date(); + const context = await loadIntakeContext(tx, userId, now); + const next = nextIntakeStep(answeredStep, { canEnablePush }); + if (next) { + await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } }); + return postInTransaction(tx, target, [next.card(context)]); + } + const rhythmCard = await tx.message.findFirst({ + where: { + threadId: target.threadId, + role: "bot", + blocks: { array_contains: [{ kind: "choice", step: "rhythm" }] }, }, - ]); + orderBy: { seq: "desc" }, + select: { blocks: true }, + }); + const rhythm = rhythmForAnswer( + (rhythmCard?.blocks as MessageBlock[] | undefined)?.find(isChoice)?.answerId, + ); + await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } }); + return postInTransaction(tx, target, [{ kind: "text", text: closingLine(rhythm, context) }]); +} +/** The messages after a focus answer: a short acknowledgement and the apps worth connecting. */ +async function focusFollowUp( + deps: OnboardingDeps, + actor: Actor, + botId: string, + option: FocusOption, + onboarding: boolean, +): Promise { const providers = deps.connectors.managedProviders(); const catalog = ( await Promise.all( @@ -290,14 +343,14 @@ export async function chooseFocus( traceId: "onboarding.choose", spaceId: actor.spaceId, userId: actor.userId, - botId: bot.id, + botId, signal: AbortSignal.timeout(15_000), }) .catch(() => []), ), ) ).flat(); - const cards: MessageBlock[] = option.apps.flatMap((slug) => { + const cards = option.apps.flatMap((slug) => { const entry = catalog.find( (item) => item.slug.toLowerCase() === slug.toLowerCase() || @@ -317,26 +370,22 @@ export async function chooseFocus( ]; }); if (!cards.length) { - await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]); - return; + // In onboarding the next question follows; another bot asks what to start with. + const text = onboarding ? "Got it." : "Got it. What would you like to work on first?"; + return [[{ kind: "text", text }]]; } - const cardNames = cards - .map((card) => (card.kind === "app_connect" ? card.name : "")) - .filter(Boolean); - const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`; - await post(deps, target, [ - { - kind: "text", - text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`, - }, - ]); - await post(deps, target, cards); - await post(deps, target, [ - { - kind: "text", - text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`, - }, - ]); + const names = cards.map((card) => card.name); + const named = + names.length > 1 ? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}` : names[0]; + return [ + [ + { + kind: "text", + text: `Got it. ${named} ${names.length > 1 ? "are" : "is"} a good place to start.`, + }, + ], + cards, + ]; } export async function markAppConnected( @@ -376,7 +425,3 @@ export async function markAppConnected( await updateBlocks(deps, target, message.id, next); } } - -function capitalize(value: string): string { - return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value; -} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..a5b59dc 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -3417,7 +3417,7 @@ export function createRouter(deps: RouterDeps) { return { ok: true as const }; }), choose: authed.onboarding.choose.handler(async ({ context, input }) => { - await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId); + await chooseFocus(onboardingDeps, context.actor, input.botId, input); return { ok: true as const }; }), dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => { diff --git a/apps/web/src/lib/onboarding-profile.test.ts b/apps/web/src/lib/onboarding-profile.test.ts index d9ce98d..ba07e54 100644 --- a/apps/web/src/lib/onboarding-profile.test.ts +++ b/apps/web/src/lib/onboarding-profile.test.ts @@ -1,56 +1,25 @@ import { describe, expect, it } from "vitest"; import { onboardingProfileUpdate } from "./onboarding-profile"; -const blank = { name: "", timezone: "", hobbies: [], workStyle: "", communication: "" }; - describe("onboardingProfileUpdate", () => { - it("turns answers into profile fields and onboarding facts", () => { + it("fills the time zone, locale and chosen language from the browser", () => { expect( onboardingProfileUpdate({ - answers: { - name: " Alice ", - timezone: "Europe/Vienna", - hobbies: ["music", "tech", "none"], - workStyle: "deep-dive", - communication: "direct", - }, - accountName: "Alice Example", - browserTimeZone: "UTC", + browserTimeZone: "Europe/Vienna", locale: "de-AT", language: "de", }), - ).toEqual({ - name: "Alice", - timeZone: "Europe/Vienna", - locale: "de-AT", - language: "de", - communicationStyle: "Direct and concise", - addFacts: [ - { category: "preferences", value: "Interested in music", source: "onboarding" }, - { category: "preferences", value: "Interested in tech and gadgets", source: "onboarding" }, - { category: "habits", value: "Prefers focused deep dives", source: "onboarding" }, - ], - }); + ).toEqual({ timeZone: "Europe/Vienna", locale: "de-AT", language: "de" }); }); - it("keeps the account name and falls back to the browser time zone", () => { + it("leaves out a language the user did not pick and malformed values", () => { expect( onboardingProfileUpdate({ - answers: { ...blank, name: "Alice", timezone: "Not/AZone" }, - accountName: "Alice", - browserTimeZone: "America/Toronto", - }), - ).toEqual({ timeZone: "America/Toronto" }); - }); - - it("leaves out a language the user did not pick and malformed tags", () => { - expect( - onboardingProfileUpdate({ - answers: blank, - browserTimeZone: "UTC", + browserTimeZone: "Not/AZone", locale: "not a tag", language: null, }), - ).toEqual({ timeZone: "UTC" }); + ).toEqual({}); + expect(onboardingProfileUpdate({ browserTimeZone: "UTC" })).toEqual({ timeZone: "UTC" }); }); }); diff --git a/apps/web/src/lib/onboarding-profile.ts b/apps/web/src/lib/onboarding-profile.ts index 78d1d58..1d7d1de 100644 --- a/apps/web/src/lib/onboarding-profile.ts +++ b/apps/web/src/lib/onboarding-profile.ts @@ -1,73 +1,19 @@ -import type { UserFactCategory } from "@rakazo/contracts"; import { isLanguageTag, isTimeZone } from "@rakazo/contracts"; -export interface OnboardingAnswers { - name: string; - timezone: string; - hobbies: string[]; - workStyle: string; - communication: string; -} - -// Stored as data for the user's bots, so these stay plain English statements. -const HOBBY_FACTS: Record = { - coding: "Interested in coding", - design: "Interested in design", - writing: "Interested in writing", - music: "Interested in music", - gaming: "Interested in gaming", - reading: "Interested in reading", - sports: "Interested in sports", - cooking: "Interested in cooking", - tech: "Interested in tech and gadgets", -}; - -const WORK_STYLE_FACTS: Record = { - structured: "Works in a structured, planned way", - flexible: "Works flexibly and spontaneously", - "deep-dive": "Prefers focused deep dives", - quick: "Works quickly and pragmatically", -}; - -const COMMUNICATION_STYLES: Record = { - direct: "Direct and concise", - detailed: "Detailed and explanatory", - balanced: "Balanced", - casual: "Casual and friendly", -}; - -/** Turns onboarding answers into one profile update; empty or unknown answers are left out. */ +/** + * The profile fields onboarding fills in without asking: the browser's time zone and locale, + * and the language the user picked for their Oos. The name comes from the account. + */ export function onboardingProfileUpdate(input: { - answers: OnboardingAnswers; - /** The account name, which only changes when the user typed a different one. */ - accountName?: string; - /** Used when the typed time zone is empty or unknown. */ browserTimeZone: string; /** Browser locale, e.g. "de-AT". */ locale?: string; /** Language the user picked for their Oos, when they picked one. */ language?: string | null; }) { - const { answers } = input; - const name = answers.name.trim(); - const typedZone = answers.timezone.trim(); - const workStyle = WORK_STYLE_FACTS[answers.workStyle]; - const facts: Array<{ category: UserFactCategory; value: string }> = [ - ...answers.hobbies.flatMap((hobby) => { - const value = HOBBY_FACTS[hobby]; - return value ? [{ category: "preferences" as const, value }] : []; - }), - ...(workStyle ? [{ category: "habits" as const, value: workStyle }] : []), - ]; - const communicationStyle = COMMUNICATION_STYLES[answers.communication]; return { - ...(name && name !== input.accountName?.trim() ? { name } : {}), - timeZone: typedZone && isTimeZone(typedZone) ? typedZone : input.browserTimeZone, + ...(isTimeZone(input.browserTimeZone) ? { timeZone: input.browserTimeZone } : {}), ...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}), ...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}), - ...(communicationStyle ? { communicationStyle } : {}), - ...(facts.length > 0 - ? { addFacts: facts.map((fact) => ({ ...fact, source: "onboarding" as const })) } - : {}), }; } diff --git a/apps/web/src/lib/onboarding-push.ts b/apps/web/src/lib/onboarding-push.ts new file mode 100644 index 0000000..bedb187 --- /dev/null +++ b/apps/web/src/lib/onboarding-push.ts @@ -0,0 +1,14 @@ +/** + * Push for the onboarding notifications card. The server offers the card only when the browser + * that answered the card before it could still turn push on, and tapping "Turn on" is the user + * gesture that subscribes before the card is answered. Both wait for the web push client + * (`lib/web-push.ts`); until it lands the card is never offered. + */ +export async function canOfferPush(): Promise { + return false; +} + +/** Subscribes this browser; true when push is on afterwards. */ +export async function turnOnPush(): Promise { + return false; +} diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 0423eaa..d740dfe 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -1,7 +1,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts"; -import { Button, Input } from "@rakazo/ui-web"; -import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { Button } from "@rakazo/ui-web"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { IntegrationSetup } from "../components/integrations/IntegrationSetup"; import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell"; @@ -24,7 +24,6 @@ import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-loo import { onboardingProfileUpdate } from "../lib/onboarding-profile"; import { rpc, selectSpace } from "../lib/rpc"; import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale"; -import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest"; /** Selects the main conversation's space and returns its route. */ @@ -53,57 +52,11 @@ function defaultEmojiConfig(): OnboardingEmojiConfig { }; } -interface UserPreferences { - name: string; - timezone: string; - hobbies: string[]; - workStyle: string; - communication: string; -} - -const HOBBY_OPTIONS = [ - { key: "coding", label: "Coding" }, - { key: "design", label: "Design" }, - { key: "writing", label: "Writing" }, - { key: "music", label: "Music" }, - { key: "gaming", label: "Gaming" }, - { key: "reading", label: "Reading" }, - { key: "sports", label: "Sports" }, - { key: "cooking", label: "Cooking" }, - { key: "tech", label: "Tech & Gadgets" }, - { key: "none", label: Nothing specific }, -]; - -const WORK_STYLE_OPTIONS = [ - { key: "structured", label: Structured & planned }, - { key: "flexible", label: Flexible & spontaneous }, - { key: "deep-dive", label: Deep-dive focused }, - { key: "quick", label: Quick & pragmatic }, -]; - -const COMMUNICATION_OPTIONS = [ - { key: "direct", label: Direct & concise }, - { key: "detailed", label: Detailed & explanatory }, - { key: "balanced", label: Balanced }, - { key: "casual", label: Casual & friendly }, -]; - -function defaultPreferences(): UserPreferences { - return { - name: "", - timezone: localTimezone(), - hobbies: [], - workStyle: "", - communication: "", - }; -} - export function OnboardingPage() { const { t } = useLingui(); const navigate = useNavigate(); - const fieldId = useId(); const [step, setStep] = useState< - "loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot" + "loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot" >("emoji"); const [selectedVoiceId, setSelectedVoiceId] = useState(""); const [voiceSkipped, setVoiceSkipped] = useState(false); @@ -114,11 +67,9 @@ export function OnboardingPage() { const [bootstrapMe, setBootstrapMe] = useState> | null>(null); const [profilePersisted, setProfilePersisted] = useState(false); const [crewReady, setCrewReady] = useState(false); - const [_helpVibe, setHelpVibe] = useState(null); const [scanBotId, setScanBotId] = useState(null); const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); - const [prefs, setPrefs] = useState(defaultPreferences()); const [currentLocale, setCurrentLocale] = useState(() => { try { return localStorage.getItem("rakazo.uiLocale") ?? "en"; @@ -140,7 +91,6 @@ export function OnboardingPage() { .then(async ([me, integrations]) => { if (cancelled) return; setBootstrapMe(me); - setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); // Get bot ID for scan phase try { const botList = await rpc.bots.list(); @@ -178,17 +128,10 @@ export function OnboardingPage() { }; }, [navigate]); - function toggleHobby(key: string) { - const hobbies = prefs.hobbies.includes(key) - ? prefs.hobbies.filter((h) => h !== key) - : [...prefs.hobbies, key]; - setPrefs({ ...prefs, hobbies }); - } - const discoveryPhase: OnboardingDiscoveryPhase = step === "loading" ? "loading" - : step === "emoji" || step === "preferences" || step === "language" || step === "voice" + : step === "emoji" || step === "language" || step === "voice" ? "emoji" : step === "integrations" ? "integrations" @@ -229,8 +172,6 @@ export function OnboardingPage() { await rpc.profile .update( onboardingProfileUpdate({ - answers: prefs, - accountName: bootstrapMe?.name, browserTimeZone: localTimezone(), locale: typeof navigator !== "undefined" ? navigator.language : undefined, language: languageChoice, @@ -252,22 +193,15 @@ export function OnboardingPage() { const me = await rpc.me().catch(() => null); if (me) setBootstrapMe(me); - const harvested = await persistUserProfileHarvest( - rpc, - collectLocalProfileSignals(me ?? undefined), - ).catch(() => null); - if (harvested) setHelpVibe(harvested); + await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch( + () => null, + ); setProfilePersisted(true); for (const serverId of integrationServers) { await rpc.mcp.assignments.approve({ botId: bot.id, serverId }); } - const started = await rpc.onboarding - .start({ botId: bot.id }) - .then(() => true) - .catch(() => false); - if (started) { - await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); - } + // The main bot asks the rest in the main conversation. + await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); const main = await rpc.spaces .list() .then((navigation) => navigation.main) @@ -300,11 +234,7 @@ export function OnboardingPage() { {error && step !== "bot" ?

{error}

: null} @@ -313,140 +243,10 @@ export function OnboardingPage() { setStep("preferences")} + onContinue={() => setStep("language")} /> ) : null} - {step === "preferences" ? ( -
-

- Tell us about yourself -

-

- A few quick preferences so your Oo can be better tailored to you. -

- -
- - setPrefs({ ...prefs, name: e.target.value })} - placeholder={t`Your name`} - className="mt-2" - /> -
- -
- - setPrefs({ ...prefs, timezone: e.target.value })} - placeholder={t`Auto-detected or enter manually`} - className="mt-2" - /> -
- -
- - Hobbies & interests - -

- Select all that apply -

-
- {HOBBY_OPTIONS.map((hobby) => ( - - ))} -
-
- -
- - Work style - -

- How do you like to work? -

-
- {WORK_STYLE_OPTIONS.map((ws) => ( - - ))} -
-
- -
- - Communication style - -

- How should your Oo talk to you? -

-
- {COMMUNICATION_OPTIONS.map((comm) => ( - - ))} -
-
- -
- - -
-
- ) : null} - {step === "language" ? (

@@ -477,7 +277,7 @@ export function OnboardingPage() { ))}

-
) : null} -
- {block.options - .filter((option) => !block.answerId || option.id === block.answerId) - .map((option) => ( - + ))} +
+ ) : null} + {block.textInput ? ( +
{ + event.preventDefault(); + const words = text.trim(); + if (words) void choose(CHOICE_TEXT_ANSWER_ID, words); + }} > - - {option.letter} - - - {option.label} - - {block.answerId === option.id ? ( - ✓ - ) : null} - - ))} - + + setText(event.target.value)} + placeholder={block.textInput.placeholder} + aria-label={block.question} + maxLength={200} + disabled={pending} + dir="auto" + /> + + + + + + +
+ ) : null} + {waysOut.length > 0 ? ( +
+ {waysOut.map((option) => ( + + ))} +
+ ) : null} + + )} {error ?

{error}

: null} - + ); } +function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) { + return ( + + {letter} + + ); +} + export function AppConnectCard({ botId, block, diff --git a/packages/adapters/src/executor-prompt.test.ts b/packages/adapters/src/executor-prompt.test.ts index 71c3f14..b61504d 100644 --- a/packages/adapters/src/executor-prompt.test.ts +++ b/packages/adapters/src/executor-prompt.test.ts @@ -18,6 +18,7 @@ const user: UserContext = { { category: "work", value: "Owns a bakery", confidence: 1 }, { category: "preferences", value: "Prefers morning meetings", confidence: 0.8 }, ], + onboardingOpen: false, }; describe("formatBotIdentity", () => { diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 479f2dd..06cbb37 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -46,6 +46,13 @@ export function formatMainAgentRole(toolNames: ReadonlySet): string { .join(" "); } +/** + * While the main bot's onboarding cards are still open, it leaves their questions to them, so + * a reply does not ask what the next card asks. + */ +export const ONBOARDING_OPEN_INSTRUCTION = + "The user is still answering onboarding cards in the main conversation: what to focus on, a goal, when to check in, quiet hours and notifications. Do not ask these questions yourself; answer what the user writes."; + /** One line of escaped user text, clipped to `max` characters. */ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string { const text = (value ?? "").replace(/\s+/g, " ").trim(); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 5cb9181..344429e 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -248,6 +248,7 @@ import { formatMainAgentRole, formatSubagentContext, formatUserContext, + ONBOARDING_OPEN_INSTRUCTION, } from "./executor-prompt.js"; import { isProtectedComputerLifecycleCommandReadingScripts, @@ -3723,6 +3724,11 @@ export function createRunExecutor(deps: ExecutorDeps) { bot.kind === "main" ? formatMainAgentRole(exposedToolNames) : undefined, timeInstruction, userBlock, + bot.kind === "main" && + userContext.onboardingOpen && + !(messagingChannelRun || foreignAgentRun) + ? ONBOARDING_OPEN_INSTRUCTION + : undefined, groupContext?.instructions, inboundHandoffInstruction, delegatedTaskInstruction, diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index c15d8d0..98babd6 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -100,6 +100,20 @@ export const DELEGATED_TASK_STATUSES = [ export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES); export type DelegatedTaskStatus = z.infer; +/** Questions the main bot asks a new user, one card at a time, in the main conversation. */ +export const ONBOARDING_STEPS = [ + "focus", + "goal", + "rhythm", + "quiet_hours", + "notifications", +] as const; +export const OnboardingStepSchema = z.enum(ONBOARDING_STEPS); +export type OnboardingStep = z.infer; + +/** `optionId` of a choice answered in the user's own words. */ +export const CHOICE_TEXT_ANSWER_ID = "_text"; + /** A question or approval that pauses its run until the user answers. */ export const AskBlockSchema = z.object({ kind: z.literal("ask"), @@ -134,9 +148,16 @@ export const MessageBlock = z.discriminatedUnion("kind", [ kind: z.literal("choice"), question: z.string(), subtitle: z.string().optional(), + /** An option without a letter is a secondary answer such as "Skip". */ options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })), + /** The onboarding step this card asks; absent on a specialist's focus card. */ + step: OnboardingStepSchema.optional(), + /** Lets the user answer in their own words. */ + textInput: z.object({ placeholder: z.string() }).optional(), /** Set once the user picks an option; renders the picker as answered. */ answerId: z.string().optional(), + /** The user's words when they answered through the text input. */ + answerText: z.string().optional(), }), z.object({ /** Inline app authorization card (Composio-backed): logo, name, one-line diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..4be4f65 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -75,7 +75,7 @@ import { VoiceInfoSchema, VoiceStatusSchema, } from "./domain.js"; -import { DelegatedTaskStatusSchema, ProductEventSchema } from "./events.js"; +import { DelegatedTaskStatusSchema, OnboardingStepSchema, ProductEventSchema } from "./events.js"; import { Id, IsoDate } from "./ids.js"; import { InboxListOutputSchema } from "./inbox.js"; import { @@ -660,15 +660,31 @@ export const appContract = { }, }, onboarding: { - /** Seed the first-run greeting into the bot's thread (focus card is separate). */ + /** Checks that the caller may onboard this bot's thread. Posts nothing. */ start: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })), - /** Post the focus choice card when the thread is still idle. */ + /** + * Posts the first card: for the main bot the first onboarding question, once per user; + * for another bot its focus card while the thread is still idle. + */ promptFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })), - /** Answer the focus choice; posts the app cards. Does not rename the bot. */ + /** + * Answers the pending card and posts what follows it: the focus card's app suggestions, + * then the next onboarding question. A stale answer (another step is pending) is ignored. + */ choose: oc - .input(z.object({ botId: Id, optionId: z.string() })) + .input( + z.object({ + botId: Id, + step: OnboardingStepSchema.optional(), + optionId: z.string(), + /** The answer in the user's words, with `optionId` "_text". */ + text: z.string().trim().min(1).max(200).optional(), + /** This device could still turn on push notifications. */ + canEnablePush: z.boolean().optional(), + }), + ) .output(z.object({ ok: z.literal(true) })), - /** Dismiss the unanswered focus card without choosing an option. */ + /** Dismisses the pending card; an onboarding question counts as skipped. */ dismissFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })), /** Flip an app_connect card to connected after authorization completes. */ appConnected: oc diff --git a/packages/core/src/attachments.test.ts b/packages/core/src/attachments.test.ts index 0a3d661..45653a0 100644 --- a/packages/core/src/attachments.test.ts +++ b/packages/core/src/attachments.test.ts @@ -109,3 +109,27 @@ describe("peer message history", () => { ).toBe("[to Analyst] chart it"); }); }); + +describe("choice card history", () => { + const card = { + kind: "choice" as const, + question: "When should I check in with you?", + options: [ + { id: "morning", letter: "A", label: "Every morning" }, + { id: "none", letter: "", label: "Not now" }, + ], + }; + + it("keeps the question and the answer the user picked or typed", () => { + expect(blocksToAgentHistoryText([card])).toBe("When should I check in with you?"); + expect(blocksToAgentHistoryText([{ ...card, answerId: "morning" }])).toBe( + "When should I check in with you?\n[answered: Every morning]", + ); + expect( + blocksToAgentHistoryText([{ ...card, answerId: "_text", answerText: "Before lunch" }]), + ).toBe("When should I check in with you?\n[answered: Before lunch]"); + expect(blocksToAgentHistoryText([{ ...card, answerId: "_dismissed" }])).toBe( + "When should I check in with you?", + ); + }); +}); diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts index 9afe692..7a3a666 100644 --- a/packages/core/src/attachments.ts +++ b/packages/core/src/attachments.ts @@ -105,6 +105,12 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string { if (block.kind === "handoff") { return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`; } + // A card's question and answer, so a later turn knows what the user already chose. + if (block.kind === "choice") { + const answer = + block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label; + return answer ? `${block.question}\n[answered: ${answer}]` : block.question; + } if ("text" in block && typeof block.text === "string") return block.text; return ""; }) diff --git a/packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql b/packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql new file mode 100644 index 0000000..1ebd459 --- /dev/null +++ b/packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "user_onboarding" ( + "userId" TEXT NOT NULL, + "step" TEXT, + "focus" TEXT, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_onboarding_pkey" PRIMARY KEY ("userId") +); + +-- AddForeignKey +ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The table is new and empty, so its constraint applies immediately. +ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_step_chk" CHECK ("step" IS NULL OR "step" IN ('focus', 'goal', 'rhythm', 'quiet_hours', 'notifications')); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a6dc085..bce92fe 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -46,6 +46,7 @@ model User { botSecrets BotSecret[] agentSecrets AgentSecret[] profile UserProfile? + onboarding UserOnboarding? facts UserFact[] @@unique([email]) @@ -1429,6 +1430,21 @@ model UserProfile { @@map("user_profiles") } +/// Where the user is in the onboarding questions the main bot asks in the main conversation. +model UserOnboarding { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// The step whose card is waiting for an answer; null once onboarding is complete. + step String? + focus String? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // CHECK constraint user_onboarding_step_chk is created in SQL. + @@map("user_onboarding") +} + /// One durable statement about the user, with its origin and consent state. model UserFact { id String @id @default(cuid()) diff --git a/packages/db/src/main-agent.ts b/packages/db/src/main-agent.ts index 6546da4..dd1f606 100644 --- a/packages/db/src/main-agent.ts +++ b/packages/db/src/main-agent.ts @@ -42,6 +42,8 @@ export interface UserContext { communicationStyle: string; summary: string; facts: Array<{ category: UserFactCategory; value: string; confidence: number }>; + /** The main bot is still asking its onboarding questions on cards in the main conversation. */ + onboardingOpen: boolean; } function isUniqueViolation(error: unknown): boolean { @@ -258,7 +260,10 @@ export async function loadUserContext( input: { userId: string; spaceId: string }, ): Promise { const [user, profile, facts] = await Promise.all([ - prisma.user.findUnique({ where: { id: input.userId }, select: { name: true } }), + prisma.user.findUnique({ + where: { id: input.userId }, + select: { name: true, onboarding: { select: { completedAt: true } } }, + }), prisma.userProfile.findUnique({ where: { userId: input.userId } }), prisma.userFact.findMany({ where: { @@ -283,5 +288,6 @@ export async function loadUserContext( value: fact.value, confidence: fact.confidence, })), + onboardingOpen: Boolean(user?.onboarding && !user.onboarding.completedAt), }; } From a094c23b47c6b711a0a57e86dc927355b7ddd86b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:19:12 +0000 Subject: [PATCH 07/38] feat(notifications): fan push out to every device, honour quiet hours, add notify_user Run notifications reached at most one phone and nothing could hold them back at night. - PushNotificationProvider sends each notification to all of the user's registered devices (web push and Expo), skips everything during quiet hours in the profile time zone, drops devices the push service reports gone (404/410, DeviceNotRegistered), records lastUsedAt and links each push to its conversation. - Expo tokens move from DATA_DIR/push-tokens files into push_subscriptions: the API imports and deletes the legacy files at start; notifications.registerPush/unregisterPush keep their contract for the mobile app (tokens must look like Expo tokens). - NotificationMessage gains the nudge kind, and the main bot gets notify_user: a short push to the main conversation that respects the nudge switch, quiet hours and a cap of three per day. It is withheld from shared messaging channels and from runs answering another owner's agent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/app.ts | 16 +- apps/api/src/router.ts | 12 +- apps/worker/src/index.ts | 11 +- packages/adapter-kit/src/types.ts | 2 +- packages/adapters/src/builtin-tools.ts | 22 ++ packages/adapters/src/executor-tools.test.ts | 6 +- packages/adapters/src/executor-tools.ts | 7 +- packages/adapters/src/executor.ts | 10 + packages/adapters/src/expo-push.test.ts | 298 ++++++++----------- packages/adapters/src/expo-push.ts | 137 +++++---- packages/adapters/src/index.ts | 1 + packages/adapters/src/notify-user-tool.ts | 85 ++++++ packages/adapters/src/push-notifications.ts | 89 ++++++ packages/contracts/src/notifications.ts | 7 + packages/contracts/src/rpc.ts | 3 +- packages/db/src/push-subscriptions.ts | 21 ++ 16 files changed, 469 insertions(+), 258 deletions(-) create mode 100644 packages/adapters/src/notify-user-tool.ts create mode 100644 packages/adapters/src/push-notifications.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c8b5057..b2b0faf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { rm } from "node:fs/promises"; import { ORPCError, onError } from "@orpc/server"; import { RPCHandler } from "@orpc/server/fetch"; import type { @@ -35,13 +34,14 @@ import { destroyBot, EmailEmulator, EncryptedSecretStore, - ExpoPushProvider, + ExpoPushSender, GraphileJobPublisher, GraphileJobWorkerHost, InMemoryJobQueue, InMemoryRealtimeFanout, InstalledConnectorProvider, IntegrationProviderSettings, + importLegacyPushTokens, isComposioEnabled, isMessagingSurfaceEnabled, isPipedreamEnabled, @@ -54,9 +54,9 @@ import { PiOAuthLogins, PipedreamConnector, PostgresRealtimeFanout, + PushNotificationProvider, pipedreamConfigFromEnv, piSessionsRoot, - pushTokenPath, reconcileCloudAgents, reconcileComputerUpdates, reconcileDelegatedTasks, @@ -67,6 +67,7 @@ import { toTeamChatInbound, VapidKeys, vapidEnvFromProcess, + WebPushSender, } from "@rakazo/adapters"; import { blockedAuthPaths, createAuth } from "@rakazo/auth"; import { signupPolicyFromEnv } from "@rakazo/core"; @@ -311,8 +312,14 @@ export async function createApp( : new PiAgentRuntime({ sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined, }); - const notifications = new ExpoPushProvider(env.dataDir); const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env)); + const notifications = new PushNotificationProvider(prisma, { + webpush: new WebPushSender(vapid), + expo: new ExpoPushSender(), + }); + void importLegacyPushTokens(prisma, env.dataDir).catch((error) => + getLogger().error("legacy push token import failed", error), + ); const auth = createAuth(prisma, { secret: env.authSecret, baseURL: env.authUrl, @@ -368,7 +375,6 @@ export async function createApp( ), ); await removePiUserSessions(env.dataDir, userId); - await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined); }, }); // One provider instance so emulator launches and polls share the same Map. diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index b6c7bc6..e08003f 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -44,7 +44,6 @@ import { computerSupportsUpdate, computerUpdateView, createVoiceProvider, - deletePushToken, deploymentAutoReviewDefault, destroyBot, displayBotWorkspacePath, @@ -76,7 +75,6 @@ import { resolveAutoReviewChecker, resolveBotWorkspacePath, sanitizeComposioError, - savePushToken, scheduleComputerControlExpiry, scheduleComputerSleep, screenLeaseIdForRun, @@ -139,6 +137,7 @@ import { projectWorkspaceDto, releaseSpaceDeletionClaim, removePushSubscription, + removePushSubscriptionsOfKind, renewSpaceDeletionClaim, requireMembership, SPACE_DELETION_CLAIM_TIMEOUT_MS, @@ -4763,11 +4762,16 @@ export function createRouter(deps: RouterDeps) { }, notifications: { registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => { - await savePushToken(deps.dataDir, context.actor.userId, input.token); + await savePushSubscription(deps.prisma, context.actor.userId, { + kind: "expo", + endpoint: input.token, + userAgent: context.userAgent, + }); return { ok: true as const }; }), + // The mobile app signs out without naming its token, so all of its tokens go. unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => { - await deletePushToken(deps.dataDir, context.actor.userId); + await removePushSubscriptionsOfKind(deps.prisma, context.actor.userId, "expo"); return { ok: true as const }; }), settings: { diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 804281c..39fba39 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -19,7 +19,7 @@ import { createWebProvider, databaseCapacityBackoffMs, EncryptedSecretStore, - ExpoPushProvider, + ExpoPushSender, GraphileJobPublisher, GraphileJobWorkerHost, InMemoryJobQueue, @@ -36,6 +36,7 @@ import { PiAgentRuntime, PipedreamConnector, PostgresRealtimeFanout, + PushNotificationProvider, pipedreamConfigFromEnv, reconcileCloudAgents, reconcileComputerUpdates, @@ -45,6 +46,9 @@ import { resolveSandboxProvider, ScriptedAgentRuntime, SpaceMemoryProviderResolver, + VapidKeys, + vapidEnvFromProcess, + WebPushSender, } from "@rakazo/adapters"; import { resolveEncryptionKey, resolveSupervisorToken } from "@rakazo/core"; import { @@ -193,7 +197,10 @@ async function main() { secretStore: secrets, deploymentModelKey, dataDir, - notifications: new ExpoPushProvider(dataDir), + notifications: new PushNotificationProvider(prisma, { + webpush: new WebPushSender(new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env))), + expo: new ExpoPushSender(), + }), jobs, events, messaging: messaging ? createMessagingContextLoader(prisma) : undefined, diff --git a/packages/adapter-kit/src/types.ts b/packages/adapter-kit/src/types.ts index d77cb5e..31b0a7a 100644 --- a/packages/adapter-kit/src/types.ts +++ b/packages/adapter-kit/src/types.ts @@ -553,7 +553,7 @@ export interface ArtifactPut { } export interface NotificationMessage { - kind: "completion" | "failure" | "help" | "takeover"; + kind: "completion" | "failure" | "help" | "takeover" | "nudge"; title: string; body: string; botId: string; diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 0ff52fa..b9726f2 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -8,6 +8,7 @@ import { USER_FACT_CATEGORIES, } from "@rakazo/contracts"; import { z } from "zod"; +import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js"; export const SUBAGENT_MODEL_TIER_DESCRIPTION = "Optional model tier: fast for quick simple work, code for programming, standard otherwise. Uses the model this space maps to the tier, or your own model when it maps none. Ignored when model_provider and model_id are set."; @@ -422,6 +423,27 @@ export const builtinAgentTools: ConnectorTool[] = [ }, }, }, + { + name: "notify_user", + description: + "Send the user a short push notification that opens the main conversation. Use it sparingly, for a check-in or reminder that matters now, and say the same in the conversation. The user's quiet hours, their nudge setting and a daily limit apply; the result says whether it was sent.", + inputSchema: { + type: "object", + properties: { + body: { + type: "string", + maxLength: NOTIFY_USER_BODY_MAX_LENGTH, + description: "One or two short sentences.", + }, + title: { + type: "string", + maxLength: NOTIFY_USER_TITLE_MAX_LENGTH, + description: "Optional; defaults to your name.", + }, + }, + required: ["body"], + }, + }, { name: "web_search", description: diff --git a/packages/adapters/src/executor-tools.test.ts b/packages/adapters/src/executor-tools.test.ts index d987728..bc2d541 100644 --- a/packages/adapters/src/executor-tools.test.ts +++ b/packages/adapters/src/executor-tools.test.ts @@ -64,7 +64,11 @@ describe("builtin tools per bot", () => { }); it("lists main-only tools for the main bot alone", () => { - expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual(["delegate_task", "update_user_profile"]); + expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([ + "delegate_task", + "notify_user", + "update_user_profile", + ]); const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false }); const main = builtinToolNamesForBot({ kind: "main", chatLinked: false }); for (const name of MAIN_ONLY_BUILTIN_TOOLS) { diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index 2e819b8..671ad46 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -19,6 +19,7 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([ /** Builtin tools only the user's main bot may use; every other bot's runs never see them. */ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet = new Set([ "delegate_task", + "notify_user", "update_user_profile", ]); @@ -105,7 +106,10 @@ export function selectBuiltinToolsForRun(options: { Boolean(options.cloudAgentEnabled), ).filter( (tool) => - !(options.foreignAgentRun && tool.name === "update_user_profile") && + !( + options.foreignAgentRun && + (tool.name === "update_user_profile" || tool.name === "notify_user") + ) && (!options.messagingChannelRun || (![ "remember", @@ -113,6 +117,7 @@ export function selectBuiltinToolsForRun(options: { "recall_memory", "forget_memory", "update_user_profile", + "notify_user", ].includes(tool.name) && !tool.name.startsWith("scratchpad_"))), ); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 5cb9181..7ec57e1 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -298,6 +298,7 @@ import { modelAcceptsImageInput, modelIdSupportsImages, } from "./model-vision.js"; +import { notifyUserFromTool } from "./notify-user-tool.js"; import { toOAuthCredential } from "./pi-credentials.js"; import { parseModelSecret, @@ -2573,6 +2574,15 @@ export function createRunExecutor(deps: ExecutorDeps) { ), ); } + if (name === "notify_user") { + return finish( + await notifyUserFromTool( + deps, + { userId: run.userId, spaceId: run.spaceId, runId, bot, signal: context.signal }, + args, + ), + ); + } if (name === "web_search") { return finish(await webSearchFromTool(web, context, args)); } diff --git a/packages/adapters/src/expo-push.test.ts b/packages/adapters/src/expo-push.test.ts index c8a7f4a..c42f8a1 100644 --- a/packages/adapters/src/expo-push.test.ts +++ b/packages/adapters/src/expo-push.test.ts @@ -1,15 +1,16 @@ -import { chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import type { PrismaClient } from "@rakazo/db"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - deletePushToken, - ExpoPushProvider, + ExpoPushSender, expoPushErrorMessage, - loadPushToken, + importLegacyPushTokens, + legacyPushTokenDir, MAX_EXPO_PUSH_RESPONSE_BYTES, - savePushToken, } from "./expo-push.js"; +import type { PushPayload } from "./web-push.js"; const dirs: string[] = []; @@ -18,13 +19,19 @@ afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); -const notifyContext = { - operationId: "n", - traceId: "n", - spaceId: "w", - userId: "user-1", - signal: new AbortController().signal, -}; +const device = { endpoint: "ExponentPushToken[test]", p256dh: null, auth: null }; + +function payload(overrides: Partial = {}): PushPayload { + return { + kind: "completion", + title: "done", + body: "ok", + botId: "b", + threadId: "t", + url: "/app/b", + ...overrides, + }; +} function jsonResponse(body: unknown, status = 200) { return Response.json(body, { status }); @@ -49,75 +56,24 @@ describe("expo push tickets", () => { }); }); -describe("expo push", () => { - it("keeps refreshed push tokens owner-only", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - const tokenFile = path.join(dataDir, "push-tokens", "user-1.txt"); - await savePushToken(dataDir, "user-1", "ExponentPushToken[old]"); - await chmod(tokenFile, 0o644); - - await savePushToken(dataDir, "user-1", "ExponentPushToken[new]"); - - expect((await stat(tokenFile)).mode & 0o777).toBe(0o600); - }); - - it("does not follow a token-file symlink for reads or writes", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - const tokenDir = path.join(dataDir, "push-tokens"); - const tokenFile = path.join(tokenDir, "user-1.txt"); - const target = path.join(dataDir, "outside.txt"); - await savePushToken(dataDir, "user-1", "ExponentPushToken[old]"); - await writeFile(target, "not-a-push-token"); - await rm(tokenFile); - await symlink(target, tokenFile); - - await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined(); - await expect(savePushToken(dataDir, "user-1", "ExponentPushToken[new]")).rejects.toThrow(); - await expect(readFile(target, "utf8")).resolves.toBe("not-a-push-token"); - }); - - it("removes a registered token", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); - await deletePushToken(dataDir, "user-1"); - await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined(); - }); - - it("does not call Expo when the user has no token", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - const push = new ExpoPushProvider(dataDir); - await push.send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - { - operationId: "n", - traceId: "n", - spaceId: "w", - userId: "missing", - signal: new AbortController().signal, - }, - ); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("posts to Expo when a token is registered", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); +describe("expo push sender", () => { + it("posts the notification to Expo for the device token", async () => { const fetchMock = vi .fn() .mockResolvedValue(jsonResponse({ data: { status: "ok", id: "ticket" } })); vi.stubGlobal("fetch", fetchMock); - const push = new ExpoPushProvider(dataDir); - await push.send( - { kind: "takeover", title: "Need you", body: "on screen", botId: "bot-1", threadId: "th-1" }, - notifyContext, - ); + await expect( + new ExpoPushSender().send( + device, + payload({ + kind: "takeover", + title: "Need you", + body: "on screen", + botId: "bot-1", + threadId: "th-1", + }), + ), + ).resolves.toBe("sent"); expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe("https://exp.host/--/api/v2/push/send"); @@ -126,55 +82,51 @@ describe("expo push", () => { title: string; collapseId: string; tag: string; - data: { kind: string }; + data: { kind: string; botId: string; threadId: string }; }; - expect(body.to).toBe("ExponentPushToken[test]"); - expect(body.title).toBe("Need you"); - expect(body.collapseId).toBe("th-1"); - expect(body.tag).toBe("th-1"); - expect(body.data.kind).toBe("takeover"); + expect(body).toMatchObject({ + to: "ExponentPushToken[test]", + title: "Need you", + collapseId: "th-1", + tag: "th-1", + data: { kind: "takeover", botId: "bot-1", threadId: "th-1" }, + }); + }); + + it("reports a device Expo no longer knows as gone", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + jsonResponse({ data: { status: "error", details: { error: "DeviceNotRegistered" } } }), + ), + ); + await expect(new ExpoPushSender().send(device, payload())).resolves.toBe("gone"); }); it("throws when Expo rejects the request", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(jsonResponse({ errors: [{ message: "boom" }] }, 500)), ); - const push = new ExpoPushProvider(dataDir); - await expect( - push.send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("boom"); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("boom"); }); it("rejects and cancels a declared oversized response", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); const response = new Response("oversized", { headers: { "content-length": String(MAX_EXPO_PUSH_RESPONSE_BYTES + 1) }, }); const cancel = vi.spyOn(response.body!, "cancel"); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("Expo push response is too large."); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow( + "Expo push response is too large.", + ); expect(cancel).toHaveBeenCalledOnce(); }); it("does not wait past cancellation when an oversized body cancel hangs", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); let cancelStarted = false; const hangingBody = new ReadableStream({ cancel() { @@ -194,51 +146,31 @@ describe("expo push", () => { ); const started = Date.now(); - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - { ...notifyContext, signal: abort.signal }, - ), - ).rejects.toThrow("Expo push response is too large."); + await expect(new ExpoPushSender().send(device, payload(), abort.signal)).rejects.toThrow( + "Expo push response is too large.", + ); expect(cancelStarted).toBe(true); expect(Date.now() - started).toBeLessThan(500); }); it("caps a streamed response without a content length", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(new Response(new Uint8Array(MAX_EXPO_PUSH_RESPONSE_BYTES + 1))), ); - - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("Expo push response is too large."); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow( + "Expo push response is too large.", + ); }); it("rejects malformed successful responses", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not json"))); - - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("Expo push returned an invalid response."); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow( + "Expo push returned an invalid response.", + ); }); it("passes caller cancellation to the Expo request", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); const controller = new AbortController(); controller.abort(new Error("notification cancelled")); const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { @@ -247,49 +179,69 @@ describe("expo push", () => { }); vi.stubGlobal("fetch", fetchMock); - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - { ...notifyContext, signal: controller.signal }, - ), - ).rejects.toThrow("notification cancelled"); + await expect(new ExpoPushSender().send(device, payload(), controller.signal)).rejects.toThrow( + "notification cancelled", + ); expect(fetchMock).toHaveBeenCalledOnce(); }); it("throws when the Expo request never reaches the network", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); - const push = new ExpoPushProvider(dataDir); - await expect( - push.send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("offline"); - }); - - it("reports DeviceNotRegistered without deleting a stored or replacement token", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[old]"); - vi.stubGlobal( - "fetch", - vi.fn(async () => { - await savePushToken(dataDir, "user-1", "ExponentPushToken[new]"); - return jsonResponse({ - data: { status: "error", details: { error: "DeviceNotRegistered" } }, - }); - }), - ); - const push = new ExpoPushProvider(dataDir); - await expect( - push.send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("DeviceNotRegistered"); - await expect(loadPushToken(dataDir, "user-1")).resolves.toBe("ExponentPushToken[new]"); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("offline"); + }); +}); + +describe("legacy push token import", () => { + function fakePrisma(existingUsers: string[]) { + const upsert = vi.fn().mockResolvedValue({}); + const prisma = { + user: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + existingUsers.includes(where.id) ? { id: where.id } : null, + ), + }, + pushSubscription: { + upsert, + findMany: vi.fn().mockResolvedValue([]), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + } as unknown as PrismaClient; + return { prisma, upsert }; + } + + it("imports each token once, skips unknown users and removes the files", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); + dirs.push(dataDir); + const dir = legacyPushTokenDir(dataDir); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "user-1.txt"), "ExponentPushToken[one]\n"); + await writeFile(path.join(dir, "gone-user.txt"), "ExponentPushToken[two]"); + await writeFile(path.join(dir, "user-2.txt"), "not a token"); + const { prisma, upsert } = fakePrisma(["user-1", "user-2"]); + + await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(1); + + expect(upsert).toHaveBeenCalledOnce(); + expect(upsert.mock.calls[0]![0]).toMatchObject({ + where: { endpoint: "ExponentPushToken[one]" }, + create: { endpoint: "ExponentPushToken[one]", userId: "user-1", kind: "expo" }, + }); + await expect(readdir(dataDir)).resolves.toEqual([]); + await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0); + }); + + it("does not follow a token-file symlink", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); + dirs.push(dataDir); + const dir = legacyPushTokenDir(dataDir); + await mkdir(dir, { recursive: true }); + const outside = path.join(dataDir, "outside.txt"); + await writeFile(outside, "ExponentPushToken[outside]"); + await symlink(outside, path.join(dir, "user-1.txt")); + const { prisma, upsert } = fakePrisma(["user-1"]); + + await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0); + expect(upsert).not.toHaveBeenCalled(); + await expect(readdir(dataDir)).resolves.toEqual(["outside.txt"]); }); }); diff --git a/packages/adapters/src/expo-push.ts b/packages/adapters/src/expo-push.ts index 3ff5dd9..c22f416 100644 --- a/packages/adapters/src/expo-push.ts +++ b/packages/adapters/src/expo-push.ts @@ -1,29 +1,29 @@ import { constants } from "node:fs"; -import { mkdir, open, unlink } from "node:fs/promises"; +import { open, readdir, rmdir, unlink } from "node:fs/promises"; import path from "node:path"; -import type { - AdapterContext, - NotificationMessage, - NotificationProvider, -} from "@rakazo/adapter-kit"; -import { getLogger } from "@rakazo/logging"; +import { ExpoPushTokenSchema } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { savePushSubscription } from "@rakazo/db"; import { combineSignals } from "./connector-safety.js"; +import type { PushOutcome, PushPayload, PushTarget } from "./web-push.js"; import { readBodyCapped, withAbort } from "./web-ssrf.js"; const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; const EXPO_PUSH_TIMEOUT_MS = 15_000; export const MAX_EXPO_PUSH_RESPONSE_BYTES = 64 * 1024; +const LEGACY_TOKEN_FILE = /^([A-Za-z0-9_-]{1,128})\.txt$/; -export function pushTokenPath(dataDir: string, userId: string) { - return path.join(dataDir, "push-tokens", `${userId}.txt`); +/** Where the API kept the single Expo token per user before push_subscriptions existed. */ +export function legacyPushTokenDir(dataDir: string) { + return path.join(dataDir, "push-tokens"); } -export async function loadPushToken(dataDir: string, userId: string): Promise { +async function readLegacyToken(file: string): Promise { try { - const handle = await open(pushTokenPath(dataDir, userId), constants.O_RDONLY | O_NOFOLLOW); + const handle = await open(file, constants.O_RDONLY | O_NOFOLLOW); try { - const token = (await handle.readFile("utf8")).trim(); - return token || undefined; + const token = ExpoPushTokenSchema.safeParse(await handle.readFile("utf8")); + return token.success ? token.data : undefined; } finally { await handle.close(); } @@ -32,26 +32,39 @@ export async function loadPushToken(dataDir: string, userId: string): Promise { - const file = pushTokenPath(dataDir, userId); - await mkdir(path.dirname(file), { recursive: true }); - const handle = await open( - file, - constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | O_NOFOLLOW, - 0o600, - ); +/** + * Moves tokens from the legacy files into push_subscriptions once, then deletes each file. A file + * whose user no longer exists, or that holds no usable token, is deleted without import; a + * database failure keeps the file for the next start. + */ +export async function importLegacyPushTokens( + prisma: PrismaClient, + dataDir: string, +): Promise { + const dir = legacyPushTokenDir(dataDir); + let names: string[]; try { - await handle.chmod(0o600); - await handle.writeFile(token.trim(), "utf8"); - } finally { - await handle.close(); + names = await readdir(dir); + } catch { + return 0; } -} - -export async function deletePushToken(dataDir: string, userId: string): Promise { - await unlink(pushTokenPath(dataDir, userId)).catch((error: NodeJS.ErrnoException) => { - if (error.code !== "ENOENT") throw error; - }); + let imported = 0; + for (const name of names) { + const userId = LEGACY_TOKEN_FILE.exec(name)?.[1]; + if (!userId) continue; + const file = path.join(dir, name); + const token = await readLegacyToken(file); + if (token) { + const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } }); + if (user) { + await savePushSubscription(prisma, userId, { kind: "expo", endpoint: token }); + imported += 1; + } + } + await unlink(file).catch(() => undefined); + } + await rmdir(dir).catch(() => undefined); + return imported; } export type ExpoPushTicket = { @@ -87,49 +100,33 @@ export function expoPushErrorMessage(body: unknown, status: number): string | un return undefined; } -export class ExpoPushProvider implements NotificationProvider { - constructor(private readonly dataDir: string) {} - - describe() { - return { - id: "expo-push", - contractVersion: "1", - adapterVersion: "0.1.0", - capabilities: { push: true, email: false }, - }; - } - - async send(message: NotificationMessage, context: AdapterContext): Promise { - const token = await loadPushToken(this.dataDir, context.userId); - if (!token) return; - const signal = combineSignals(context.signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS)); - let response: Response; - try { - response = await fetch("https://exp.host/--/api/v2/push/send", { - method: "POST", - headers: { "content-type": "application/json", accept: "application/json" }, - body: JSON.stringify({ - to: token, - title: message.title, - body: message.body, - collapseId: message.threadId, - tag: message.threadId, - data: { kind: message.kind, botId: message.botId, threadId: message.threadId }, - }), - signal, - }); - } catch (error) { - getLogger().error("expo push request failed", error); - throw error; - } - const body = await readExpoPushBody(response, signal); +/** Sends to one Expo push token; "gone" when Expo reports the device is no longer registered. */ +export class ExpoPushSender { + async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise { + const deadline = combineSignals(signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS)); + const response = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + to: target.endpoint, + title: payload.title, + body: payload.body, + collapseId: payload.threadId, + tag: payload.threadId, + data: { kind: payload.kind, botId: payload.botId, threadId: payload.threadId }, + }), + signal: deadline, + }); + const body = await readExpoPushBody(response, deadline); if (response.ok && body === undefined) { throw new Error("Expo push returned an invalid response."); } + if (expoPushTickets(body).some((ticket) => ticket.details?.error === "DeviceNotRegistered")) { + return "gone"; + } const failure = expoPushErrorMessage(body, response.status); - if (!failure) return; - getLogger().error(failure); - throw new Error(failure); + if (failure) throw new Error(failure); + return "sent"; } } diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index ecf21b9..c4393d1 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -100,6 +100,7 @@ export * from "./pipedream-connector.js"; export * from "./profile-consolidation.js"; export * from "./project-workspace-files.js"; export * from "./project-workspace-path.js"; +export * from "./push-notifications.js"; export * from "./realtime.js"; export * from "./release-watch.js"; export * from "./remote-mcp.js"; diff --git a/packages/adapters/src/notify-user-tool.ts b/packages/adapters/src/notify-user-tool.ts new file mode 100644 index 0000000..6b10256 --- /dev/null +++ b/packages/adapters/src/notify-user-tool.ts @@ -0,0 +1,85 @@ +import type { NotificationProvider } from "@rakazo/adapter-kit"; +import { NUDGES_PER_DAY } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + claimDailyNudge, + countPushSubscriptions, + ensureMainConversation, + loadNotificationPolicy, +} from "@rakazo/db"; +import { z } from "zod"; + +export const NOTIFY_USER_BODY_MAX_LENGTH = 200; +export const NOTIFY_USER_TITLE_MAX_LENGTH = 60; + +const NotifyUserArgs = z.object({ + body: z.string().trim().min(1).max(NOTIFY_USER_BODY_MAX_LENGTH), + title: z.string().trim().max(NOTIFY_USER_TITLE_MAX_LENGTH).optional(), +}); + +export interface NotifyUserToolContext { + userId: string; + spaceId: string; + runId: string; + bot: { id: string; name: string; kind: string }; + signal: AbortSignal; +} + +export type NotifyUserResult = { error: string } | { sent: true } | { sent: false; reason: string }; + +/** + * A nudge from the main bot: a push that opens the main conversation. The user's nudge switch, + * quiet hours and the daily cap all apply; a held-back nudge is reported, not an error. + */ +export async function notifyUserFromTool( + deps: { prisma: PrismaClient; notifications?: NotificationProvider }, + context: NotifyUserToolContext, + rawArgs: Record, +): Promise { + if (context.bot.kind !== "main") { + return { error: "Only the user's main bot can notify the user." }; + } + const parsed = NotifyUserArgs.safeParse(rawArgs); + if (!parsed.success) { + return { + error: `Invalid notification: body is required, up to ${NOTIFY_USER_BODY_MAX_LENGTH} characters; title up to ${NOTIFY_USER_TITLE_MAX_LENGTH}.`, + }; + } + const { prisma, notifications } = deps; + if (!notifications) { + return { sent: false, reason: "Push notifications are not available on this server." }; + } + const policy = await loadNotificationPolicy(prisma, context.userId); + if (!policy.nudges) return { sent: false, reason: "The user turned nudges off." }; + if (policy.quiet) return { sent: false, reason: "It is the user's quiet hours." }; + if ((await countPushSubscriptions(prisma, context.userId)) === 0) { + return { sent: false, reason: "The user has no device with notifications turned on." }; + } + if (!(await claimDailyNudge(prisma, context.userId, policy.localDate))) { + return { sent: false, reason: `The daily limit of ${NUDGES_PER_DAY} nudges is reached.` }; + } + const conversation = await ensureMainConversation(prisma, context.userId); + try { + await notifications.send( + { + kind: "nudge", + title: parsed.data.title || context.bot.name, + body: parsed.data.body, + botId: context.bot.id, + threadId: conversation.threadId, + }, + { + operationId: `notify_user:${context.runId}`, + traceId: context.runId, + spaceId: context.spaceId, + userId: context.userId, + botId: context.bot.id, + runId: context.runId, + signal: context.signal, + }, + ); + } catch { + return { sent: false, reason: "No device accepted the notification." }; + } + return { sent: true }; +} diff --git a/packages/adapters/src/push-notifications.ts b/packages/adapters/src/push-notifications.ts new file mode 100644 index 0000000..74f5956 --- /dev/null +++ b/packages/adapters/src/push-notifications.ts @@ -0,0 +1,89 @@ +import type { + AdapterContext, + NotificationMessage, + NotificationProvider, +} from "@rakazo/adapter-kit"; +import type { PrismaClient, PushSubscriptionKind } from "@rakazo/db"; +import { + forgetPushSubscription, + listPushSubscriptions, + loadNotificationPolicy, + markPushSubscriptionUsed, +} from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; +import type { PushOutcome, PushPayload, PushTarget } from "./web-push.js"; + +/** Lock screens show a line or two; the rest lives in the conversation. */ +const PUSH_BODY_MAX_LENGTH = 240; + +export interface PushSender { + send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise; +} + +function clip(text: string): string { + const trimmed = text.trim(); + return trimmed.length > PUSH_BODY_MAX_LENGTH + ? `${trimmed.slice(0, PUSH_BODY_MAX_LENGTH - 1).trimEnd()}…` + : trimmed; +} + +/** + * Delivers one notification to every device the user registered (browsers and the mobile app), + * except during their quiet hours. Devices the push service reports gone are dropped. Throws only + * when every device failed. + */ +export class PushNotificationProvider implements NotificationProvider { + constructor( + private readonly prisma: PrismaClient, + private readonly senders: Partial>, + private readonly now: () => Date = () => new Date(), + ) {} + + describe() { + return { + id: "push", + contractVersion: "1", + adapterVersion: "0.1.0", + capabilities: { push: true, email: false }, + }; + } + + async send(message: NotificationMessage, context: AdapterContext): Promise { + const policy = await loadNotificationPolicy(this.prisma, context.userId, this.now()); + if (policy.quiet) return; + const devices = (await listPushSubscriptions(this.prisma, context.userId)).filter( + (device) => this.senders[device.kind], + ); + if (devices.length === 0) return; + const payload: PushPayload = { + ...message, + title: message.title.trim().slice(0, 120), + body: clip(message.body), + url: await this.conversationPath(message), + }; + const results = await Promise.allSettled( + devices.map(async (device) => { + const outcome = await this.senders[device.kind]!.send(device, payload, context.signal); + if (outcome === "gone") await forgetPushSubscription(this.prisma, device.id); + else await markPushSubscriptionUsed(this.prisma, device.id, this.now()); + }), + ); + const failures = results.filter((result) => result.status === "rejected"); + for (const failure of failures) { + // Errors carry a status or a reason, never the endpoint or token. + getLogger().error("push delivery failed", failure.reason); + } + if (failures.length === devices.length) throw new Error("Push delivery failed"); + } + + /** The web app path of the conversation the notification is about. */ + private async conversationPath(message: NotificationMessage): Promise { + const thread = await this.prisma.thread.findUnique({ + where: { id: message.threadId }, + select: { groupId: true }, + }); + return thread?.groupId + ? `/app/g/${encodeURIComponent(thread.groupId)}` + : `/app/${encodeURIComponent(message.botId)}`; + } +} diff --git a/packages/contracts/src/notifications.ts b/packages/contracts/src/notifications.ts index 5cb6287..b82c110 100644 --- a/packages/contracts/src/notifications.ts +++ b/packages/contracts/src/notifications.ts @@ -58,6 +58,13 @@ export const WebPushEndpointSchema = z } }, "Endpoint must be an https URL"); +/** The mobile app's Expo push token, e.g. "ExponentPushToken[xxxxxxxx]". */ +export const ExpoPushTokenSchema = z + .string() + .trim() + .max(512) + .regex(/^Expo(nent)?PushToken\[[^\]\s]+\]$/, "Invalid Expo push token"); + /** The JSON form of a browser PushSubscription (RFC 8291 keys, base64url). */ export const WebPushSubscriptionInput = z.object({ endpoint: WebPushEndpointSchema, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1dc59cb..d22607a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -84,6 +84,7 @@ import { } from "./integration-settings.js"; import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js"; import { + ExpoPushTokenSchema, NotificationSettingsSchema, UpdateNotificationSettingsInput, WebPushConfigSchema, @@ -807,7 +808,7 @@ export const appContract = { }, notifications: { registerPush: oc - .input(z.object({ token: z.string().min(8).max(512) })) + .input(z.object({ token: ExpoPushTokenSchema })) .output(z.object({ ok: z.literal(true) })), unregisterPush: oc.output(z.object({ ok: z.literal(true) })), settings: { diff --git a/packages/db/src/push-subscriptions.ts b/packages/db/src/push-subscriptions.ts index 885f337..879f705 100644 --- a/packages/db/src/push-subscriptions.ts +++ b/packages/db/src/push-subscriptions.ts @@ -86,3 +86,24 @@ export async function listPushSubscriptions( }); return rows.map((row) => ({ ...row, kind: row.kind as PushSubscriptionKind })); } + +export async function countPushSubscriptions( + prisma: PrismaClient, + userId: string, +): Promise { + return prisma.pushSubscription.count({ where: { userId } }); +} + +/** Records a successful delivery to a device. */ +export async function markPushSubscriptionUsed( + prisma: PrismaClient, + id: string, + at: Date, +): Promise { + await prisma.pushSubscription.updateMany({ where: { id }, data: { lastUsedAt: at } }); +} + +/** Drops a device its push service no longer knows. */ +export async function forgetPushSubscription(prisma: PrismaClient, id: string): Promise { + await prisma.pushSubscription.deleteMany({ where: { id } }); +} From 98b0798f60d528adb8f62bdaed6e0831e7cd8111 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:20:45 +0000 Subject: [PATCH 08/38] feat(coach): main-bot coach tools and goals in its prompt The main agent had no way to record what the user wants to achieve or how their day went, and its prompt never showed their goals or habits. - update_goal, update_habit, log_check_in and write_daily_brief, main-bot only and, like update_user_profile, withheld from shared channels and from runs that answer another owner's agent. Goals and habits can be named by id or exact title. - The main bot's own runs list active goals and habits (with ids, done state and streak) in the block; the basics-only block and every other bot never see them. The main agent role adds a short coaching stance and names each coach tool only when the run has it, so the check-in routine prompts stay tool-agnostic. - The scripted runtime understands a few coach commands and the morning check-in, so tests can drive the tools end to end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/builtin-tools.ts | 2 + packages/adapters/src/coach-rhythm.ts | 2 +- packages/adapters/src/coach-tools.test.ts | 79 ++++ packages/adapters/src/coach-tools.ts | 354 ++++++++++++++++++ .../src/executor-capabilities.test.ts | 3 + .../src/executor-instructions.test.ts | 62 +++ packages/adapters/src/executor-prompt.ts | 55 ++- packages/adapters/src/executor-tools.test.ts | 9 +- packages/adapters/src/executor-tools.ts | 19 +- packages/adapters/src/executor.ts | 14 +- packages/adapters/src/scripted-runtime.ts | 53 +++ 11 files changed, 639 insertions(+), 13 deletions(-) create mode 100644 packages/adapters/src/coach-tools.test.ts create mode 100644 packages/adapters/src/coach-tools.ts diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 0ff52fa..a6da4d1 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -8,6 +8,7 @@ import { USER_FACT_CATEGORIES, } from "@rakazo/contracts"; import { z } from "zod"; +import { coachTools } from "./coach-tools.js"; export const SUBAGENT_MODEL_TIER_DESCRIPTION = "Optional model tier: fast for quick simple work, code for programming, standard otherwise. Uses the model this space maps to the tier, or your own model when it maps none. Ignored when model_provider and model_id are set."; @@ -422,6 +423,7 @@ export const builtinAgentTools: ConnectorTool[] = [ }, }, }, + ...coachTools, { name: "web_search", description: diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index f9ee580..348e26a 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -34,7 +34,7 @@ export const COACH_ROUTINES: Record export interface CoachRhythmDeps { prisma: PrismaClient; - jobs: JobPublisher; + jobs: Pick; } /** The cron for one check-in, or null when it is off. */ diff --git a/packages/adapters/src/coach-tools.test.ts b/packages/adapters/src/coach-tools.test.ts new file mode 100644 index 0000000..3aa7567 --- /dev/null +++ b/packages/adapters/src/coach-tools.test.ts @@ -0,0 +1,79 @@ +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it } from "vitest"; +import { COACH_TOOL_NAMES, executeCoachTool } from "./coach-tools.js"; +import { selectBuiltinToolsForRun } from "./executor-tools.js"; + +function toolNames(options: { + mainBot?: boolean; + messagingChannelRun?: boolean; + foreignAgentRun?: boolean; + trigger?: string; +}) { + return selectBuiltinToolsForRun({ + graphicalToolsAllowed: false, + groupId: null, + trigger: options.trigger ?? "user", + semanticMemoryEnabled: false, + messagingChannelRun: options.messagingChannelRun ?? false, + foreignAgentRun: options.foreignAgentRun, + bot: { + kind: options.mainBot ? "main" : "specialist", + restrictBuiltinTools: false, + allowedBuiltinTools: [], + }, + }).map((tool) => tool.name); +} + +/** Any database access fails the test: refusals and invalid input must not reach it. */ +const untouchedPrisma = new Proxy( + {}, + { + get(_target, property) { + throw new Error(`unexpected database access: ${String(property)}`); + }, + }, +) as PrismaClient; + +const context = (kind: string) => ({ userId: "user-1", bot: { id: "bot-1", kind } }); + +describe("coach tools", () => { + it("are the main bot's alone, also in its check-in routines, and never for other people", () => { + for (const name of COACH_TOOL_NAMES) { + expect(toolNames({ mainBot: true })).toContain(name); + expect(toolNames({ mainBot: true, trigger: "routine" })).toContain(name); + expect(toolNames({ mainBot: false })).not.toContain(name); + expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain(name); + expect(toolNames({ mainBot: true, foreignAgentRun: true })).not.toContain(name); + } + }); + + it("refuse specialists even when they call a tool anyway", async () => { + for (const name of COACH_TOOL_NAMES) { + await expect( + executeCoachTool(untouchedPrisma, context("specialist"), name, { title: "Run" }), + ).resolves.toEqual({ error: "Only the user's main bot can coach the user." }); + } + }); + + it("reject invalid or incomplete arguments before writing", async () => { + const main = context("main"); + await expect(executeCoachTool(untouchedPrisma, main, "update_goal", {})).resolves.toEqual({ + error: "A new goal needs a title.", + }); + await expect( + executeCoachTool(untouchedPrisma, main, "update_goal", { title: "x", target_date: "soon" }), + ).resolves.toEqual({ error: expect.stringContaining("target_date") }); + await expect( + executeCoachTool(untouchedPrisma, main, "update_habit", { title: "Walk", cadence: "hourly" }), + ).resolves.toEqual({ error: expect.stringContaining("cadence") }); + await expect( + executeCoachTool(untouchedPrisma, main, "log_check_in", { habit: "Walk", goal: "10k" }), + ).resolves.toEqual({ error: "Log a habit and a goal in separate calls." }); + await expect( + executeCoachTool(untouchedPrisma, main, "log_check_in", { mood: 9 }), + ).resolves.toEqual({ error: expect.stringContaining("mood") }); + await expect( + executeCoachTool(untouchedPrisma, main, "write_daily_brief", { text: " " }), + ).resolves.toEqual({ error: expect.stringContaining("text") }); + }); +}); diff --git a/packages/adapters/src/coach-tools.ts b/packages/adapters/src/coach-tools.ts new file mode 100644 index 0000000..39a496e --- /dev/null +++ b/packages/adapters/src/coach-tools.ts @@ -0,0 +1,354 @@ +import type { ConnectorTool } from "@rakazo/adapter-kit"; +import { + CHECK_IN_NOTE_MAX_LENGTH, + COACH_TITLE_MAX_LENGTH, + DAILY_BRIEF_MAX_LENGTH, + GOAL_DETAIL_MAX_LENGTH, + GOAL_STATUSES, + GoalStatusSchema, + HABIT_CADENCES, + HabitCadenceSchema, + LocalDateSchema, +} from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + logCheckIn, + updateGoal, + updateHabit, + upsertDailyBrief, +} from "@rakazo/db"; +import { z } from "zod"; + +/** The main bot's coach tools, in the order they appear in its tool list. */ +export const COACH_TOOL_NAMES = [ + "update_goal", + "update_habit", + "log_check_in", + "write_daily_brief", +] as const; +export type CoachToolName = (typeof COACH_TOOL_NAMES)[number]; + +export function isCoachToolName(name: string): name is CoachToolName { + return (COACH_TOOL_NAMES as readonly string[]).includes(name); +} + +const DATE_HINT = "The user's local date, YYYY-MM-DD."; + +export const coachTools: ConnectorTool[] = [ + { + name: "update_goal", + description: + "Track a goal the user committed to. Without id it creates one; with id it changes it, and status done or dropped closes it. Track only what the user stated or agreed to.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Id or exact title of the goal to change." }, + title: { type: "string", description: 'A short outcome, e.g. "Run a 10k in November".' }, + detail: { type: "string", description: "Optional context, such as why it matters." }, + status: { type: "string", enum: [...GOAL_STATUSES] }, + target_date: { type: "string", description: `${DATE_HINT} An empty string clears it.` }, + }, + }, + }, + { + name: "update_habit", + description: + "Track a habit the user wants to keep. Without id it creates one; with id it changes it, and active false stops tracking it.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Id or exact title of the habit to change." }, + title: { type: "string", description: 'A short practice, e.g. "Walk 20 minutes".' }, + cadence: { type: "string", enum: [...HABIT_CADENCES] }, + goal: { + type: "string", + description: "Id or exact title of the goal it serves. An empty string unlinks it.", + }, + active: { type: "boolean" }, + }, + }, + }, + { + name: "log_check_in", + description: + "Record what the user reported for a day: a habit kept or missed, progress on a goal, or, with neither, how the day went. Logging the same day again updates it.", + inputSchema: { + type: "object", + properties: { + habit: { type: "string", description: "Id or exact title of the habit." }, + goal: { type: "string", description: "Id or exact title of the goal." }, + date: { type: "string", description: `${DATE_HINT} Defaults to today.` }, + done: { type: "boolean", description: "Whether the habit was kept." }, + mood: { type: "integer", minimum: 1, maximum: 5 }, + energy: { type: "integer", minimum: 1, maximum: 5 }, + note: { type: "string", description: "The user's words, briefly." }, + }, + }, + }, + { + name: "write_daily_brief", + description: + "Save the user's brief for the day, shown at the top of their Today page. Plain text, two to five short lines. Writing again replaces it.", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + date: { type: "string", description: `${DATE_HINT} Defaults to today.` }, + }, + required: ["text"], + }, + }, +]; + +const Ref = z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH); +const ClearableDate = z.union([LocalDateSchema, z.literal("")]); + +const UpdateGoalArgs = z.object({ + id: Ref.optional(), + title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), + detail: z.string().trim().max(GOAL_DETAIL_MAX_LENGTH).optional(), + status: GoalStatusSchema.optional(), + target_date: ClearableDate.optional(), +}); + +const UpdateHabitArgs = z.object({ + id: Ref.optional(), + title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), + cadence: HabitCadenceSchema.optional(), + goal: z.union([Ref, z.literal("")]).optional(), + active: z.boolean().optional(), +}); + +const Rating = z.number().int().min(1).max(5); + +const LogCheckInArgs = z.object({ + habit: Ref.optional(), + goal: Ref.optional(), + date: LocalDateSchema.optional(), + done: z.boolean().optional(), + mood: Rating.optional(), + energy: Rating.optional(), + note: z.string().trim().max(CHECK_IN_NOTE_MAX_LENGTH).optional(), +}); + +const WriteDailyBriefArgs = z.object({ + text: z.string().trim().min(1).max(DAILY_BRIEF_MAX_LENGTH), + date: LocalDateSchema.optional(), +}); + +export interface CoachToolContext { + userId: string; + bot: { id: string; kind: string }; +} + +type ToolResult = Record; + +function invalid(name: string, error: z.ZodError): ToolResult { + const issues = error.issues + .map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`) + .join("; "); + return { error: `Invalid ${name} arguments: ${issues}` }; +} + +/** An id first, then an exact title (any case) among the user's items. */ +async function resolveRef( + find: (where: object) => Promise>, + ref: string, + what: "goal" | "habit", +): Promise { + const byId = await find({ id: ref }); + if (byId[0]) return byId[0].id; + const byTitle = await find({ title: { equals: ref, mode: "insensitive" } }); + if (byTitle.length === 1) return byTitle[0]!.id; + return byTitle.length > 1 + ? { error: `Several ${what}s are titled "${ref}". Use the id.` } + : { error: `No ${what} "${ref}".` }; +} + +function goalRef(prisma: PrismaClient, userId: string, ref: string) { + return resolveRef( + (where) => + prisma.userGoal.findMany({ + where: { userId, ...where }, + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + select: { id: true }, + take: 2, + }), + ref, + "goal", + ); +} + +function habitRef(prisma: PrismaClient, userId: string, ref: string) { + return resolveRef( + (where) => + prisma.habit.findMany({ + where: { userId, ...where }, + orderBy: [{ active: "desc" }, { createdAt: "desc" }], + select: { id: true }, + take: 2, + }), + ref, + "habit", + ); +} + +async function updateGoalFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = UpdateGoalArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("update_goal", parsed.error); + const args = parsed.data; + const targetDate = args.target_date === undefined ? undefined : args.target_date || null; + if (!args.id) { + if (!args.title) return { error: "A new goal needs a title." }; + const goal = await createGoal(prisma, context.userId, { + title: args.title, + detail: args.detail, + targetDate, + source: "chat", + createdByBotId: context.bot.id, + }); + return { ok: true, goal: { id: goal.id, title: goal.title, status: goal.status } }; + } + const goalId = await goalRef(prisma, context.userId, args.id); + if (typeof goalId !== "string") return goalId; + const goal = await updateGoal(prisma, context.userId, goalId, { + title: args.title, + detail: args.detail, + status: args.status, + targetDate, + }); + return { + ok: true, + goal: { id: goal.id, title: goal.title, status: goal.status, targetDate: goal.targetDate }, + }; +} + +async function updateHabitFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = UpdateHabitArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("update_habit", parsed.error); + const args = parsed.data; + let goalId: string | null | undefined; + if (args.goal) { + const resolved = await goalRef(prisma, context.userId, args.goal); + if (typeof resolved !== "string") return resolved; + goalId = resolved; + } else if (args.goal === "") { + goalId = null; + } + if (!args.id) { + if (!args.title) return { error: "A new habit needs a title." }; + const habit = await createHabit(prisma, context.userId, { + title: args.title, + cadence: args.cadence, + goalId, + source: "chat", + createdByBotId: context.bot.id, + }); + return { ok: true, habit: { id: habit.id, title: habit.title, cadence: habit.cadence } }; + } + const habitId = await habitRef(prisma, context.userId, args.id); + if (typeof habitId !== "string") return habitId; + const habit = await updateHabit(prisma, context.userId, habitId, { + title: args.title, + cadence: args.cadence, + goalId, + active: args.active, + }); + return { + ok: true, + habit: { id: habit.id, title: habit.title, cadence: habit.cadence, active: habit.active }, + }; +} + +async function logCheckInFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = LogCheckInArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("log_check_in", parsed.error); + const { habit, goal, ...fields } = parsed.data; + if (habit && goal) return { error: "Log a habit and a goal in separate calls." }; + const habitId = habit ? await habitRef(prisma, context.userId, habit) : undefined; + if (habitId !== undefined && typeof habitId !== "string") return habitId; + const goalId = goal ? await goalRef(prisma, context.userId, goal) : undefined; + if (goalId !== undefined && typeof goalId !== "string") return goalId; + const checkIn = await logCheckIn(prisma, context.userId, { + kind: habitId ? "habit" : goalId ? "goal" : "daily", + habitId, + goalId, + ...fields, + createdByBotId: context.bot.id, + }); + return { + ok: true, + checkIn: { + kind: checkIn.kind, + date: checkIn.date, + done: checkIn.done, + mood: checkIn.mood, + energy: checkIn.energy, + }, + }; +} + +async function writeDailyBriefFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = WriteDailyBriefArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("write_daily_brief", parsed.error); + const brief = await upsertDailyBrief(prisma, context.userId, { + text: parsed.data.text, + date: parsed.data.date, + botId: context.bot.id, + }); + return { ok: true, date: brief.date }; +} + +const HANDLERS: Record< + CoachToolName, + ( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, + ) => Promise +> = { + update_goal: updateGoalFromTool, + update_habit: updateHabitFromTool, + log_check_in: logCheckInFromTool, + write_daily_brief: writeDailyBriefFromTool, +}; + +/** Runs one coach tool for the user's main bot; every other bot is refused. */ +export async function executeCoachTool( + prisma: PrismaClient, + context: CoachToolContext, + name: CoachToolName, + rawArgs: Record, +): Promise { + if (context.bot.kind !== "main") { + return { error: "Only the user's main bot can coach the user." }; + } + try { + return await HANDLERS[name](prisma, context, rawArgs); + } catch (error) { + if (error instanceof CoachNotFoundError || error instanceof InvalidCoachInputError) { + return { error: error.message }; + } + throw error; + } +} diff --git a/packages/adapters/src/executor-capabilities.test.ts b/packages/adapters/src/executor-capabilities.test.ts index 57de9d8..8c953dd 100644 --- a/packages/adapters/src/executor-capabilities.test.ts +++ b/packages/adapters/src/executor-capabilities.test.ts @@ -111,6 +111,9 @@ async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record ({ name: "Ada" })) }, userProfile: { findUnique: vi.fn(async () => null) }, userFact: { findMany: vi.fn(async () => []) }, + // The main bot's goals and habits. + userGoal: { findMany: vi.fn(async () => []) }, + habit: { findMany: vi.fn(async () => []) }, ...models, }; let request: AgentRunRequest | undefined; diff --git a/packages/adapters/src/executor-instructions.test.ts b/packages/adapters/src/executor-instructions.test.ts index 26c2e89..da9dc0a 100644 --- a/packages/adapters/src/executor-instructions.test.ts +++ b/packages/adapters/src/executor-instructions.test.ts @@ -1,5 +1,6 @@ import type { AgentRunRequest } from "@rakazo/adapter-kit"; import type { MessageBlock } from "@rakazo/contracts"; +import { addDays, localDateIn } from "@rakazo/core"; import { describe, expect, it, vi } from "vitest"; import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; import { createRunExecutor } from "./executor.js"; @@ -20,6 +21,9 @@ interface FixtureOptions { sender?: { name: string } | null; profile?: Record | null; facts?: Array<{ category: string; value: string; confidence: number }>; + goals?: Array>; + habits?: Array>; + doneCheckIns?: Array<{ habitId: string; date: Date }>; peers?: Array<{ id: string; name: string; @@ -131,6 +135,9 @@ async function runOnce(options: FixtureOptions = {}) { async () => options.facts ?? [{ category: "work", value: "Owns a bakery", confidence: 1 }], ), }, + userGoal: { findMany: vi.fn(async () => options.goals ?? []) }, + habit: { findMany: vi.fn(async () => options.habits ?? []) }, + checkIn: { findMany: vi.fn(async () => options.doneCheckIns ?? []) }, actionApprovalRule: { findMany: vi.fn(async () => []) }, actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) }, externalEffect: { @@ -297,6 +304,11 @@ describe("run instructions", () => { expect(request.tools.map((tool) => tool.name)).not.toContain("update_user_profile"); expect(request.instructions).not.toContain("update_user_profile"); + for (const name of ["update_goal", "update_habit", "log_check_in", "write_daily_brief"]) { + expect(request.tools.map((tool) => tool.name)).not.toContain(name); + expect(request.instructions).not.toContain(name); + } + expect(request.instructions).not.toContain("Active goals"); }); it("gives runs woken by the user's own bot the full user block", async () => { @@ -338,6 +350,56 @@ describe("run instructions", () => { expect(request.instructions).not.toContain("save lasting facts they share with remember"); }); + it("gives the main bot the user's goals and habits and names its coach tools", async () => { + const today = localDateIn("Europe/Berlin"); + const row = { + source: "chat", + createdByBotId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const coach = { + goals: [ + { + ...row, + id: "goal-1", + title: "Ship the beta", + detail: "", + status: "active", + targetDate: new Date(`${addDays(today, 30)}T00:00:00Z`), + spaceId: null, + completedAt: null, + }, + ], + habits: [ + { ...row, id: "habit-1", title: "Walk", cadence: "daily", goalId: null, active: true }, + { ...row, id: "habit-2", title: "Long run", cadence: "weekly", goalId: null, active: true }, + ], + doneCheckIns: [today, addDays(today, -1)].map((date) => ({ + habitId: "habit-1", + date: new Date(`${date}T00:00:00Z`), + })), + }; + const main = await runOnce({ + bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }, + ...coach, + }); + const names = main.request.tools.map((tool) => tool.name); + expect(names).toEqual( + expect.arrayContaining(["update_goal", "update_habit", "log_check_in", "write_daily_brief"]), + ); + expect(main.request.instructions).toContain("You are also their coach"); + expect(main.request.instructions).toContain("save today's brief with write_daily_brief"); + expect(main.request.instructions).toContain( + `Active goals:\n- Ship the beta, due ${addDays(today, 30)} (id: goal-1)\nHabits:\n- Walk: daily, done today, streak 2 (id: habit-1)\n- Long run: weekly, not done this week, streak 0 (id: habit-2)\n`, + ); + + const specialist = await runOnce(coach); + expect(specialist.request.instructions).not.toContain("Active goals"); + expect(specialist.request.instructions).not.toContain("You are also their coach"); + expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal"); + }); + it("shows the main bot every specialist it can delegate to, its own space first", async () => { const { request, peersFindMany } = await runOnce({ bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }, diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 479f2dd..09704a4 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -1,9 +1,12 @@ import { escapePromptData, knownTimeZone } from "@rakazo/core"; -import type { UserContext } from "@rakazo/db"; +import type { CoachSummary, UserContext } from "@rakazo/db"; +import { COACH_TOOL_NAMES } from "./coach-tools.js"; /** Per-field caps so user-written profile text cannot crowd out the rest of the prompt. */ const USER_FIELD_MAX_CHARS = 500; const USER_SUMMARY_MAX_CHARS = 2_000; +/** Goals and habits listed in the user block; the rest are counted. */ +const COACH_PROMPT_ITEM_LIMIT = 20; export interface BotIdentity { name: string; @@ -41,11 +44,30 @@ export function formatMainAgentRole(toolNames: ReadonlySet): string { : toolNames.has("remember") ? "Keep what you know about the user current: save lasting facts they share with remember." : "Keep what you know about the user current.", + ...formatCoachGuidance(toolNames), ] .filter(Boolean) .join(" "); } +/** How the main bot coaches, naming only the coach tools this run has. */ +function formatCoachGuidance(toolNames: ReadonlySet): Array { + if (!COACH_TOOL_NAMES.some((name) => toolNames.has(name))) return []; + return [ + "You are also their coach: help them reach the goals and keep the habits they chose. Be brief and encouraging, notice progress, and never nag.", + toolNames.has("update_goal") + ? "Record goals they commit to with update_goal, and close them when they are done or dropped." + : undefined, + toolNames.has("update_habit") ? "Track habits they want to keep with update_habit." : undefined, + toolNames.has("log_check_in") + ? "When they tell you how a habit, a goal or their day went, record it with log_check_in." + : undefined, + toolNames.has("write_daily_brief") + ? "At the morning check-in, save today's brief with write_daily_brief." + : undefined, + ]; +} + /** One line of escaped user text, clipped to `max` characters. */ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string { const text = (value ?? "").replace(/\s+/g, " ").trim(); @@ -62,7 +84,11 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): */ export function formatUserContext( user: UserContext, - options: { basicsOnly?: boolean } = {}, + options: { + basicsOnly?: boolean; + /** The main bot's own runs: the user's active goals and habits. */ + coach?: Pick; + } = {}, ): string | undefined { const field = (label: string, value: string | null | undefined, max?: number) => { const text = userText(value, max); @@ -82,6 +108,7 @@ export function formatUserContext( field("Communication style", user.communicationStyle), options.basicsOnly ? undefined : field("Summary", user.summary, USER_SUMMARY_MAX_CHARS), ...(facts.length > 0 ? ["Facts:", ...facts] : []), + ...(options.coach && !options.basicsOnly ? coachLines(options.coach) : []), ].filter((line): line is string => Boolean(line)); if (lines.length === 0) return undefined; return [ @@ -92,6 +119,30 @@ export function formatUserContext( ].join("\n"); } +function listed(label: string, items: readonly T[], line: (item: T) => string): string[] { + if (items.length === 0) return []; + const shown = items.slice(0, COACH_PROMPT_ITEM_LIMIT).map(line); + const more = items.length - shown.length; + return [`${label}:`, ...shown, ...(more > 0 ? [`- and ${more} more`] : [])]; +} + +/** Active goals and habits with ids, so coach tools can name them exactly. */ +function coachLines(coach: Pick): string[] { + return [ + ...listed( + "Active goals", + coach.goals, + (goal) => + `- ${userText(goal.title)}${goal.targetDate ? `, due ${goal.targetDate}` : ""} (id: ${goal.id})`, + ), + ...listed("Habits", coach.habits, (habit) => { + const period = habit.cadence === "weekly" ? "this week" : "today"; + const done = habit.doneToday ? `done ${period}` : `not done ${period}`; + return `- ${userText(habit.title)}: ${habit.cadence}, ${done}, streak ${habit.streak} (id: ${habit.id})`; + }), + ]; +} + /** * Background a run_subagent helper shares with the bot that started it: who it works for, * the present moment, and the user block. diff --git a/packages/adapters/src/executor-tools.test.ts b/packages/adapters/src/executor-tools.test.ts index d987728..d7b60eb 100644 --- a/packages/adapters/src/executor-tools.test.ts +++ b/packages/adapters/src/executor-tools.test.ts @@ -64,7 +64,14 @@ describe("builtin tools per bot", () => { }); it("lists main-only tools for the main bot alone", () => { - expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual(["delegate_task", "update_user_profile"]); + expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([ + "delegate_task", + "log_check_in", + "update_goal", + "update_habit", + "update_user_profile", + "write_daily_brief", + ]); const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false }); const main = builtinToolNamesForBot({ kind: "main", chatLinked: false }); for (const name of MAIN_ONLY_BUILTIN_TOOLS) { diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index 2e819b8..d19477b 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -1,6 +1,7 @@ import type { ConnectorTool } from "@rakazo/adapter-kit"; import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; +import { COACH_TOOL_NAMES } from "./coach-tools.js"; import { selectMemoryTools } from "./memory-tools.js"; import { filterImageReturningComputerTools } from "./model-vision.js"; import { @@ -20,6 +21,13 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet = new Set([ "delegate_task", "update_user_profile", + ...COACH_TOOL_NAMES, +]); + +/** Tools that write what the user's bots know about the user, so only the user may drive them. */ +const USER_MODEL_TOOLS: ReadonlySet = new Set([ + "update_user_profile", + ...COACH_TOOL_NAMES, ]); const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name); @@ -105,15 +113,10 @@ export function selectBuiltinToolsForRun(options: { Boolean(options.cloudAgentEnabled), ).filter( (tool) => - !(options.foreignAgentRun && tool.name === "update_user_profile") && + !(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) && (!options.messagingChannelRun || - (![ - "remember", - "save_memory", - "recall_memory", - "forget_memory", - "update_user_profile", - ].includes(tool.name) && + (!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && + !USER_MODEL_TOOLS.has(tool.name) && !tool.name.startsWith("scratchpad_"))), ); return filterBuiltinToolsForBot( diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 5cb9181..24899ff 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -96,6 +96,7 @@ import { InvalidSpaceNameError, isTooManyDatabaseConnections, listSpaceUserBotIds, + loadCoachSummary, loadRunHistoryMessages, loadUserContext, type McpServer, @@ -175,6 +176,7 @@ import { type CloudAgentConnection, cloudAgentsEnabled } from "./cloud-agent-fac import { executeCloudAgentTool } from "./cloud-agent-service.js"; import { validCloudAgentArgs } from "./cloud-agent-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; +import { executeCoachTool, isCoachToolName } from "./coach-tools.js"; import { collectLogIds, mergeConnectedPlugins, @@ -2573,6 +2575,11 @@ export function createRunExecutor(deps: ExecutorDeps) { ), ); } + if (isCoachToolName(name)) { + return finish( + await executeCoachTool(deps.prisma, { userId: run.userId, bot }, name, args), + ); + } if (name === "web_search") { return finish(await webSearchFromTool(web, context, args)); } @@ -3689,8 +3696,13 @@ export function createRunExecutor(deps: ExecutorDeps) { : undefined; const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone); // Replies to another owner's agent, like a shared channel, only get the basics. + const basicsOnly = messagingChannelRun || foreignAgentRun; const userInstruction = formatUserContext(userContext, { - basicsOnly: messagingChannelRun || foreignAgentRun, + basicsOnly, + coach: + bot.kind === "main" && !basicsOnly + ? await loadCoachSummary(deps.prisma, run.userId) + : undefined, }); const userBlock = userInstruction ? redactSecrets(userInstruction, runSecrets) : undefined; diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index 6a19544..6d66c41 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -6,6 +6,7 @@ import type { } from "@rakazo/adapter-kit"; import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core"; +import { COACH_ROUTINES } from "./coach-rhythm.js"; const running = new Map(); @@ -15,6 +16,56 @@ const LEARN_ABOUT_ME = new RegExp( "i", ); +/** "set a goal: …", "track a habit: …", "done today: " and "write today's brief: …". */ +const COACH_COMMANDS: Array<{ + pattern: RegExp; + reply: string; + call: (value: string) => { name: string; args: Record }; +}> = [ + { + pattern: /^set a goal:\s*([\s\S]+)$/i, + reply: "goal noted.", + call: (title) => ({ name: "update_goal", args: { title } }), + }, + { + pattern: /^track a habit:\s*([\s\S]+)$/i, + reply: "habit noted.", + call: (title) => ({ name: "update_habit", args: { title } }), + }, + { + pattern: /^done today:\s*([\s\S]+)$/i, + reply: "checked off.", + call: (habit) => ({ name: "log_check_in", args: { habit, done: true } }), + }, + { + pattern: /^write today's brief:\s*([\s\S]+)$/i, + reply: "today's brief is ready.", + call: (text) => ({ name: "write_daily_brief", args: { text } }), + }, +]; + +/** The morning check-in writes a fixed brief so tests can follow it to the Today page. */ +export const SCRIPTED_MORNING_BRIEF = "One thing at a time today."; + +function inferCoachScript(prompt: string): NonNullable | undefined { + if (prompt.startsWith(COACH_ROUTINES.morning.prompt)) { + return [ + { + assistant: "good morning. today's brief is ready.", + toolCalls: [{ name: "write_daily_brief", args: { text: SCRIPTED_MORNING_BRIEF } }], + complete: true, + }, + ]; + } + for (const command of COACH_COMMANDS) { + const value = command.pattern.exec(prompt.trim())?.[1]?.trim(); + if (value) { + return [{ assistant: command.reply, toolCalls: [command.call(value)], complete: true }]; + } + } + return undefined; +} + export class ScriptedAgentRuntime implements AgentRuntime { describe() { return { @@ -207,6 +258,8 @@ export function inferScript( }, ]; } + const coach = inferCoachScript(prompt); + if (coach) return coach; if (lower.includes("delegate to the bot named")) { const name = namedBot(prompt) ?? "Researcher"; const brief = From 84dff4d84616ade6331ea7ffae51dc6f132f5539 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:38 +0000 Subject: [PATCH 09/38] refactor(memory): derive the run's memory budget from the document limit The prompt budget was a separate 32 KB constant; it is now twice the per-document limit, so a full bot document and a full space document keep fitting if either number changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/memory-context.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/adapters/src/memory-context.ts b/packages/adapters/src/memory-context.ts index e53d266..901ba7e 100644 --- a/packages/adapters/src/memory-context.ts +++ b/packages/adapters/src/memory-context.ts @@ -1,6 +1,8 @@ import type { AdapterContext, MemorySnapshot, MemoryStore } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES } from "@rakazo/adapter-kit"; -const MAX_AGENT_MEMORY_BYTES = 32 * 1024; +/** Room for a full bot document and a full space document. */ +const MAX_AGENT_MEMORY_BYTES = 2 * MEMORY_DOCUMENT_MAX_BYTES; const OMITTED_MARKER = "[earlier notes omitted]\n"; type ScopedMemoryDocument = MemorySnapshot["documents"][number] & { From ddf13adf08010cc49b2529bb8a04419f1a34a7f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:38 +0000 Subject: [PATCH 10/38] feat(profile): edit facts in place in About you About you could only confirm or delete what the bots know about the user, so a fact that was slightly wrong or filed under the wrong category had to be deleted and taught again, and a fact could not be limited to some spaces from the app even though the profile supports it. - Each fact gets an Edit button that turns its row into an inline editor: wording, category, and (when the user has more than one space, or the fact is already limited) which spaces it shows in. Escape or Cancel leaves it unchanged; only changed fields are sent through profile.update editFacts. - A limited fact names its spaces next to its source. - The repository validates edits like new facts: a wording whose normalized form is empty or equals another fact of the user is refused, all edits of one update apply atomically, and the stored embedding is only dropped when the wording really changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/user-profile-edit.spec.ts | 74 ++++++++++ apps/web/src/components/UserFactEditor.tsx | 129 ++++++++++++++++++ .../src/components/UserProfileSettings.tsx | 118 ++++++++++++---- apps/web/src/lib/user-profile-view.test.ts | 44 ++++++ apps/web/src/lib/user-profile-view.ts | 57 +++++++- packages/db/src/user-profile.postgres.test.ts | 60 ++++++++ packages/db/src/user-profile.ts | 70 ++++++---- .../testkit/src/user-profile.postgres.test.ts | 41 ++++++ 8 files changed, 537 insertions(+), 56 deletions(-) create mode 100644 apps/web/e2e/user-profile-edit.spec.ts create mode 100644 apps/web/src/components/UserFactEditor.tsx diff --git a/apps/web/e2e/user-profile-edit.spec.ts b/apps/web/e2e/user-profile-edit.spec.ts new file mode 100644 index 0000000..612ab13 --- /dev/null +++ b/apps/web/e2e/user-profile-edit.spec.ts @@ -0,0 +1,74 @@ +/** + * E2E: Settings → About you edits a fact in place: its wording, category and the spaces it + * shows in. + */ +import { expect, test } from "@playwright/test"; +import type { UserProfileState } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; + +test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester"); + await completeOnboarding(page); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + await rpc(page, "profile/update", { + addFacts: [{ category: "preferences", value: "Prefers tea" }], + }); + + const settings = await openUserSettings(page, "profile"); + const profile = settings.getByTestId("user-profile-settings"); + const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" }); + await tea.getByRole("button", { name: "Edit" }).click(); + + const editor = profile.getByTestId("user-fact-editor"); + const wording = editor.getByRole("textbox", { name: "Fact" }); + await expect(wording).toBeFocused(); + await wording.fill("Prefers green tea"); + await editor.getByRole("combobox", { name: "Category" }).selectOption("habits"); + await editor.getByRole("button", { name: "All spaces" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Side project" }).click(); + await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked(); + await page.keyboard.press("Escape"); + await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible(); + await captureScreenshot(page, testInfo, "user-profile-edit"); + + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + const edited = profile + .getByTestId("user-facts-habits") + .getByTestId("user-fact") + .filter({ hasText: "Prefers green tea" }); + await expect(edited).toContainText("Side project"); + await expect(tea).toHaveCount(0); + const teaFact = async () => + (await rpc(page, "profile/get", null)).facts.find((fact) => + fact.value.includes("tea"), + ); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + category: "habits", + allSpaces: false, + spaceIds: [side.id], + }); + + // Escape leaves the fact as it was; showing it everywhere again clears the limit. + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded"); + await page.keyboard.press("Escape"); + await expect(editor).toHaveCount(0); + await expect(edited).toContainText("Prefers green tea"); + await expect(settings).toBeVisible(); + + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("button", { name: "Side project" }).click(); + await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click(); + await page.keyboard.press("Escape"); + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + await expect(edited).not.toContainText("Side project"); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + allSpaces: true, + spaceIds: [], + }); +}); diff --git a/apps/web/src/components/UserFactEditor.tsx b/apps/web/src/components/UserFactEditor.tsx new file mode 100644 index 0000000..a7b7e9a --- /dev/null +++ b/apps/web/src/components/UserFactEditor.tsx @@ -0,0 +1,129 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { UserFact, UserFactCategory } from "@rakazo/contracts"; +import { USER_FACT_CATEGORIES, USER_FACT_VALUE_MAX_LENGTH } from "@rakazo/contracts"; +import { + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + NativeSelect, + NativeSelectOption, +} from "@rakazo/ui-web"; +import { ChevronDownIcon } from "lucide-react"; +import { useState } from "react"; +import type { UserFactDraft } from "../lib/user-profile-view"; +import { factSpaceNames, toggleFactSpace, userFactDraft } from "../lib/user-profile-view"; + +/** Edits one fact in place: its wording, category and, with more than one space, where it shows. */ +export function UserFactEditor({ + fact, + spaces, + categoryLabels, + busy, + onSave, + onCancel, +}: { + fact: UserFact; + /** The user's spaces; the visibility control appears with more than one. */ + spaces: ReadonlyArray<{ id: string; name: string }>; + categoryLabels: Record; + busy: boolean; + onSave: (draft: UserFactDraft) => void; + onCancel: () => void; +}) { + const { t } = useLingui(); + const [draft, setDraft] = useState(() => userFactDraft(fact)); + const allSpaces = t`All spaces`; + const showSpaces = spaces.length > 1 || draft.spaceIds !== null; + + return ( +
  • +
    { + event.preventDefault(); + if (draft.value.trim()) onSave(draft); + }} + onKeyDown={(event) => { + // The spaces menu renders in a portal; its Escape only closes the menu. + if (event.key === "Escape" && event.currentTarget.contains(event.target as Node)) { + event.stopPropagation(); + onCancel(); + } + }} + > + setDraft({ ...draft, value: event.target.value })} + /> +
    + + setDraft({ ...draft, category: event.target.value as UserFactCategory }) + } + > + {USER_FACT_CATEGORIES.map((category) => ( + + {categoryLabels[category]} + + ))} + + {showSpaces ? ( + + } + > + + {factSpaceNames(draft.spaceIds, spaces).join(", ") || allSpaces} + + + + setDraft({ ...draft, spaceIds: null })} + > + {allSpaces} + + + {spaces.map((space) => ( + + setDraft({ + ...draft, + spaceIds: toggleFactSpace(draft.spaceIds, space.id, checked), + }) + } + > + + {space.name} + + + ))} + + + ) : null} +
    + + +
    +
    +
    +
  • + ); +} diff --git a/apps/web/src/components/UserProfileSettings.tsx b/apps/web/src/components/UserProfileSettings.tsx index 6a8e2e3..3811a9a 100644 --- a/apps/web/src/components/UserProfileSettings.tsx +++ b/apps/web/src/components/UserProfileSettings.tsx @@ -10,7 +10,7 @@ import { AlertDialogTitle, Button, } from "@rakazo/ui-web"; -import { XIcon } from "lucide-react"; +import { PencilIcon, XIcon } from "lucide-react"; import type { ReactNode } from "react"; import { useEffect, useId, useState } from "react"; import { getActiveUiLocale } from "../lib/i18n"; @@ -20,9 +20,12 @@ import { CLEARED_PROFILE_FIELD, displayLanguageTag, displayRegionTag, + factSpaceNames, filledProfileFields, groupUserFacts, + userFactEdit, } from "../lib/user-profile-view"; +import { UserFactEditor } from "./UserFactEditor"; function Row({ value, @@ -51,14 +54,30 @@ function Row({ ); } -/** Everything the user's bots know about the user, with consent and forget controls. */ +/** Everything the user's bots know about the user, with edit, consent and forget controls. */ export function UserProfileSettings() { const { t } = useLingui(); const [state, setState] = useState(null); + const [spaces, setSpaces] = useState>([]); + const [editingId, setEditingId] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [forgetOpen, setForgetOpen] = useState(false); + useEffect(() => { + let cancelled = false; + // Only names a fact's spaces; without them the profile still works. + rpc.spaces + .list() + .then((navigation) => { + if (!cancelled) setSpaces(navigation.spaces.map(({ id, name }) => ({ id, name }))); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { let cancelled = false; rpc.profile @@ -74,14 +93,16 @@ export function UserProfileSettings() { }; }, [t]); - async function apply(change: () => Promise) { - if (busy) return; + async function apply(change: () => Promise): Promise { + if (busy) return false; setBusy(true); setError(null); try { setState(await change()); + return true; } catch (err) { setError(err instanceof Error ? err.message : t`Couldn't save`); + return false; } finally { setBusy(false); } @@ -178,36 +199,75 @@ export function UserProfileSettings() { {categoryLabels[group.category]}
      - {group.facts.map((fact) => ( - ( - <> - {fact.status === "pending" ? ( + {group.facts.map((fact) => + fact.id === editingId ? ( + setEditingId(null)} + onSave={(draft) => { + const edit = userFactEdit(fact, draft); + if (!edit) { + setEditingId(null); + return; + } + void apply(() => rpc.profile.update({ editFacts: [edit] })).then((saved) => { + if (saved) setEditingId(null); + }); + }} + /> + ) : ( + ( + <> + {fact.status === "pending" ? ( + + ) : null} - ) : null} - {deleteButton( - describedBy, - () => void apply(() => rpc.profile.forget({ factIds: [fact.id] })), - )} - - )} - /> - ))} + {deleteButton( + describedBy, + () => void apply(() => rpc.profile.forget({ factIds: [fact.id] })), + )} + + )} + /> + ), + )}
    ))} diff --git a/apps/web/src/lib/user-profile-view.test.ts b/apps/web/src/lib/user-profile-view.test.ts index 0843590..0f774c6 100644 --- a/apps/web/src/lib/user-profile-view.test.ts +++ b/apps/web/src/lib/user-profile-view.test.ts @@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest"; import { displayLanguageTag, displayRegionTag, + factSpaceNames, filledProfileFields, groupUserFacts, + toggleFactSpace, + userFactDraft, + userFactEdit, } from "./user-profile-view"; function fact(overrides: Partial & Pick): UserFact { @@ -74,3 +78,43 @@ describe("user profile view", () => { expect(displayRegionTag("not a tag", "en")).toBe("not a tag"); }); }); + +describe("user fact editing", () => { + const tea = fact({ id: "tea", value: "Prefers tea", category: "preferences" }); + const limited = fact({ id: "lin", allSpaces: false, spaceIds: ["space-b", "space-a"] }); + + it("sends only what changed, or nothing", () => { + expect(userFactEdit(tea, userFactDraft(tea))).toBeNull(); + expect(userFactEdit(tea, { ...userFactDraft(tea), value: " Prefers tea " })).toBeNull(); + expect( + userFactEdit(limited, { ...userFactDraft(limited), spaceIds: ["space-a", "space-b"] }), + ).toBeNull(); + expect( + userFactEdit(tea, { value: " Prefers green tea ", category: "habits", spaceIds: null }), + ).toEqual({ id: "tea", value: "Prefers green tea", category: "habits" }); + expect(userFactEdit(tea, { ...userFactDraft(tea), spaceIds: ["space-a"] })).toEqual({ + id: "tea", + spaceIds: ["space-a"], + }); + expect(userFactEdit(limited, { ...userFactDraft(limited), spaceIds: null })).toEqual({ + id: "lin", + spaceIds: null, + }); + }); + + it("limits a fact to checked spaces and shows it everywhere when none is left", () => { + expect(toggleFactSpace(null, "space-a", true)).toEqual(["space-a"]); + expect(toggleFactSpace(["space-a"], "space-b", true)).toEqual(["space-a", "space-b"]); + expect(toggleFactSpace(["space-a", "space-b"], "space-a", false)).toEqual(["space-b"]); + expect(toggleFactSpace(["space-a"], "space-a", false)).toBeNull(); + }); + + it("names the spaces of a limited fact", () => { + const spaces = [ + { id: "space-a", name: "Home" }, + { id: "space-b", name: "Side project" }, + ]; + expect(factSpaceNames(["space-b", "gone"], spaces)).toEqual(["Side project"]); + expect(factSpaceNames(null, spaces)).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/user-profile-view.ts b/apps/web/src/lib/user-profile-view.ts index 97877dc..623e9be 100644 --- a/apps/web/src/lib/user-profile-view.ts +++ b/apps/web/src/lib/user-profile-view.ts @@ -1,6 +1,15 @@ -import type { UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts"; +import type { UpdateUserProfile, UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts"; import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; +/** What the fact editor holds: null spaces means every space. */ +export interface UserFactDraft { + value: string; + category: UserFactCategory; + spaceIds: string[] | null; +} + +type UserFactEdit = NonNullable[number]; + export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary"; const PROFILE_FIELDS: ProfileField[] = [ @@ -75,3 +84,49 @@ export function displayRegionTag(tag: string, uiLocale: string): string { return tag; } } + +export function userFactDraft(fact: UserFact): UserFactDraft { + return { + value: fact.value, + category: fact.category, + spaceIds: fact.allSpaces ? null : fact.spaceIds, + }; +} + +/** The `editFacts` entry for what changed in the draft, or null when nothing did. */ +export function userFactEdit(fact: UserFact, draft: UserFactDraft): UserFactEdit | null { + const edit: UserFactEdit = { id: fact.id }; + const value = draft.value.trim(); + if (value !== fact.value) edit.value = value; + if (draft.category !== fact.category) edit.category = draft.category; + const before = userFactDraft(fact).spaceIds; + const sameSpaces = + before === null || draft.spaceIds === null + ? before === draft.spaceIds + : before.length === draft.spaceIds.length && + draft.spaceIds.every((spaceId) => before.includes(spaceId)); + if (!sameSpaces) edit.spaceIds = draft.spaceIds; + return Object.keys(edit).length > 1 ? edit : null; +} + +/** Checks or unchecks one space; unchecking the last one shows the fact everywhere again. */ +export function toggleFactSpace( + spaceIds: string[] | null, + spaceId: string, + checked: boolean, +): string[] | null { + const current = spaceIds ?? []; + const next = checked + ? [...current.filter((id) => id !== spaceId), spaceId] + : current.filter((id) => id !== spaceId); + return next.length > 0 ? next : null; +} + +/** Names of the spaces a limited fact shows in, in the order of `spaces`; unknown ids are skipped. */ +export function factSpaceNames( + spaceIds: string[] | null, + spaces: ReadonlyArray<{ id: string; name: string }>, +): string[] { + if (!spaceIds) return []; + return spaces.filter((space) => spaceIds.includes(space.id)).map((space) => space.name); +} diff --git a/packages/db/src/user-profile.postgres.test.ts b/packages/db/src/user-profile.postgres.test.ts index 03e10b0..d1c218c 100644 --- a/packages/db/src/user-profile.postgres.test.ts +++ b/packages/db/src/user-profile.postgres.test.ts @@ -179,6 +179,66 @@ describePostgres("user profile (PostgreSQL)", () => { }); }); + it("edits a fact in place, refusing a wording another fact already has", async () => { + const { userId, homeSpaceId } = await newUser(); + const work = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: "Work", + }); + const [tea, coffee] = await updateUserProfile(prisma, userId, { + addFacts: [ + { category: "preferences", value: "Prefers tea", source: "user" }, + { category: "preferences", value: "Drinks coffee at work", source: "conversation" }, + ], + }); + await prisma.userFact.update({ + where: { id: tea!.id }, + data: { embedding: [0.1, 0.2], embeddingModel: "test-embedding" }, + }); + + // Same wording, new category and visibility: the embedding still fits. + await updateUserProfile(prisma, userId, { + editFacts: [{ id: tea!.id, value: " Prefers tea ", category: "habits", spaceIds: [work.id] }], + }); + expect(await prisma.userFact.findUniqueOrThrow({ where: { id: tea!.id } })).toMatchObject({ + value: "Prefers tea", + category: "habits", + allSpaces: false, + embedding: [0.1, 0.2], + embeddingModel: "test-embedding", + }); + expect( + (await getUserProfileState(prisma, userId)).facts.find((fact) => fact.id === tea!.id), + ).toMatchObject({ allSpaces: false, spaceIds: [work.id] }); + + for (const value of ["“prefers TEA!”", "..."]) { + await expect( + updateUserProfile(prisma, userId, { + editFacts: [ + { id: coffee!.id, category: "work" }, + { id: coffee!.id, value }, + ], + }), + ).rejects.toBeInstanceOf(InvalidUserFactError); + } + // The whole update failed, the category change included. + expect(await prisma.userFact.findUniqueOrThrow({ where: { id: coffee!.id } })).toMatchObject({ + value: "Drinks coffee at work", + category: "preferences", + }); + + // A fact may take a wording that an earlier edit in the same update gave up. + await updateUserProfile(prisma, userId, { + editFacts: [ + { id: tea!.id, value: "Prefers green tea" }, + { id: coffee!.id, value: "prefers tea" }, + ], + }); + const values = (await getUserProfileState(prisma, userId)).facts.map((fact) => fact.value); + expect(values.sort()).toEqual(["Prefers green tea", "prefers tea"]); + }); + it("limits a fact to the user's own spaces", async () => { const owner = await newUser(); const other = await newUser("Grace"); diff --git a/packages/db/src/user-profile.ts b/packages/db/src/user-profile.ts index c8f80f3..da24418 100644 --- a/packages/db/src/user-profile.ts +++ b/packages/db/src/user-profile.ts @@ -250,36 +250,54 @@ async function addFacts( return results; } -async function editFact( +/** + * Applies the user's fact edits in order. A new wording must still say something and must not + * repeat another fact, compared the way new facts are. + */ +async function editFacts( tx: Prisma.TransactionClient, userId: string, - edit: UserFactEdit, + edits: UserFactEdit[], ): Promise { - const fact = await tx.userFact.findFirst({ - where: { id: edit.id, userId }, - select: { id: true }, + if (edits.length === 0) return; + const facts = await tx.userFact.findMany({ + where: { userId }, + select: { id: true, value: true }, }); - if (!fact) throw new UserFactNotFoundError(); - const data: Prisma.UserFactUpdateInput = {}; - if (edit.category !== undefined) { - data.category = edit.category; - data.sensitive = isSensitiveUserFactCategory(edit.category); + const values = new Map(facts.map((fact) => [fact.id, fact.value])); + for (const edit of edits) { + const current = values.get(edit.id); + if (current === undefined) throw new UserFactNotFoundError(); + const data: Prisma.UserFactUpdateInput = {}; + if (edit.category !== undefined) { + data.category = edit.category; + data.sensitive = isSensitiveUserFactCategory(edit.category); + } + if (edit.value !== undefined && edit.value.trim() !== current) { + const value = edit.value.trim(); + const key = normalizeUserFactValue(value); + if (!key) throw new InvalidUserFactError("Fact is empty"); + for (const [id, other] of values) { + if (id !== edit.id && normalizeUserFactValue(other) === key) { + throw new InvalidUserFactError("This fact already exists"); + } + } + values.set(edit.id, value); + data.value = value; + // The stored embedding described the old wording. + data.embedding = []; + data.embeddingModel = null; + } + if (edit.spaceIds !== undefined) { + const spaces = visibleSpacesOf(edit.spaceIds); + data.allSpaces = spaces === null; + data.visibleSpaces = { + deleteMany: {}, + ...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}), + }; + } + if (Object.keys(data).length > 0) await tx.userFact.update({ where: { id: edit.id }, data }); } - if (edit.value !== undefined) { - data.value = edit.value.trim(); - // The stored embedding described the old wording. - data.embedding = []; - data.embeddingModel = null; - } - if (edit.spaceIds !== undefined) { - const spaces = visibleSpacesOf(edit.spaceIds); - data.allSpaces = spaces === null; - data.visibleSpaces = { - deleteMany: {}, - ...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}), - }; - } - await tx.userFact.update({ where: { id: fact.id }, data }); } async function confirmFacts( @@ -341,7 +359,7 @@ export async function updateUserProfile( }); } const added = await addFacts(tx, userId, update.addFacts ?? []); - for (const edit of update.editFacts ?? []) await editFact(tx, userId, edit); + await editFacts(tx, userId, update.editFacts ?? []); await confirmFacts(tx, userId, update.confirmFactIds ?? []); return added; }); diff --git a/packages/testkit/src/user-profile.postgres.test.ts b/packages/testkit/src/user-profile.postgres.test.ts index 11b3ede..462e461 100644 --- a/packages/testkit/src/user-profile.postgres.test.ts +++ b/packages/testkit/src/user-profile.postgres.test.ts @@ -161,6 +161,47 @@ describeWithDatabase("user profile through the API and the main bot", () => { expect(forgotten.facts).toEqual([]); expect(forgotten.profile).toMatchObject({ timeZone: null, language: null, summary: "" }); }); + + it("edits a fact's wording, category and spaces for its owner only", async () => { + const cookie = await signup(app, `profile-edit-${stamp}@rakazo.test`, "Profile Edit"); + const intruder = await signup(app, `profile-edit-other-${stamp}@rakazo.test`, "Intruder"); + const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" }); + const elsewhere = await rpc<{ id: string }>(app, intruder, "spaces/create", { name: "Theirs" }); + const added = await rpc(app, cookie, "profile/update", { + addFacts: [ + { category: "preferences", value: "Prefers tea" }, + { category: "work", value: "Works late" }, + ], + }); + const tea = added.facts.find((fact) => fact.value === "Prefers tea")!; + + const edited = await rpc(app, cookie, "profile/update", { + editFacts: [ + { id: tea.id, value: "Prefers green tea", category: "habits", spaceIds: [side.id] }, + ], + }); + expect(edited.facts.find((fact) => fact.id === tea.id)).toMatchObject({ + value: "Prefers green tea", + category: "habits", + allSpaces: false, + spaceIds: [side.id], + }); + + const edit = (body: unknown, who = cookie) => raw(app, who, "profile/update", body); + expect((await edit({ editFacts: [{ id: tea.id, value: "works late!" }] })).status).toBe(400); + expect((await edit({ editFacts: [{ id: tea.id, spaceIds: [elsewhere.id] }] })).status).toBe( + 400, + ); + expect((await edit({ editFacts: [{ id: tea.id, value: "Stolen" }] }, intruder)).status).toBe( + 404, + ); + expect((await rpc(app, intruder, "profile/get")).facts).toEqual([]); + expect( + (await rpc(app, cookie, "profile/get")).facts.find( + (fact) => fact.id === tea.id, + ), + ).toMatchObject({ value: "Prefers green tea", spaceIds: [side.id] }); + }); }); async function signup(app: App, email: string, name: string) { From e13dbe5ffaa7255586908b507afd6d2f21bcd6e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:39 +0000 Subject: [PATCH 11/38] feat(inbox): list and answer items from all of the user's spaces The inbox in the main conversation only showed asks, approvals, takeovers and unread threads of the current space. The main conversation lives in the home space, so anything a bot needed in a project space never reached it. - inbox.list now covers every space the user belongs to (except spaces being deleted), still only the user's own bots, runs and threads. Items from another space carry that space's id and name. - The web inbox shows the space name next to the bot for those items, answers them with that space's header so the existing thread procedures check membership and ownership for that space, and opens their thread in it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/inbox.test.ts | 36 ++++++++++ apps/api/src/inbox.ts | 47 ++++++++++-- apps/web/e2e/inbox.spec.ts | 69 ++++++++++++++++++ apps/web/src/components/MainInbox.test.tsx | 79 ++++++++++++++++---- apps/web/src/components/MainInbox.tsx | 60 +++++++++++----- apps/web/src/pages/Shell.tsx | 7 +- packages/contracts/src/inbox.ts | 9 ++- packages/contracts/src/rpc.ts | 2 +- packages/testkit/src/authorization.test.ts | 10 +++ packages/testkit/src/inbox.test.ts | 83 +++++++++++++++++++--- 10 files changed, 351 insertions(+), 51 deletions(-) diff --git a/apps/api/src/inbox.test.ts b/apps/api/src/inbox.test.ts index 08360a1..f5e754d 100644 --- a/apps/api/src/inbox.test.ts +++ b/apps/api/src/inbox.test.ts @@ -18,9 +18,11 @@ function run( status: "waiting_input" | "waiting_takeover", thread: Partial = {}, bot = scout, + spaceId = "space-home", ): InboxRunRow { return { id, + spaceId, status, updatedAt: at(0), bot, @@ -48,8 +50,10 @@ const approval = { function unreadThread( bot: InboxUnreadThreadRow["bot"], latest?: { id: string; text: string; minute: number }, + spaceId = "space-home", ): InboxUnreadThreadRow { return { + spaceId, createdAt: at(0), bot, messages: latest @@ -227,4 +231,36 @@ describe("buildInboxItems", () => { }); expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() }); }); + + it("names the space of items from the user's other spaces", () => { + const side = { id: "space-side", name: "Side project" }; + const items = buildInboxItems({ + runs: [ + run("run-home", "waiting_input"), + run("run-side", "waiting_input", {}, writer, side.id), + run("takeover-side", "waiting_takeover", {}, writer, side.id), + ], + messages: [ + message("ask-home", "run-home", [question], 1), + message("ask-side", "run-side", [question], 2), + ], + unreadThreads: [ + unreadThread( + { id: "bot-quiet", name: "Quiet", color: "#3EC5A8" }, + { id: "quiet-latest", text: "Done", minute: 3 }, + side.id, + ), + ], + otherSpaces: new Map([[side.id, side]]), + }); + + expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([ + ["takeover", "Side project"], + ["ask", null], + ["ask", "Side project"], + ["unread", "Side project"], + ]); + expect(items[1]).not.toHaveProperty("space"); + expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } }); + }); }); diff --git a/apps/api/src/inbox.ts b/apps/api/src/inbox.ts index 549d5a6..23162b6 100644 --- a/apps/api/src/inbox.ts +++ b/apps/api/src/inbox.ts @@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { promptSnippet } from "./runs.js"; -/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */ +/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */ const INBOX_RUN_LIMIT = 50; const INBOX_UNREAD_LIMIT = 50; const INBOX_PREVIEW_LENGTH = 280; @@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280; type AskBlock = Extract; type ComputerBlock = Extract; type InboxBot = { id: string; name: string; color: string }; +type InboxSpace = NonNullable; export interface InboxRunRow { id: string; + spaceId: string; status: string; updatedAt: Date; bot: InboxBot; @@ -33,6 +35,7 @@ export interface InboxMessageRow { } export interface InboxUnreadThreadRow { + spaceId: string; createdAt: Date; bot: InboxBot | null; /** The newest bot message, if any. */ @@ -95,14 +98,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number { /** * Turns waiting runs, their ask and takeover messages, and unread threads into inbox items: * everything that waits on the user first, then unread threads not already covered, each - * oldest first. + * oldest first. Items from another space name it. */ export function buildInboxItems(input: { runs: InboxRunRow[]; /** Ask and takeover messages of those runs, newest first. */ messages: InboxMessageRow[]; unreadThreads: InboxUnreadThreadRow[]; + /** The user's other spaces by id; rows of any space not listed belong to the current one. */ + otherSpaces?: ReadonlyMap; }): InboxItem[] { + const spaceOf = (spaceId: string) => { + const space = input.otherSpaces?.get(spaceId); + return space ? { space } : {}; + }; const messagesByRun = new Map(); for (const message of input.messages) { if (!message.runId) continue; @@ -122,6 +131,7 @@ export function buildInboxItems(input: { kind: isApprovalAskBlock(pending.block) ? "approval" : "ask", bot: run.bot, target, + ...spaceOf(run.spaceId), runId: run.id, messageId: pending.message.id, ask: pending.block, @@ -135,6 +145,7 @@ export function buildInboxItems(input: { kind: "takeover", bot: run.bot, target: { botId: run.bot.id }, + ...spaceOf(run.spaceId), runId: run.id, messageId: request?.message.id ?? null, text: request?.block.text ?? "", @@ -154,6 +165,7 @@ export function buildInboxItems(input: { kind: "unread", bot: thread.bot, target, + ...spaceOf(thread.spaceId), messageId: latest?.id ?? null, text: previewText(latest?.blocks), createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(), @@ -165,13 +177,34 @@ export function buildInboxItems(input: { const botSelect = { id: true, name: true, color: true } as const; -/** The current space's inbox: only the actor's own bots, runs and threads. */ +/** + * The other spaces whose items the inbox shows: every space the user belongs to, except spaces + * being deleted. + */ +async function otherInboxSpaces( + prisma: PrismaClient, + actor: Actor, +): Promise> { + const memberships = await prisma.spaceMember.findMany({ + where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } }, + select: { space: { select: { id: true, name: true } } }, + }); + return new Map(memberships.map(({ space }) => [space.id, space])); +} + +/** + * The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are + * answered and opened in their own space, where the thread procedures check membership and + * ownership again. + */ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise { + const otherSpaces = await otherInboxSpaces(prisma, actor); + const spaceIds = [actor.spaceId, ...otherSpaces.keys()]; const ownBot = { userId: actor.userId, archivedAt: null }; const [runs, unreadThreads] = await Promise.all([ prisma.run.findMany({ where: { - spaceId: actor.spaceId, + spaceId: { in: spaceIds }, userId: actor.userId, status: { in: ["waiting_input", "waiting_takeover"] }, bot: ownBot, @@ -179,6 +212,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise { + const stamp = Date.now(); + await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross"); + await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 }); + const helper = await createBot(page, "Helper"); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", { + name: "Scout", + title: "", + description: "", + notifyOnFinish: true, + computerMode: "team", + }); + await rpcInSpace(page, side.id, "threads/send", { + botId: scout.id, + text: "ask me which city to use", + }); + const runInSide = async () => + ( + await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", { + botId: scout.id, + }) + ).run ?? null; + await expect + .poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 }) + .toBe("waiting_input"); + + await openMainConversation(page, helper.id); + const inbox = page.getByRole("region", { name: "Needs you" }); + const ask = inbox.locator("[data-kind='ask']"); + await expect(ask).toContainText("Scout", { timeout: 30_000 }); + await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await captureScreenshot(page, testInfo, "inbox-cross-space"); + + await ask.getByRole("button", { name: "Edit first" }).click(); + await ask.getByRole("textbox", { name: "Answer" }).fill("Paris"); + await ask.getByRole("button", { name: "Send answer" }).click(); + await expect(ask).toHaveCount(0); + await expect.poll(runInSide, { timeout: 60_000 }).toBeNull(); + + // The reply arrives as unread from the other space and opens there. + const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" }); + await expect(reply).toContainText("Paris", { timeout: 45_000 }); + await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await reply.getByRole("button").click(); + await page.waitForURL(new RegExp(`/app/${scout.id}$`)); + await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); + expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id); +}); + +async function rpcInSpace( + page: Page, + spaceId: string, + procedure: string, + body: unknown, +): Promise { + const response = await page.request.post(`/rpc/${procedure}`, { + data: { json: body }, + headers: { "x-rakazo-space-id": spaceId }, + }); + const parsed = (await response.json()) as { json?: T; error?: { message?: string } }; + if (!response.ok() || parsed.error) { + throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`); + } + return parsed.json as T; +} + async function createBot(page: Page, name: string) { return rpc<{ id: string }>(page, "bots/create", { name, diff --git a/apps/web/src/components/MainInbox.test.tsx b/apps/web/src/components/MainInbox.test.tsx index 0d6175d..c208258 100644 --- a/apps/web/src/components/MainInbox.test.tsx +++ b/apps/web/src/components/MainInbox.test.tsx @@ -79,6 +79,22 @@ const unread: InboxItem = { text: "Draft ready for review", createdAt, }; +const side = { id: "space-side", name: "Side project" }; +const askInOtherSpace: InboxItem = { + kind: "ask", + bot: writer, + target: { botId: writer.id }, + space: side, + runId: "run-side", + messageId: "msg-side", + ask: { kind: "ask", text: "Which title fits?", status: "pending" }, + createdAt, +}; +const groupAskInOtherSpace: InboxItem = { + ...approvalInGroup, + space: side, + messageId: "msg-side-group", +}; const idleBots = [ { id: scout.id, status: "idle", unread: false, preview: "" }, @@ -89,10 +105,18 @@ let container: HTMLDivElement; let root: Root; const onOpenBot = vi.fn(); const onOpenGroup = vi.fn(); +const onOpenInSpace = vi.fn(); function render(bots = idleBots) { return act(async () => - root.render(), + root.render( + , + ), ); } @@ -111,6 +135,7 @@ beforeEach(() => { api.markRead.mockReset().mockResolvedValue({ ok: true }); onOpenBot.mockReset(); onOpenGroup.mockReset(); + onOpenInSpace.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -154,12 +179,10 @@ it("answers through the thread the ask came from and drops only that item", asyn itemOf("approval").querySelector("[data-answer]")?.click(), ); - expect(api.answer).toHaveBeenCalledWith({ - groupId: "group-ops", - runId: "run-approval", - messageId: "msg-approval", - answer: "Paris", - }); + expect(api.answer).toHaveBeenCalledWith( + { groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris" }, + { context: {} }, + ); expect(api.markRead).not.toHaveBeenCalled(); expect(api.list).toHaveBeenCalledTimes(2); expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1); @@ -173,7 +196,7 @@ it("marks the bot's own thread read before answering, so the question does not r await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); - expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }); + expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }, { context: {} }); expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan( api.answer.mock.invocationCallOrder[0]!, ); @@ -187,12 +210,10 @@ it("refreshes after a failed answer so a stale ask disappears", async () => { await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); - expect(api.answer).toHaveBeenCalledWith({ - botId: scout.id, - runId: "run-ask", - messageId: "msg-ask", - answer: "Paris", - }); + expect(api.answer).toHaveBeenCalledWith( + { botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris" }, + { context: {} }, + ); expect(container.querySelector("[data-testid='main-inbox']")).toBeNull(); }); @@ -208,6 +229,36 @@ it("opens the bot's thread for takeovers and unread threads, and the source thre expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]); }); +it("names another space's items and answers and opens them in that space", async () => { + api.list.mockResolvedValueOnce({ items: [askInOtherSpace, askInOwnThread] }); + api.list.mockResolvedValue({ items: [askInOwnThread, groupAskInOtherSpace] }); + api.answer.mockResolvedValue({ ok: true }); + await render(); + + const items = container.querySelectorAll("[data-testid='main-inbox-item']"); + expect(items[0]?.querySelector("[data-testid='main-inbox-space']")?.textContent).toBe( + "· Side project", + ); + expect(items[1]?.querySelector("[data-testid='main-inbox-space']")).toBeNull(); + + await act(async () => items[0]?.querySelector("[data-answer]")?.click()); + expect(api.markRead).toHaveBeenCalledWith( + { botId: writer.id }, + { context: { spaceId: side.id } }, + ); + expect(api.answer).toHaveBeenCalledWith( + { botId: writer.id, runId: "run-side", messageId: "msg-side", answer: "Paris" }, + { context: { spaceId: side.id } }, + ); + + const [own, group] = container.querySelectorAll("[data-testid='main-inbox-item']"); + await act(async () => group?.querySelector("button")?.click()); + await act(async () => own?.querySelector("button")?.click()); + expect(onOpenInSpace.mock.calls).toEqual([[side.id, "/app/g/group-ops"]]); + expect(onOpenBot.mock.calls).toEqual([[scout.id]]); + expect(onOpenGroup).not.toHaveBeenCalled(); +}); + it("refreshes when a bot's state changes, not on every render", async () => { api.list.mockResolvedValue({ items: [] }); await render(); diff --git a/apps/web/src/components/MainInbox.tsx b/apps/web/src/components/MainInbox.tsx index 5f3007b..8ea65bd 100644 --- a/apps/web/src/components/MainInbox.tsx +++ b/apps/web/src/components/MainInbox.tsx @@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000; type PendingAskItem = Extract; +/** Thread procedures for an item run in the item's own space. */ +function itemContext(item: InboxItem) { + return { context: item.space ? { spaceId: item.space.id } : {} }; +} + /** - * What the user's bots need from them outside the main conversation, with each bot as the - * sender: asks and approvals are answered in place, takeovers and unread threads open the - * bot's thread. + * What the user's bots in all of their spaces need from them outside the main conversation, with + * each bot as the sender: asks and approvals are answered in place, takeovers and unread threads + * open the bot's thread. */ export function MainInbox({ bots, onOpenBot, onOpenGroup, + onOpenInSpace, }: { /** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */ bots: readonly Pick[]; onOpenBot: (botId: string) => void; onOpenGroup: (groupId: string) => void; + /** Opens an app path, such as a thread, in another of the user's spaces. */ + onOpenInSpace: (spaceId: string, path: string) => void; }) { const { t } = useLingui(); const [items, setItems] = useState([]); @@ -59,11 +67,16 @@ export function MainInbox({ }, [refresh]); const open = useCallback( - (target: InboxThreadTarget) => { - if ("botId" in target) onOpenBot(target.botId); + ({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => { + if (space) { + onOpenInSpace( + space.id, + "botId" in target ? `/app/${target.botId}` : `/app/g/${target.groupId}`, + ); + } else if ("botId" in target) onOpenBot(target.botId); else onOpenGroup(target.groupId); }, - [onOpenBot, onOpenGroup], + [onOpenBot, onOpenGroup, onOpenInSpace], ); const answer = useCallback( @@ -72,14 +85,12 @@ export function MainInbox({ // Answering reads the ask, so the bot's thread should not come back as unread with the // same question. Mark it first: the reply to this answer marks it unread again. if ("botId" in item.target) { - await rpc.threads.markRead(item.target).catch(() => undefined); + await rpc.threads.markRead(item.target, itemContext(item)).catch(() => undefined); } - await rpc.threads.answer({ - ...item.target, - runId: item.runId, - messageId: item.messageId, - answer: text, - }); + await rpc.threads.answer( + { ...item.target, runId: item.runId, messageId: item.messageId, answer: text }, + itemContext(item), + ); const key = inboxItemKey(item); setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key)); } finally { @@ -105,10 +116,10 @@ export function MainInbox({
    open(item.target)} + onClick={() => open(item)} className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent" > - + {item.text ? ( {bot.name} + {space ? ( + + + {space.name} + + ) : null} ); } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index bf25ff9..a3b1e3a 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -4637,7 +4637,12 @@ export function ShellPage() { )} {activeGroup?.kind === "main" && !showProjectTimeline ? ( - + ) : null} {recordingSkill ? (
    diff --git a/packages/contracts/src/inbox.ts b/packages/contracts/src/inbox.ts index 7894cd2..3bd318d 100644 --- a/packages/contracts/src/inbox.ts +++ b/packages/contracts/src/inbox.ts @@ -13,6 +13,11 @@ const InboxItemBase = z.object({ /** The bot that needs the user, shown as the sender. */ bot: z.object({ id: Id, name: z.string(), color: z.string() }), target: InboxThreadTargetSchema, + /** + * Set when the item belongs to another of the user's spaces: call the thread procedures and + * open the thread in that space. + */ + space: z.object({ id: Id, name: z.string() }).optional(), createdAt: z.string(), }); @@ -24,8 +29,8 @@ const PendingAskItem = InboxItemBase.extend({ }); /** - * What a bot needs from the user outside the main conversation: an answer, an approval, the - * computer screen, or a look at an unread thread. + * What a bot needs from the user outside the main conversation, in any of the user's spaces: an + * answer, an approval, the computer screen, or a look at an unread thread. */ export const InboxItemSchema = z.discriminatedUnion("kind", [ PendingAskItem.extend({ kind: z.literal("ask") }), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..0d11a12 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -815,7 +815,7 @@ export const appContract = { .input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() })) .output(z.array(DelegatedTaskSchema)), }, - /** Asks, approvals, takeovers and unread threads outside the main conversation. */ + /** Asks, approvals, takeovers and unread threads outside the main conversation, in all spaces. */ inbox: { list: oc.output(InboxListOutputSchema), }, diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ce1e785..a4490cb 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -712,6 +712,16 @@ describeWithDatabase("API authorization and resource isolation", () => { .items, ).toContainEqual(expect.objectContaining({ kind: "ask", runId: run.id })); expect(await rpc(app, member, "inbox/list", {}, sharedSpaceId)).toEqual({ items: [] }); + // From another of the member's spaces the inbox spans the shared one too, still without + // the owner's runs. + const memberSpace = await rpc<{ id: string }>( + app, + member, + "spaces/create", + { name: "Member side" }, + sharedSpaceId, + ); + expect(await rpc(app, member, "inbox/list", {}, memberSpace.id)).toEqual({ items: [] }); }); it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => { diff --git a/packages/testkit/src/inbox.test.ts b/packages/testkit/src/inbox.test.ts index 2fe3d09..22dfc2f 100644 --- a/packages/testkit/src/inbox.test.ts +++ b/packages/testkit/src/inbox.test.ts @@ -94,7 +94,7 @@ describeInbox("inbox.list", () => { expect(await listInbox(app, cookie)).toEqual([]); }); - it("collects approvals, takeovers and unread threads of this space outside the main conversation", async () => { + it("collects approvals, takeovers and unread threads of every space outside the main conversation", async () => { const cookie = await signup(app, `inbox-mixed-${stamp}@rakazo.test`, "Inbox Mixed"); const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); const scout = await createBot(app, cookie, "Scout"); @@ -187,6 +187,14 @@ describeInbox("inbox.list", () => { text: "Sign in to continue.", createdAt: expect.any(String), }, + expect.objectContaining({ + kind: "ask", + bot: expect.objectContaining({ id: remote.id }), + target: { botId: remote.id }, + space: { id: remoteSpace.id, name: "Remote" }, + runId: remoteAsk.runId, + messageId: remoteAsk.messageId, + }), expect.objectContaining({ kind: "unread", bot: expect.objectContaining({ id: scout.id }), @@ -196,16 +204,74 @@ describeInbox("inbox.list", () => { ]); // The Pilot thread is unread too, but its takeover already covers it. expect(items.filter((item) => item.bot.id === pilot.id)).toHaveLength(1); + // Items of the current space carry no space. + expect(items.filter((item) => item.space).map((item) => item.bot.id)).toEqual([remote.id]); - expect(await listInbox(app, cookie, remoteSpace.id)).toEqual([ + // From the other space, the same items with the sides swapped. + const fromRemote = await listInbox(app, cookie, remoteSpace.id); + expect(fromRemote.map((item) => [item.kind, item.space?.id ?? null])).toEqual([ + ["approval", me.spaceId], + ["takeover", me.spaceId], + ["ask", null], + ["unread", me.spaceId], + ]); + }); + + it("answers an item from another space only in that space and only for its owner", async () => { + const cookie = await signup(app, `inbox-cross-${stamp}@rakazo.test`, "Inbox Cross"); + const intruder = await signup(app, `inbox-intruder-${stamp}@rakazo.test`, "Inbox Intruder"); + const me = await rpc<{ spaceId: string }>(app, cookie, "me"); + const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" }); + const scout = await createBot(app, cookie, "Scout", side.id); + + await rpc( + app, + cookie, + "threads/send", + { botId: scout.id, text: "ask me which city to use" }, + side.id, + ); + await waitForThread( + app, + cookie, + scout.id, + (snapshot) => snapshot.run?.status === "waiting_input", + side.id, + ); + + const [item] = await listInbox(app, cookie, me.spaceId); + if (item?.kind !== "ask") throw new Error("expected an ask item"); + expect(item).toMatchObject({ + space: { id: side.id, name: "Side" }, + target: { botId: scout.id }, + }); + const answer = { + ...item.target, + runId: item.runId, + messageId: item.messageId, + answer: "Paris", + }; + + // The thread is not in the current space, and the intruder belongs to neither space. + await expect(rpc(app, cookie, "threads/answer", answer, me.spaceId)).rejects.toThrow(); + await expect(rpc(app, intruder, "threads/answer", answer, side.id)).rejects.toThrow(); + await expect(rpc(app, intruder, "threads/answer", answer)).rejects.toThrow(); + expect(await listInbox(app, intruder)).toEqual([]); + expect((await listInbox(app, cookie, me.spaceId)).map((entry) => entry.kind)).toEqual(["ask"]); + + await rpc(app, cookie, "threads/answer", answer, item.space?.id); + await waitForThread(app, cookie, scout.id, (snapshot) => !snapshot.run, side.id); + expect(await listInbox(app, cookie, me.spaceId)).toEqual([ expect.objectContaining({ - kind: "ask", - bot: expect.objectContaining({ id: remote.id }), - target: { botId: remote.id }, - runId: remoteAsk.runId, - messageId: remoteAsk.messageId, + kind: "unread", + target: { botId: scout.id }, + space: { id: side.id, name: "Side" }, + text: expect.stringContaining("Paris"), }), ]); + + await rpc(app, cookie, "threads/markRead", { botId: scout.id }, side.id); + expect(await listInbox(app, cookie, me.spaceId)).toEqual([]); }); }); @@ -269,11 +335,12 @@ async function waitForThread( cookie: string, botId: string, done: (snapshot: ThreadSnapshot) => boolean, + spaceId?: string, ): Promise { const start = Date.now(); let last: ThreadSnapshot | null = null; while (Date.now() - start < 20_000) { - last = await rpc(app, cookie, "threads/get", { botId }); + last = await rpc(app, cookie, "threads/get", { botId }, spaceId); if (done(last)) return last; await new Promise((resolve) => setTimeout(resolve, 100)); } From 1a3829031fb47f6b3ee56583d74c1d61893b51e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:50 +0000 Subject: [PATCH 12/38] feat(coach): coach RPCs for goals, habits, check-ins, today and rhythm The web app needs to show and change the user's coach data, and the check-in times need a way to change after onboarding. - coach.today, coach.goals.*, coach.habits.*, coach.checkIns.* and coach.rhythm.get/set, owner-only and not space-scoped; another user's ids read as not found, invalid input as a bad request. - coach-rhythm reuses the shared clock-time helpers. - Tests: an API suite covering ownership and validation for every procedure, the coach tools driven through the main bot (and refused for a specialist), and a morning check-in routine that fires in the main conversation and writes today's brief; the unauthenticated authorization sweep lists the new procedures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/coach.ts | 96 +++++++ apps/api/src/router.ts | 47 ++++ packages/adapters/src/coach-rhythm.ts | 11 +- packages/adapters/src/index.ts | 1 + packages/contracts/src/rpc.ts | 39 +++ packages/testkit/src/authorization.test.ts | 13 + packages/testkit/src/cli/harness.ts | 1 + packages/testkit/src/coach.postgres.test.ts | 284 ++++++++++++++++++++ 8 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/coach.ts create mode 100644 packages/testkit/src/coach.postgres.test.ts diff --git a/apps/api/src/coach.ts b/apps/api/src/coach.ts new file mode 100644 index 0000000..2bf3fcc --- /dev/null +++ b/apps/api/src/coach.ts @@ -0,0 +1,96 @@ +import { ORPCError } from "@orpc/server"; +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { getCoachRhythm, setCoachRhythm } from "@rakazo/adapters"; +import type { + Actor, + CheckIn, + CoachRhythm, + CoachToday, + Goal, + Habit, + LogCheckIn, +} from "@rakazo/contracts"; +import type { CheckInFilter, GoalPatch, HabitPatch, PrismaClient } from "@rakazo/db"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + listCheckIns, + listGoals, + listHabits, + loadCoachToday, + logCheckIn, + removeGoal, + removeHabit, + updateGoal, + updateHabit, +} from "@rakazo/db"; + +export interface CoachDeps { + prisma: PrismaClient; + jobs: JobPublisher; +} + +/** Another user's ids read as missing, so a caller cannot probe them. */ +async function mapErrors(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + if (error instanceof CoachNotFoundError) { + throw new ORPCError("NOT_FOUND", { message: error.message }); + } + if (error instanceof InvalidCoachInputError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + } +} + +/** Coach procedures for the signed-in user only; nothing here is space-scoped. */ +export function createCoachService(deps: CoachDeps) { + const { prisma } = deps; + return { + today: (actor: Actor): Promise => loadCoachToday(prisma, actor.userId), + + listGoals: (actor: Actor, input: { status?: Goal["status"] }) => + listGoals(prisma, actor.userId, input), + createGoal: ( + actor: Actor, + input: { + title: string; + detail?: string; + targetDate?: string | null; + spaceId?: string | null; + }, + ) => mapErrors(() => createGoal(prisma, actor.userId, { ...input, source: "user" })), + updateGoal: (actor: Actor, { goalId, ...patch }: GoalPatch & { goalId: string }) => + mapErrors(() => updateGoal(prisma, actor.userId, goalId, patch)), + removeGoal: async (actor: Actor, input: { goalId: string }) => { + await mapErrors(() => removeGoal(prisma, actor.userId, input.goalId)); + return { ok: true as const }; + }, + + listHabits: (actor: Actor, input: { includeInactive?: boolean }): Promise => + listHabits(prisma, actor.userId, input), + createHabit: ( + actor: Actor, + input: { title: string; cadence: Habit["cadence"]; goalId?: string | null }, + ) => mapErrors(() => createHabit(prisma, actor.userId, { ...input, source: "user" })), + updateHabit: (actor: Actor, { habitId, ...patch }: HabitPatch & { habitId: string }) => + mapErrors(() => updateHabit(prisma, actor.userId, habitId, patch)), + removeHabit: async (actor: Actor, input: { habitId: string }) => { + await mapErrors(() => removeHabit(prisma, actor.userId, input.habitId)); + return { ok: true as const }; + }, + + logCheckIn: (actor: Actor, input: LogCheckIn): Promise => + mapErrors(() => logCheckIn(prisma, actor.userId, input)), + listCheckIns: (actor: Actor, input: CheckInFilter) => + mapErrors(() => listCheckIns(prisma, actor.userId, input)), + + getRhythm: (actor: Actor): Promise => getCoachRhythm(prisma, actor.userId), + setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise => + setCoachRhythm(deps, { userId: actor.userId, rhythm }), + }; +} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index b6c7bc6..d0cc995 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -158,6 +158,7 @@ import { createAgentSkillsService } from "./agent-skills.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; +import { createCoachService } from "./coach.js"; import { executionBlocksUserTakeover, resolveBusyBotName, @@ -531,6 +532,7 @@ export function createRouter(deps: RouterDeps) { dataDir: deps.dataDir, }); const agentSkills = createAgentSkillsService(deps.prisma); + const coach = createCoachService(deps); const authed = os.use(async ({ context, next }) => { if (!context.actor) throw new ORPCError("UNAUTHORIZED"); @@ -576,6 +578,51 @@ export function createRouter(deps: RouterDeps) { forgetProfile(deps.prisma, context.actor, input), ), }, + coach: { + today: authed.coach.today.handler(({ context }) => coach.today(context.actor)), + goals: { + list: authed.coach.goals.list.handler(({ context, input }) => + coach.listGoals(context.actor, input), + ), + create: authed.coach.goals.create.handler(({ context, input }) => + coach.createGoal(context.actor, input), + ), + update: authed.coach.goals.update.handler(({ context, input }) => + coach.updateGoal(context.actor, input), + ), + remove: authed.coach.goals.remove.handler(({ context, input }) => + coach.removeGoal(context.actor, input), + ), + }, + habits: { + list: authed.coach.habits.list.handler(({ context, input }) => + coach.listHabits(context.actor, input), + ), + create: authed.coach.habits.create.handler(({ context, input }) => + coach.createHabit(context.actor, input), + ), + update: authed.coach.habits.update.handler(({ context, input }) => + coach.updateHabit(context.actor, input), + ), + remove: authed.coach.habits.remove.handler(({ context, input }) => + coach.removeHabit(context.actor, input), + ), + }, + checkIns: { + log: authed.coach.checkIns.log.handler(({ context, input }) => + coach.logCheckIn(context.actor, input), + ), + list: authed.coach.checkIns.list.handler(({ context, input }) => + coach.listCheckIns(context.actor, input), + ), + }, + rhythm: { + get: authed.coach.rhythm.get.handler(({ context }) => coach.getRhythm(context.actor)), + set: authed.coach.rhythm.set.handler(({ context, input }) => + coach.setRhythm(context.actor, input), + ), + }, + }, spaces: { list: authed.spaces.list.handler(async ({ context }) => spaceNavigationDto(deps, context.actor, repos, groupRepos), diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index 348e26a..6606092 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -1,7 +1,7 @@ import type { JobPublisher } from "@rakazo/adapter-kit"; import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; import type { CoachRhythm } from "@rakazo/contracts"; -import { COACH_RHYTHM_OFF } from "@rakazo/contracts"; +import { COACH_RHYTHM_OFF, clockTimeToMinutes, minutesToClockTime } from "@rakazo/contracts"; import { nextCronDate } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db"; @@ -41,19 +41,16 @@ export interface CoachRhythmDeps { export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null { const slot = key === "weekly" ? rhythm.weekly : rhythm[key]; if (!slot) return null; - const time = typeof slot === "string" ? slot : slot.time; - const [hour, minute] = time.split(":").map(Number); - return `${minute} ${hour} * * ${typeof slot === "string" ? "*" : slot.day}`; + const minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time); + return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`; } -const pad = (value: string) => value.padStart(2, "0"); - /** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */ function rhythmSlot(key: CoachKey, crons: readonly string[]) { const match = crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null; if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return null; - const time = `${pad(match[2]!)}:${pad(match[1]!)}`; + const time = minutesToClockTime(Number(match[2]) * 60 + Number(match[1])); if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time }; return match[3] === "*" ? time : null; } diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 2a841cc..972d83a 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -24,6 +24,7 @@ export * from "./cloud-agent-service.js"; export * from "./cloud-agent-tools.js"; export * from "./cloud-agent-tools-select.js"; export * from "./coach-rhythm.js"; +export * from "./coach-tools.js"; export * from "./composio-catalog-cache.js"; export * from "./composio-connector.js"; export * from "./composio-emulator.js"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1dc59cb..4808b29 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,21 @@ import { eventIterator, oc } from "@orpc/contract"; import * as z from "zod"; import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js"; +import { + CheckInSchema, + CoachRhythmSchema, + CoachTodaySchema, + CreateGoalInput, + CreateHabitInput, + GoalSchema, + HabitSchema, + ListCheckInsInput, + ListGoalsInput, + ListHabitsInput, + LogCheckInInput, + UpdateGoalInput, + UpdateHabitInput, +} from "./coach.js"; import { ActionApprovalRuleSchema, ActionAutoReviewSettingsSchema, @@ -189,6 +204,30 @@ export const appContract = { update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema), forget: oc.input(ForgetUserFactsInput).output(UserProfileStateSchema), }, + /** The user's goals, habits, check-ins and check-in times; owner-only, across spaces. */ + coach: { + today: oc.output(CoachTodaySchema), + goals: { + list: oc.input(ListGoalsInput).output(z.array(GoalSchema)), + create: oc.input(CreateGoalInput).output(GoalSchema), + update: oc.input(UpdateGoalInput).output(GoalSchema), + remove: oc.input(z.object({ goalId: Id })).output(z.object({ ok: z.literal(true) })), + }, + habits: { + list: oc.input(ListHabitsInput).output(z.array(HabitSchema)), + create: oc.input(CreateHabitInput).output(HabitSchema), + update: oc.input(UpdateHabitInput).output(HabitSchema), + remove: oc.input(z.object({ habitId: Id })).output(z.object({ ok: z.literal(true) })), + }, + checkIns: { + log: oc.input(LogCheckInInput).output(CheckInSchema), + list: oc.input(ListCheckInsInput).output(z.array(CheckInSchema)), + }, + rhythm: { + get: oc.output(CoachRhythmSchema), + set: oc.input(CoachRhythmSchema).output(CoachRhythmSchema), + }, + }, spaces: { list: oc.output(SpaceNavigationSchema), create: oc diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index c640e4d..4b75880 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -64,6 +64,19 @@ describeWithDatabase("API authorization and resource isolation", () => { ["profile/get"], ["profile/update", { timeZone: "UTC" }], ["profile/forget", { all: true }], + ["coach/today"], + ["coach/goals/list", {}], + ["coach/goals/create", { title: "Nope" }], + ["coach/goals/update", { goalId: "missing-goal", title: "Nope" }], + ["coach/goals/remove", { goalId: "missing-goal" }], + ["coach/habits/list", {}], + ["coach/habits/create", { title: "Nope" }], + ["coach/habits/update", { habitId: "missing-habit", title: "Nope" }], + ["coach/habits/remove", { habitId: "missing-habit" }], + ["coach/checkIns/log", { kind: "daily", mood: 3 }], + ["coach/checkIns/list", {}], + ["coach/rhythm/get"], + ["coach/rhythm/set", { morning: null, evening: null, weekly: null }], ["deployment/get"], ["deployment/update", { signupsEnabled: true }], ["updater/status"], diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index ffe58e9..21a5f03 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -106,6 +106,7 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/coach.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", diff --git a/packages/testkit/src/coach.postgres.test.ts b/packages/testkit/src/coach.postgres.test.ts new file mode 100644 index 0000000..7f86a1a --- /dev/null +++ b/packages/testkit/src/coach.postgres.test.ts @@ -0,0 +1,284 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { COACH_ROUTINES, ComposioEmulator, SCRIPTED_MORNING_BRIEF } from "@rakazo/adapters"; +import type { + CheckIn, + CoachRhythm, + CoachToday, + Goal, + Habit, + Routine, + SpaceNavigation, +} from "@rakazo/contracts"; +import { ensureMainBot, ensureMainConversation } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; + +describeWithDatabase("coach through the API and the main bot", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-coach-")); + + beforeAll(async () => { + const { createApp } = await import("../../../apps/api/src/app.ts"); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + composio: new ComposioEmulator(), + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + async function waitForRun(runId: string) { + const start = Date.now(); + while (Date.now() - start < 20_000) { + const run = await handles.prisma.run.findUnique({ + where: { id: runId }, + select: { id: true, status: true, error: true, threadId: true }, + }); + if (run && ["completed", "failed", "cancelled"].includes(run.status)) { + expect(run, run.error ?? "").toMatchObject({ status: "completed" }); + return run; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`run ${runId} did not finish`); + } + + async function sendAndWait(cookie: string, botId: string, text: string) { + const { runId } = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId, text }); + return waitForRun(runId); + } + + async function toolOutcome(runId: string) { + const event = await handles.prisma.event.findFirstOrThrow({ + where: { runId, type: "agent.tool.completed" }, + select: { payload: true }, + }); + return event.payload as { name: string; outcome: string; error?: string }; + } + + it("keeps each user's goals, habits and check-ins to that user", async () => { + const ada = await signup(app, `coach-ada-${stamp}@rakazo.test`, "Ada"); + const bob = await signup(app, `coach-bob-${stamp}@rakazo.test`, "Bob"); + const bobSpaces = await rpc(app, bob, "spaces/list"); + + const goal = await rpc(app, ada, "coach/goals/create", { + title: "Ship the beta", + targetDate: "2026-12-01", + }); + expect(goal).toMatchObject({ title: "Ship the beta", status: "active", source: "user" }); + const habit = await rpc(app, ada, "coach/habits/create", { + title: "Write 300 words", + goalId: goal.id, + }); + expect(habit).toMatchObject({ cadence: "daily", goalId: goal.id, source: "user" }); + const checkIn = await rpc(app, ada, "coach/checkIns/log", { + kind: "habit", + habitId: habit.id, + done: true, + }); + await rpc(app, ada, "coach/checkIns/log", { + kind: "goal", + goalId: goal.id, + note: "Draft done", + }); + await rpc(app, ada, "coach/checkIns/log", { kind: "daily", mood: 4, energy: 3 }); + + const today = await rpc(app, ada, "coach/today"); + expect(today).toMatchObject({ date: checkIn.date, timeZone: "UTC", brief: null }); + expect(today.goals.map((g) => g.id)).toEqual([goal.id]); + expect(today.habits).toMatchObject([{ id: habit.id, doneToday: true, streak: 1 }]); + expect(today.checkIn).toMatchObject({ mood: 4, energy: 3 }); + expect(await rpc(app, ada, "coach/checkIns/list", { goalId: goal.id })).toHaveLength( + 1, + ); + + // Bob sees none of it and cannot touch it; Ada's ids read as missing to him. + expect(await rpc(app, bob, "coach/goals/list", {})).toEqual([]); + expect(await rpc(app, bob, "coach/habits/list", {})).toEqual([]); + expect(await rpc(app, bob, "coach/checkIns/list", { habitId: habit.id })).toEqual( + [], + ); + expect((await rpc(app, bob, "coach/today")).habits).toEqual([]); + for (const [procedure, input] of [ + ["coach/goals/update", { goalId: goal.id, status: "dropped" }], + ["coach/goals/remove", { goalId: goal.id }], + ["coach/habits/update", { habitId: habit.id, active: false }], + ["coach/habits/remove", { habitId: habit.id }], + ["coach/checkIns/log", { kind: "habit", habitId: habit.id, done: false }], + ["coach/checkIns/log", { kind: "goal", goalId: goal.id, note: "Mine now" }], + ["coach/habits/create", { title: "Borrowed", goalId: goal.id }], + ] as const) { + expect((await raw(app, bob, procedure, input)).status, procedure).toBe(404); + } + // Ada cannot file a goal under Bob's space. + expect( + ( + await raw(app, ada, "coach/goals/create", { + title: "Elsewhere", + spaceId: bobSpaces.spaces[0]!.id, + }) + ).status, + ).toBe(400); + expect((await raw(app, ada, "coach/goals/create", { title: " " })).status).toBe(400); + expect((await raw(app, ada, "coach/checkIns/log", { kind: "habit" })).status).toBe(400); + expect( + (await raw(app, ada, "coach/checkIns/log", { kind: "daily", date: "2999-01-01" })).status, + ).toBe(400); + expect((await rpc(app, ada, "coach/habits/list", {})).map((h) => h.active)).toEqual([ + true, + ]); + + const closed = await rpc(app, ada, "coach/goals/update", { + goalId: goal.id, + status: "done", + }); + expect(closed.completedAt).not.toBeNull(); + expect((await rpc(app, ada, "coach/today")).goals).toEqual([]); + await rpc(app, ada, "coach/habits/remove", { habitId: habit.id }); + await rpc(app, ada, "coach/goals/remove", { goalId: goal.id }); + expect(await rpc(app, ada, "coach/goals/list", {})).toEqual([]); + expect(await rpc(app, ada, "coach/checkIns/list", { kind: "habit" })).toEqual([]); + }); + + it("lets the main bot coach through its tools, and only the main bot", async () => { + const cookie = await signup(app, `coach-tools-${stamp}@rakazo.test`, "Coach Tools"); + const me = await rpc<{ userId: string }>(app, cookie, "me"); + const main = await ensureMainBot(handles.prisma, me.userId); + const specialist = await rpc<{ id: string }>(app, cookie, "bots/create", { + name: "Researcher", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + + await sendAndWait(cookie, main.id, "set a goal: Run a 10k"); + await sendAndWait(cookie, main.id, "track a habit: Walk"); + await sendAndWait(cookie, main.id, "done today: walk"); + const briefRun = await sendAndWait(cookie, main.id, "write today's brief: Rest, then plan."); + expect(await toolOutcome(briefRun.id)).toMatchObject({ + name: "write_daily_brief", + outcome: "succeeded", + }); + + const today = await rpc(app, cookie, "coach/today"); + expect(today.goals).toMatchObject([ + { title: "Run a 10k", source: "chat", createdByBotId: main.id }, + ]); + expect(today.habits).toMatchObject([ + { title: "Walk", source: "chat", doneToday: true, streak: 1 }, + ]); + expect(today.brief).toMatchObject({ text: "Rest, then plan.", botId: main.id }); + const [walked] = await rpc(app, cookie, "coach/checkIns/list", {}); + expect(walked).toMatchObject({ kind: "habit", done: true, createdByBotId: main.id }); + + const refused = await sendAndWait(cookie, specialist.id, "set a goal: Sneak in"); + expect(await toolOutcome(refused.id)).toMatchObject({ + name: "update_goal", + outcome: "error", + error: expect.stringContaining("not available in this run"), + }); + expect(await rpc(app, cookie, "coach/goals/list", {})).toHaveLength(1); + }); + + it("schedules check-ins as main-bot routines, and the morning one writes today's brief", async () => { + const cookie = await signup(app, `coach-rhythm-${stamp}@rakazo.test`, "Coach Rhythm"); + const me = await rpc<{ userId: string }>(app, cookie, "me"); + expect(await rpc(app, cookie, "coach/rhythm/get")).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + const rhythm = { morning: "07:00", evening: null, weekly: { day: 0, time: "18:00" } }; + expect(await rpc(app, cookie, "coach/rhythm/set", rhythm)).toEqual(rhythm); + expect(await rpc(app, cookie, "coach/rhythm/get")).toEqual(rhythm); + expect((await raw(app, cookie, "coach/rhythm/set", { ...rhythm, morning: "7am" })).status).toBe( + 400, + ); + + const conversation = await ensureMainConversation(handles.prisma, me.userId); + const routines = await rpc(app, cookie, "routines/list", { + botId: conversation.leadBotId, + }); + expect(routines.map((routine) => routine.name).sort()).toEqual( + [COACH_ROUTINES.morning.name, COACH_ROUTINES.weekly.name].sort(), + ); + + const morning = await handles.prisma.routine.findFirstOrThrow({ + where: { botId: conversation.leadBotId, coachKey: "morning" }, + }); + const dueAt = new Date(Date.now() - 1_000); + await handles.prisma.routine.update({ where: { id: morning.id }, data: { nextRunAt: dueAt } }); + await handles.executor.wakeRoutine(morning.id, dueAt.toISOString()); + const run = await handles.prisma.run.findFirstOrThrow({ + where: { routineId: morning.id, trigger: "routine" }, + }); + expect((await waitForRun(run.id)).threadId).toBe(conversation.threadId); + expect((await rpc(app, cookie, "coach/today")).brief).toMatchObject({ + text: SCRIPTED_MORNING_BRIEF, + botId: conversation.leadBotId, + }); + // The routine re-armed for the next morning. + const rearmed = await handles.prisma.routine.findUniqueOrThrow({ where: { id: morning.id } }); + expect(rearmed.active).toBe(true); + expect(rearmed.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie, + origin: "http://127.0.0.1:5173", + }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +} From 3db0f0cfbd4d720553ce0bfa3478327b57c1a0e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:28:59 +0000 Subject: [PATCH 13/38] feat(onboarding): save goal, check-in rhythm, quiet hours and push from the cards With the coach and notification modules merged, the onboarding answers now take effect: - the goal card creates a goal (source onboarding) through createGoal; - the rhythm card sets the main bot's check-in routines through setCoachRhythm, and the closing line names the first check-in from the routines that were saved; - the quiet-hours card updates the notification settings; - the choice card tells the server whether this browser can still turn push on, so the notifications card is offered only there, and "Turn on" subscribes before answering. The thread lock becomes NO KEY UPDATE so a rhythm routine, which references the thread, can be written while the answer holds the lock. The goal answer is capped at the coach title length. A Postgres suite walks the questions end to end: answers, skips, a dismissed card, a stale and a doubled answer, another bot's focus card, and another user's access. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/onboarding-intake.test.ts | 98 +++++++ apps/api/src/onboarding-intake.ts | 69 ++--- apps/api/src/onboarding.test.ts | 20 +- apps/api/src/onboarding.ts | 39 +-- apps/api/src/router.ts | 7 +- apps/web/src/lib/onboarding-push.ts | 14 - apps/web/src/pages/shell/message-cards.tsx | 7 +- .../src/executor-instructions.test.ts | 21 +- packages/adapters/src/executor-prompt.test.ts | 2 + packages/contracts/src/rpc.ts | 5 +- packages/testkit/src/cli/harness.ts | 1 + .../src/onboarding-intake.postgres.test.ts | 276 ++++++++++++++++++ 12 files changed, 461 insertions(+), 98 deletions(-) create mode 100644 apps/api/src/onboarding-intake.test.ts delete mode 100644 apps/web/src/lib/onboarding-push.ts create mode 100644 packages/testkit/src/onboarding-intake.postgres.test.ts diff --git a/apps/api/src/onboarding-intake.test.ts b/apps/api/src/onboarding-intake.test.ts new file mode 100644 index 0000000..0c8ccbd --- /dev/null +++ b/apps/api/src/onboarding-intake.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + closingLine, + focusCard, + intakeContext, + intakeStep, + nextIntakeStep, + readIntakeAnswer, + secondaryOption, +} from "./onboarding-intake.js"; + +// Intl may separate the time from AM/PM with a narrow no-break space. +const plain = (text: string) => text.replace(/\s/g, " "); +const off = { morning: null, evening: null, weekly: null }; + +describe("onboarding steps", () => { + it("asks in order and offers notifications only to a device that can still turn push on", () => { + expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal"); + expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm"); + expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours"); + expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications"); + expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull(); + expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull(); + }); + + it("gives every onboarding card a quiet way out, and another bot's focus card none", () => { + const context = intakeContext(null, new Date()); + for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) { + const card = intakeStep(id).card(context); + expect(card.step).toBe(id); + expect(secondaryOption(card)).toBeDefined(); + } + expect(focusCard().step).toBeUndefined(); + expect(secondaryOption(focusCard())).toBeUndefined(); + }); + + it("writes quiet hours in the user's locale", () => { + const labels = (locale: string | null) => + intakeStep("quiet_hours") + .card(intakeContext({ locale, timeZone: null }, new Date())) + .options.map((option) => plain(option.label)); + expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]); + expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]); + expect(labels("not a tag")).toEqual(labels("en")); + }); +}); + +describe("readIntakeAnswer", () => { + const goal = intakeStep("goal").card(intakeContext(null, new Date())); + const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date())); + + it("accepts the card's own options and words only where the card takes them", () => { + expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({ + option: { id: "weekly", letter: "C", label: "Once a week" }, + }); + expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull(); + expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull(); + expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" }); + expect(readIntakeAnswer(goal, "_text", " ")).toBeNull(); + expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip"); + }); +}); + +describe("closingLine", () => { + const vienna = (iso: string, locale = "de-AT") => + intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso)); + + it("names the first check-in in the user's time zone", () => { + const morning = { ...off, morning: "08:00" }; + expect(closingLine(morning, vienna("2026-10-07T04:00:00Z"))).toBe( + "All set. I’ll check in today at 8:00.", + ); + expect(closingLine(morning, vienna("2026-10-07T09:00:00Z"))).toBe( + "All set. I’ll check in tomorrow at 8:00.", + ); + expect(closingLine({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z"))).toBe( + "All set. I’ll check in today at 18:00.", + ); + // Wednesday in Vienna; the weekly review is on Sunday. + const weekly = { ...off, weekly: { day: 0, time: "18:00" } }; + expect(plain(closingLine(weekly, vienna("2026-10-07T09:00:00Z", "en-US")))).toBe( + "All set. I’ll check in on Sunday at 6:00 PM.", + ); + }); + + it("says the user can just write when no check-in is set", () => { + expect(closingLine(off, vienna("2026-10-07T09:00:00Z"))).toBe( + "All set. I’m here whenever you need me.", + ); + }); + + it("falls back to English and UTC for an unusable profile", () => { + expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({ + locale: "en", + timeZone: "UTC", + }); + }); +}); diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts index 036126a..a5ea131 100644 --- a/apps/api/src/onboarding-intake.ts +++ b/apps/api/src/onboarding-intake.ts @@ -1,7 +1,15 @@ -import type { MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters"; +import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts"; import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { knownTimeZone, nextCronDateAcross } from "@rakazo/core"; -import { type Prisma, type PrismaClient, updateUserProfile } from "@rakazo/db"; +import { + createGoal, + type Prisma, + type PrismaClient, + updateNotificationSettings, + updateUserProfile, +} from "@rakazo/db"; /** * The questions the main bot asks a new user in the main conversation, one card at a time. @@ -12,18 +20,6 @@ import { type Prisma, type PrismaClient, updateUserProfile } from "@rakazo/db"; export type ChoiceBlock = Extract; type ChoiceOption = ChoiceBlock["options"][number]; -/** When the main bot checks in; the same shape the coach rhythm stores. */ -export interface CheckInRhythm { - morning: string | null; - evening: string | null; - weekly: { day: number; time: string } | null; -} - -export interface QuietHours { - start: string; - end: string; -} - /** A validated answer: an option of the card, or the user's own words. */ export type IntakeAnswer = | { option: ChoiceOption; text?: undefined } @@ -38,6 +34,7 @@ export interface IntakeContext { export interface IntakeEffects { prisma: PrismaClient; + jobs: JobPublisher; tx: Prisma.TransactionClient; userId: string; } @@ -111,7 +108,7 @@ export function focusCard(step?: "focus"): ChoiceBlock { }; } -const RHYTHMS: Record = { +const RHYTHMS: Record = { morning: { morning: "08:00", evening: null, weekly: null }, morning_evening: { morning: "08:00", evening: "18:00", weekly: null }, weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } }, @@ -123,12 +120,6 @@ const QUIET_HOURS: Record = { "23-08": { start: "23:00", end: "08:00" }, }; -// Coach and notification effects. They land with the coach (goals, rhythm) and notification -// settings modules; until then the answer stays on its card. -async function saveGoal(_effects: IntakeEffects, _title: string): Promise {} -async function saveRhythm(_effects: IntakeEffects, _rhythm: CheckInRhythm): Promise {} -async function saveQuietHours(_effects: IntakeEffects, _quietHours: QuietHours): Promise {} - export const INTAKE_STEPS: IntakeStep[] = [ { id: "focus", @@ -151,8 +142,9 @@ export const INTAKE_STEPS: IntakeStep[] = [ textInput: { placeholder: "Your goal" }, options: [SKIP], }), - save: async (effects, answer) => { - if (answer.text) await saveGoal(effects, answer.text); + save: async ({ prisma, userId }, answer) => { + if (answer.text) + await createGoal(prisma, userId, { title: answer.text, source: "onboarding" }); }, }, { @@ -168,9 +160,9 @@ export const INTAKE_STEPS: IntakeStep[] = [ { id: "none", letter: "", label: "Not now" }, ], }), - save: async (effects, answer) => { + save: async ({ prisma, jobs, userId }, answer) => { const rhythm = answer.option && RHYTHMS[answer.option.id]; - if (rhythm) await saveRhythm(effects, rhythm); + if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm }); }, }, { @@ -188,9 +180,9 @@ export const INTAKE_STEPS: IntakeStep[] = [ SKIP, ], }), - save: async (effects, answer) => { + save: async ({ prisma, userId }, answer) => { const quietHours = answer.option && QUIET_HOURS[answer.option.id]; - if (quietHours) await saveQuietHours(effects, quietHours); + if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours }); }, }, { @@ -244,10 +236,6 @@ export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined { return card.options.find((option) => !option.letter); } -export function rhythmForAnswer(answerId: string | undefined): CheckInRhythm | null { - return (answerId && RHYTHMS[answerId]) || null; -} - /** Card times and the closing line follow the user's profile, falling back to English and UTC. */ export function intakeContext( profile: { locale: string | null; timeZone: string | null } | null, @@ -271,18 +259,6 @@ function formatClock(time: string, locale: string): string { }).format(new Date(Date.UTC(2000, 0, 1, hour, minute))); } -function rhythmCrons(rhythm: CheckInRhythm): string[] { - const cron = (time: string, day = "*") => { - const [hour, minute] = time.split(":").map(Number); - return `${minute} ${hour} * * ${day}`; - }; - return [ - ...(rhythm.morning ? [cron(rhythm.morning)] : []), - ...(rhythm.evening ? [cron(rhythm.evening)] : []), - ...(rhythm.weekly ? [cron(rhythm.weekly.time, String(rhythm.weekly.day))] : []), - ]; -} - function localDay(date: Date, timeZone: string): number { const [year, month, day] = new Intl.DateTimeFormat("en-CA", { timeZone, @@ -297,10 +273,9 @@ function localDay(date: Date, timeZone: string): number { } /** One line that says what happens next: the first check-in, when there is one. */ -export function closingLine(rhythm: CheckInRhythm | null, context: IntakeContext): string { - const next = rhythm - ? nextCronDateAcross(rhythmCrons(rhythm), context.now, context.timeZone) - : null; +export function closingLine(rhythm: CoachRhythm, context: IntakeContext): string { + const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []); + const next = nextCronDateAcross(crons, context.now, context.timeZone); if (!next) return "All set. I’m here whenever you need me."; const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone); const day = diff --git a/apps/api/src/onboarding.test.ts b/apps/api/src/onboarding.test.ts index 38189ea..66f8328 100644 --- a/apps/api/src/onboarding.test.ts +++ b/apps/api/src/onboarding.test.ts @@ -1,6 +1,7 @@ import type * as db from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import { chooseFocus, markAppConnected } from "./onboarding.js"; +import { focusCard } from "./onboarding-intake.js"; const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>); vi.mock("@rakazo/db", async (original) => ({ @@ -13,16 +14,16 @@ vi.mock("@rakazo/db", async (original) => ({ })); function fixture(catalog: unknown[]) { posted.length = 0; + // A specialist's focus card, waiting for an answer. + const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]); const tx = { $executeRaw: vi.fn(), - message: { - findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]), - update: vi.fn(), - }, + message: { findMany: pending, update: vi.fn() }, }; const deps = { prisma: { bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) }, + message: { findMany: pending }, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)), }, events: { notify: vi.fn() }, @@ -39,17 +40,19 @@ function fixture(catalog: unknown[]) { describe("onboarding connection suggestions", () => { it("does not invent authorization cards when no connector has an app catalog", async () => { const { deps, actor } = fixture([]); - await chooseFocus(deps, actor, "bot", "day"); + await chooseFocus(deps, actor, "bot", { optionId: "day" }); expect(posted.flatMap((message) => message.blocks)).not.toContainEqual( expect.objectContaining({ kind: "app_connect" }), ); - expect(posted.length).toBeGreaterThan(0); + expect(posted.flatMap((message) => message.blocks)).toEqual([ + { kind: "text", text: "Got it. What would you like to work on first?" }, + ]); }); it("uses the available connector and omits unavailable apps", async () => { const { deps, actor } = fixture([ { connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null }, ]); - await chooseFocus(deps, actor, "bot", "day"); + await chooseFocus(deps, actor, "bot", { optionId: "day" }); expect( posted .flatMap((message) => message.blocks) @@ -57,6 +60,9 @@ describe("onboarding connection suggestions", () => { ).toEqual([ expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }), ]); + expect(posted[0]?.blocks).toEqual([ + { kind: "text", text: "Got it. Slack is a good place to start." }, + ]); }); }); diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index 8163991..aa570e7 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -1,5 +1,6 @@ import { ORPCError } from "@orpc/server"; -import type { ConnectorRegistry } from "@rakazo/adapters"; +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { type ConnectorRegistry, getCoachRhythm } from "@rakazo/adapters"; import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts"; import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { featuredConnectorProvidersMatch } from "@rakazo/core"; @@ -24,7 +25,6 @@ import { intakeStep, nextIntakeStep, readIntakeAnswer, - rhythmForAnswer, secondaryOption, } from "./onboarding-intake.js"; @@ -39,6 +39,7 @@ type OnboardingDeps = { prisma: PrismaClient; events: ThreadEvents; connectors: ConnectorRegistry; + jobs: JobPublisher; }; type Target = { spaceId: string; botId: string; threadId: string }; @@ -65,8 +66,12 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin return { bot, thread: bot.thread }; } +/** + * Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows + * that reference the thread from another connection (the coach routines do). + */ async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR UPDATE`; + await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`; } async function postInTransaction( @@ -137,13 +142,13 @@ function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined { return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId); } -/** The newest card in the thread that still waits for an answer. */ +/** The newest card in the thread that still waits for an answer, however long ago it came. */ async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) { const messages = await client.message.findMany({ - where: { threadId, role: "bot" }, + where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } }, select: { id: true, blocks: true }, orderBy: { seq: "desc" }, - take: 50, + take: 20, }); for (const message of messages) { const blocks = message.blocks as MessageBlock[]; @@ -242,9 +247,6 @@ export async function chooseFocus( !pending.card.step || pending.card.step === "focus" ? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id) : undefined; - if (!pending.card.step && !focus) { - throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" }); - } // Fetched before the transaction: the connector catalog can take seconds. const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step)) @@ -260,7 +262,7 @@ export async function chooseFocus( if (current.card.step) { if (onboarding?.step !== current.card.step) return null; await intakeStep(current.card.step).save( - { prisma: deps.prisma, tx, userId: actor.userId }, + { prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId }, answer, ); } @@ -269,7 +271,7 @@ export async function chooseFocus( let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks); for (const message of followUp) last = await postInTransaction(tx, target, message); if (current.card.step) { - last = await askNext(tx, target, actor.userId, current.card.step, input.canEnablePush); + last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush); } return last; }); @@ -296,6 +298,7 @@ async function loadIntakeContext( /** Posts the next onboarding question, or the closing line once every step is answered. */ async function askNext( + deps: OnboardingDeps, tx: Prisma.TransactionClient, target: Target, userId: string, @@ -309,18 +312,8 @@ async function askNext( await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } }); return postInTransaction(tx, target, [next.card(context)]); } - const rhythmCard = await tx.message.findFirst({ - where: { - threadId: target.threadId, - role: "bot", - blocks: { array_contains: [{ kind: "choice", step: "rhythm" }] }, - }, - orderBy: { seq: "desc" }, - select: { blocks: true }, - }); - const rhythm = rhythmForAnswer( - (rhythmCard?.blocks as MessageBlock[] | undefined)?.find(isChoice)?.answerId, - ); + // The rhythm step saved its answer outside this transaction, so the routines are readable. + const rhythm = await getCoachRhythm(deps.prisma, userId); await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } }); return postInTransaction(tx, target, [{ kind: "text", text: closingLine(rhythm, context) }]); } diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 9aeabb8..d06c813 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -518,7 +518,12 @@ export function createRouter(deps: RouterDeps) { userAgent?: string; }>(); const repos = createRepos(deps.prisma); - const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors }; + const onboardingDeps = { + prisma: deps.prisma, + events: deps.events, + connectors: deps.connectors, + jobs: deps.jobs, + }; const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets); const groupRepos = createGroupRepos(deps.prisma); const projectGoalRepos = createProjectGoalRepos(deps.prisma); diff --git a/apps/web/src/lib/onboarding-push.ts b/apps/web/src/lib/onboarding-push.ts deleted file mode 100644 index bedb187..0000000 --- a/apps/web/src/lib/onboarding-push.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Push for the onboarding notifications card. The server offers the card only when the browser - * that answered the card before it could still turn push on, and tapping "Turn on" is the user - * gesture that subscribes before the card is answered. Both wait for the web push client - * (`lib/web-push.ts`); until it lands the card is never offered. - */ -export async function canOfferPush(): Promise { - return false; -} - -/** Subscribes this browser; true when push is on afterwards. */ -export async function turnOnPush(): Promise { - return false; -} diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index db4e32e..5c12492 100644 --- a/apps/web/src/pages/shell/message-cards.tsx +++ b/apps/web/src/pages/shell/message-cards.tsx @@ -19,8 +19,8 @@ import { BuiCard, SuccessPop } from "../../components/ai/primitives"; import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open"; import { chartViewport } from "../../lib/chart-viewport"; import { connectMcpOauth } from "../../lib/mcp-connect"; -import { canOfferPush, turnOnPush } from "../../lib/onboarding-push"; import { rpc } from "../../lib/rpc"; +import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push"; type ChoiceOption = Extract["options"][number]; @@ -55,7 +55,7 @@ export function ChoiceCard({ try { // Tapping "Turn on" is the gesture the browser needs before it asks for permission. const answer = - block.step === "notifications" && optionId === "on" && !(await turnOnPush()) + block.step === "notifications" && optionId === "on" && (await enableWebPush()) !== "enabled" ? "later" : optionId; await rpc.onboarding.choose({ @@ -63,7 +63,8 @@ export function ChoiceCard({ step: block.step, optionId: answer, ...(words ? { text: words } : {}), - canEnablePush: await canOfferPush(), + // The server offers the notifications card only to a browser that can still turn push on. + canEnablePush: webPushSupported() && !(await webPushEnabled()), }); await onBotChanged(); } catch (err) { diff --git a/packages/adapters/src/executor-instructions.test.ts b/packages/adapters/src/executor-instructions.test.ts index 26c2e89..219b379 100644 --- a/packages/adapters/src/executor-instructions.test.ts +++ b/packages/adapters/src/executor-instructions.test.ts @@ -20,6 +20,7 @@ interface FixtureOptions { sender?: { name: string } | null; profile?: Record | null; facts?: Array<{ category: string; value: string; confidence: number }>; + onboarding?: { completedAt: Date | null } | null; peers?: Array<{ id: string; name: string; @@ -112,7 +113,12 @@ async function runOnce(options: FixtureOptions = {}) { agentSecret: { findMany: vi.fn(async () => []) }, agentSkill: { findMany: vi.fn(async () => []) }, scratchpadItem: { findMany: vi.fn(async () => []) }, - user: { findUnique: vi.fn(async () => ({ name: "Ada Example" })) }, + user: { + findUnique: vi.fn(async () => ({ + name: "Ada Example", + onboarding: options.onboarding ?? null, + })), + }, userProfile: { findUnique: vi.fn(async () => options.profile === undefined @@ -338,6 +344,19 @@ describe("run instructions", () => { expect(request.instructions).not.toContain("save lasting facts they share with remember"); }); + it("leaves open onboarding questions to their cards, for the main bot only", async () => { + const main = { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }; + const open = { completedAt: null }; + const note = "still answering onboarding cards"; + + expect((await runOnce({ bot: main, onboarding: open })).request.instructions).toContain(note); + expect( + (await runOnce({ bot: main, onboarding: { completedAt: new Date() } })).request.instructions, + ).not.toContain(note); + expect((await runOnce({ bot: main })).request.instructions).not.toContain(note); + expect((await runOnce({ onboarding: open })).request.instructions).not.toContain(note); + }); + it("shows the main bot every specialist it can delegate to, its own space first", async () => { const { request, peersFindMany } = await runOnce({ bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }, diff --git a/packages/adapters/src/executor-prompt.test.ts b/packages/adapters/src/executor-prompt.test.ts index b61504d..4570495 100644 --- a/packages/adapters/src/executor-prompt.test.ts +++ b/packages/adapters/src/executor-prompt.test.ts @@ -97,6 +97,7 @@ describe("formatUserContext", () => { communicationStyle: "", summary: " ", facts: [{ category: "work", value: " ", confidence: 1 }], + onboardingOpen: false, }); expect(block).toContain("\nName: Ada\n"); expect(block).not.toContain("Time zone"); @@ -113,6 +114,7 @@ describe("formatUserContext", () => { communicationStyle: "", summary: "", facts: [], + onboardingOpen: false, }), ).toBeUndefined(); }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6bec10f..78b01cb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,7 @@ import { eventIterator, oc } from "@orpc/contract"; import * as z from "zod"; import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js"; +import { COACH_TITLE_MAX_LENGTH } from "./coach.js"; import { ActionApprovalRuleSchema, ActionAutoReviewSettingsSchema, @@ -684,8 +685,8 @@ export const appContract = { botId: Id, step: OnboardingStepSchema.optional(), optionId: z.string(), - /** The answer in the user's words, with `optionId` "_text". */ - text: z.string().trim().min(1).max(200).optional(), + /** The answer in the user's words, with `optionId` "_text"; only the goal card takes one. */ + text: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), /** This device could still turn on push notifications. */ canEnablePush: z.boolean().optional(), }), diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..58fa18e 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -106,6 +106,7 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/onboarding-intake.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", diff --git a/packages/testkit/src/onboarding-intake.postgres.test.ts b/packages/testkit/src/onboarding-intake.postgres.test.ts new file mode 100644 index 0000000..57aceea --- /dev/null +++ b/packages/testkit/src/onboarding-intake.postgres.test.ts @@ -0,0 +1,276 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposioEmulator, getCoachRhythm } from "@rakazo/adapters"; +import type { MessageBlock } from "@rakazo/contracts"; +import { getNotificationSettings, loadUserContext } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; +type Choice = Extract; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; + +describeWithDatabase("onboarding questions in the main conversation", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-onboarding-")); + + beforeAll(async () => { + const { createApp } = await import("../../../apps/api/src/app.ts"); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + composio: new ComposioEmulator(), + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + /** A fresh user with their main bot, as the web wizard leaves them. */ + async function newUser(name: string) { + const cookie = await signup(app, `${name.toLowerCase()}-${stamp}@rakazo.test`, name); + const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); + await rpc(app, cookie, "profile/update", { timeZone: "Europe/Vienna", locale: "en-US" }); + const main = await rpc<{ id: string }>(app, cookie, "bots/ensureMain"); + const { main: conversation } = await rpc<{ main: { groupId: string } }>( + app, + cookie, + "spaces/list", + ); + const { id: threadId } = await handles.prisma.thread.findUniqueOrThrow({ + where: { groupId: conversation.groupId }, + select: { id: true }, + }); + return { cookie, userId: me.userId, spaceId: me.spaceId, botId: main.id, threadId }; + } + + async function messages(threadId: string) { + const rows = await handles.prisma.message.findMany({ + where: { threadId }, + orderBy: { seq: "asc" }, + select: { blocks: true }, + }); + return rows.flatMap((row) => row.blocks as MessageBlock[]); + } + + async function cards(threadId: string): Promise { + return (await messages(threadId)).filter((block): block is Choice => block.kind === "choice"); + } + + async function pending(threadId: string) { + return (await cards(threadId)).filter((card) => !card.answerId); + } + + const onboardingRow = (userId: string) => + handles.prisma.userOnboarding.findUniqueOrThrow({ where: { userId } }); + + it("asks one question after another and records every answer", async () => { + const ada = await newUser("Ada"); + const choose = (body: Record) => + rpc(app, ada.cookie, "onboarding/choose", { botId: ada.botId, ...body }); + + await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); + await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); + expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["focus"]); + expect(await onboardingRow(ada.userId)).toMatchObject({ step: "focus", completedAt: null }); + expect( + (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) + .onboardingOpen, + ).toBe(true); + + await choose({ step: "focus", optionId: "day" }); + expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" }); + const facts = await handles.prisma.userFact.findMany({ where: { userId: ada.userId } }); + expect(facts.map(({ category, value, source }) => ({ category, value, source }))).toEqual([ + { category: "goals", value: "Wants help with day-to-day work first", source: "onboarding" }, + ]); + const afterFocus = await messages(ada.threadId); + expect(afterFocus.filter((block) => block.kind === "app_connect").length).toBeGreaterThan(0); + // The app suggestions come before the next question. + expect(afterFocus.at(-1)).toMatchObject({ kind: "choice", step: "goal" }); + + // A stale answer to a card that is no longer pending changes nothing. + await choose({ step: "focus", optionId: "inbox" }); + expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" }); + await expect(choose({ step: "goal", optionId: "morning" })).rejects.toThrow(/400/); + await expect(choose({ step: "goal", optionId: "_text", text: " " })).rejects.toThrow(/400/); + + // A double submit records the goal once. + await Promise.all([ + choose({ step: "goal", optionId: "_text", text: "Run a 10K" }), + choose({ step: "goal", optionId: "_text", text: "Run a 10K" }), + ]); + const goals = await handles.prisma.userGoal.findMany({ where: { userId: ada.userId } }); + expect(goals.map(({ title, source }) => ({ title, source }))).toEqual([ + { title: "Run a 10K", source: "onboarding" }, + ]); + expect((await cards(ada.threadId)).find((card) => card.step === "goal")).toMatchObject({ + answerId: "_text", + answerText: "Run a 10K", + }); + + await choose({ step: "rhythm", optionId: "morning" }); + expect(await getCoachRhythm(handles.prisma, ada.userId)).toEqual({ + morning: "08:00", + evening: null, + weekly: null, + }); + expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["quiet_hours"]); + expect( + (await pending(ada.threadId))[0]?.options.map((option) => option.label.replace(/\s/g, " ")), + ).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]); + + // A browser that can still turn push on gets the notifications question. + await choose({ step: "quiet_hours", optionId: "22-07", canEnablePush: true }); + expect((await getNotificationSettings(handles.prisma, ada.userId)).quietHours).toEqual({ + start: "22:00", + end: "07:00", + }); + expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["notifications"]); + + await choose({ step: "notifications", optionId: "later" }); + expect(await onboardingRow(ada.userId)).toMatchObject({ + step: null, + completedAt: expect.any(Date), + }); + expect(await pending(ada.threadId)).toEqual([]); + const closing = (await messages(ada.threadId)).at(-1); + expect(closing?.kind === "text" && closing.text.replace(/\s/g, " ")).toMatch( + /^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/, + ); + expect( + (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) + .onboardingOpen, + ).toBe(false); + + // Onboarding runs once. + const count = (await messages(ada.threadId)).length; + await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); + expect((await messages(ada.threadId)).length).toBe(count); + }); + + it("moves on when a question is skipped or dismissed, and saves nothing for it", async () => { + const bo = await newUser("Bo"); + const choose = (body: Record) => + rpc(app, bo.cookie, "onboarding/choose", { botId: bo.botId, ...body }); + + await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: bo.botId }); + await rpc(app, bo.cookie, "onboarding/dismissFocus", { botId: bo.botId }); + expect((await cards(bo.threadId))[0]).toMatchObject({ step: "focus", answerId: "skip" }); + await choose({ step: "goal", optionId: "skip" }); + await choose({ step: "rhythm", optionId: "none" }); + await choose({ step: "quiet_hours", optionId: "skip" }); + + expect(await onboardingRow(bo.userId)).toMatchObject({ + step: null, + focus: null, + completedAt: expect.any(Date), + }); + expect(await handles.prisma.userFact.count({ where: { userId: bo.userId } })).toBe(0); + expect(await handles.prisma.userGoal.count({ where: { userId: bo.userId } })).toBe(0); + expect(await getCoachRhythm(handles.prisma, bo.userId)).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + expect((await getNotificationSettings(handles.prisma, bo.userId)).quietHours).toBeNull(); + expect((await messages(bo.threadId)).at(-1)).toEqual({ + kind: "text", + text: "All set. I’m here whenever you need me.", + }); + + // Another bot still gets only its focus card, outside onboarding. + const helper = await rpc<{ id: string; threadId: string }>(app, bo.cookie, "bots/create", { + name: "Helper", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + const { thread } = await handles.prisma.bot.findUniqueOrThrow({ + where: { id: helper.id }, + select: { thread: { select: { id: true } } }, + }); + await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: helper.id }); + const [focus] = await pending(thread!.id); + expect(focus?.step).toBeUndefined(); + expect(focus?.options.map((option) => option.id)).toEqual(["day", "inbox", "research", "crew"]); + await rpc(app, bo.cookie, "onboarding/choose", { botId: helper.id, optionId: "research" }); + expect(await pending(thread!.id)).toEqual([]); + expect(await onboardingRow(bo.userId)).toMatchObject({ focus: null }); + }); + + it("keeps each user's onboarding to themselves", async () => { + const owner = await newUser("Owner"); + const other = await newUser("Other"); + await rpc(app, owner.cookie, "onboarding/promptFocus", { botId: owner.botId }); + + for (const [procedure, body] of [ + ["onboarding/promptFocus", { botId: owner.botId }], + ["onboarding/choose", { botId: owner.botId, step: "focus", optionId: "day" }], + ["onboarding/dismissFocus", { botId: owner.botId }], + ["onboarding/start", { botId: owner.botId }], + ] as const) { + const response = await raw(app, other.cookie, procedure, body); + expect(response.status, procedure).toBeGreaterThanOrEqual(400); + } + expect(await onboardingRow(owner.userId)).toMatchObject({ step: "focus", focus: null }); + expect((await pending(owner.threadId)).map((card) => card.step)).toEqual(["focus"]); + expect( + await handles.prisma.userOnboarding.findUnique({ where: { userId: other.userId } }), + ).toBe(null); + expect(await handles.prisma.userFact.count({ where: { userId: owner.userId } })).toBe(0); + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie, + origin: "http://127.0.0.1:5173", + }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +} From d63f7b3bf16e42b07aec178fa578ca678cfb0ca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:34:14 +0000 Subject: [PATCH 14/38] test(notifications): cover quiet hours, delivery, nudges and the notification RPCs The quiet-hours CHECK accepted a row with only one bound set, because a NULL comparison made the whole condition NULL; it now requires both bounds explicitly. The migration has not shipped yet, so it is corrected in place. - Unit: quiet-hours math across midnight, time zones and daylight saving; VAPID key handling (configured, generated once and shared, invalid); the web push sender (encrypted VAPID-signed request, gone on 404/410, no private or non-https endpoints); the fan-out with fake senders. - PostgreSQL: settings and subscription repositories (constraints, per-user removal, endpoint hand-over, device cap, concurrent nudge cap, cascade on account deletion), delivery and notify_user against a real main conversation, and every notification RPC through the API including cross-user isolation and endpoint validation. - .env.example documents the optional VAPID keys and VAPID_SUBJECT. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .env.example | 4 + .../adapters/src/notify-user.postgres.test.ts | 216 ++++++++++++++++++ .../adapters/src/push-notifications.test.ts | 179 +++++++++++++++ packages/adapters/src/web-push.test.ts | 155 +++++++++++++ packages/core/src/quiet-hours.test.ts | 62 +++++ .../migration.sql | 4 +- .../notification-settings.postgres.test.ts | 184 +++++++++++++++ packages/testkit/src/cli/harness.ts | 3 + .../src/notifications.postgres.test.ts | 198 ++++++++++++++++ 9 files changed, 1004 insertions(+), 1 deletion(-) create mode 100644 packages/adapters/src/notify-user.postgres.test.ts create mode 100644 packages/adapters/src/push-notifications.test.ts create mode 100644 packages/adapters/src/web-push.test.ts create mode 100644 packages/core/src/quiet-hours.test.ts create mode 100644 packages/db/src/notification-settings.postgres.test.ts create mode 100644 packages/testkit/src/notifications.postgres.test.ts diff --git a/.env.example b/.env.example index 5d588c8..9c4b48f 100644 --- a/.env.example +++ b/.env.example @@ -166,8 +166,12 @@ SMTP_URL= EMAIL_FROM= # Local development only: capture password-reset email and print its link to the API console. EMAIL_EMULATOR= +# Optional web push keys (set both). Unset: generated once and stored encrypted in the database. +# Browsers only allow push on https (or localhost). VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= +# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https. +VAPID_SUBJECT= # Structured logs to stdout (json in production, pretty in development unless overridden). LOG_LEVEL=info LOG_FORMAT= diff --git a/packages/adapters/src/notify-user.postgres.test.ts b/packages/adapters/src/notify-user.postgres.test.ts new file mode 100644 index 0000000..f0105f5 --- /dev/null +++ b/packages/adapters/src/notify-user.postgres.test.ts @@ -0,0 +1,216 @@ +import { randomUUID } from "node:crypto"; +import type { PrismaClient } from "@rakazo/db"; +import { + bootstrapUserSpace, + createDb, + ensureMainConversation, + savePushSubscription, + updateNotificationSettings, +} from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { notifyUserFromTool } from "./notify-user-tool.js"; +import { PushNotificationProvider, type PushSender } from "./push-notifications.js"; +import type { PushPayload } from "./web-push.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; +const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; + +describePostgres("push delivery and notify_user (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser() { + const id = `nu${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); + const conversation = await ensureMainConversation(prisma, id); + const main = await prisma.bot.findUniqueOrThrow({ + where: { id: conversation.leadBotId }, + select: { id: true, name: true, kind: true }, + }); + return { userId: id, conversation, main }; + } + + function recordingSender(outcome: "sent" | "gone" = "sent") { + const sent: PushPayload[] = []; + const sender: PushSender = { + send: vi.fn(async (_target, payload: PushPayload) => { + sent.push(payload); + return outcome; + }), + }; + return { sender, sent }; + } + + function toolContext(user: Awaited>, bot = user.main) { + return { + userId: user.userId, + spaceId: user.conversation.spaceId, + runId: `run-${randomUUID()}`, + bot, + signal: new AbortController().signal, + }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("delivers to each device, links the conversation and drops gone devices", async () => { + const user = await newUser(); + const browser = `https://push.example.test/${randomUUID()}`; + await savePushSubscription(prisma, user.userId, { + kind: "webpush", + endpoint: browser, + ...keys, + }); + await savePushSubscription(prisma, user.userId, { + kind: "expo", + endpoint: `ExponentPushToken[${user.userId}]`, + }); + const web = recordingSender("sent"); + const expo = recordingSender("gone"); + await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send( + { + kind: "completion", + title: "Chief finished", + body: "Done", + botId: user.main.id, + threadId: user.conversation.threadId, + }, + { + operationId: "notify", + traceId: "notify", + spaceId: user.conversation.spaceId, + userId: user.userId, + signal: new AbortController().signal, + }, + ); + expect(web.sent[0]?.url).toBe(`/app/g/${user.conversation.groupId}`); + const devices = await prisma.pushSubscription.findMany({ where: { userId: user.userId } }); + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ endpoint: browser, lastUsedAt: expect.any(Date) }); + }); + + it("lets only the main bot nudge, within the user's settings and the daily cap", async () => { + const user = await newUser(); + const web = recordingSender(); + const deps = { + prisma, + notifications: new PushNotificationProvider(prisma, { webpush: web.sender }), + }; + + await expect( + notifyUserFromTool(deps, toolContext(user, { ...user.main, kind: "specialist" }), { + body: "Hi", + }), + ).resolves.toEqual({ error: "Only the user's main bot can notify the user." }); + await expect(notifyUserFromTool(deps, toolContext(user), {})).resolves.toMatchObject({ + error: expect.stringContaining("body is required"), + }); + await expect( + notifyUserFromTool({ prisma }, toolContext(user), { body: "Hi" }), + ).resolves.toMatchObject({ sent: false }); + await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ + sent: false, + reason: "The user has no device with notifications turned on.", + }); + + await savePushSubscription(prisma, user.userId, { + kind: "webpush", + endpoint: `https://push.example.test/${randomUUID()}`, + ...keys, + }); + await updateNotificationSettings(prisma, user.userId, { nudges: false }); + await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ + sent: false, + reason: "The user turned nudges off.", + }); + + // Quiet hours around the current time. + await updateNotificationSettings(prisma, user.userId, { + nudges: true, + quietHours: quietAroundNow(), + }); + await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ + sent: false, + reason: "It is the user's quiet hours.", + }); + expect(web.sent).toHaveLength(0); + + await updateNotificationSettings(prisma, user.userId, { quietHours: null }); + for (let index = 0; index < 3; index += 1) { + await expect( + notifyUserFromTool(deps, toolContext(user), { body: `Check-in ${index}` }), + ).resolves.toEqual({ sent: true }); + } + await expect( + notifyUserFromTool(deps, toolContext(user), { body: "One too many" }), + ).resolves.toEqual({ sent: false, reason: "The daily limit of 3 nudges is reached." }); + + expect(web.sent).toHaveLength(3); + expect(web.sent[0]).toMatchObject({ + kind: "nudge", + title: user.main.name, + body: "Check-in 0", + threadId: user.conversation.threadId, + url: `/app/g/${user.conversation.groupId}`, + }); + }); + + it("reports a nudge no device accepted", async () => { + const user = await newUser(); + await savePushSubscription(prisma, user.userId, { + kind: "webpush", + endpoint: `https://push.example.test/${randomUUID()}`, + ...keys, + }); + const failing: PushSender = { + send: async () => { + throw new Error("Web push failed (500)"); + }, + }; + await expect( + notifyUserFromTool( + { prisma, notifications: new PushNotificationProvider(prisma, { webpush: failing }) }, + toolContext(user), + { body: "Hi", title: "Evening check-in" }, + ), + ).resolves.toEqual({ sent: false, reason: "No device accepted the notification." }); + }); +}); + +/** A UTC range that covers the current minute (users without a time zone use UTC). */ +function quietAroundNow() { + const now = new Date(); + const minute = now.getUTCHours() * 60 + now.getUTCMinutes(); + const clock = (value: number) => { + const wrapped = (value + 1440) % 1440; + return `${String(Math.floor(wrapped / 60)).padStart(2, "0")}:${String(wrapped % 60).padStart(2, "0")}`; + }; + return { start: clock(minute - 60), end: clock(minute + 60) }; +} diff --git a/packages/adapters/src/push-notifications.test.ts b/packages/adapters/src/push-notifications.test.ts new file mode 100644 index 0000000..c305f94 --- /dev/null +++ b/packages/adapters/src/push-notifications.test.ts @@ -0,0 +1,179 @@ +import type { NotificationMessage } from "@rakazo/adapter-kit"; +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it, vi } from "vitest"; +import { PushNotificationProvider, type PushSender } from "./push-notifications.js"; +import type { PushOutcome, PushPayload } from "./web-push.js"; + +const context = { + operationId: "notify", + traceId: "t", + spaceId: "space-1", + userId: "user-1", + signal: new AbortController().signal, +}; +const message: NotificationMessage = { + kind: "completion", + title: "Chief finished", + body: "Booked the table for Friday.", + botId: "bot-1", + threadId: "thread-1", +}; + +function fakeDb(options: { + devices: Array<{ id: string; kind: "webpush" | "expo" }>; + quietHours?: { start: number; end: number }; + timeZone?: string; + groupId?: string | null; +}) { + const forgotten: string[] = []; + const used: string[] = []; + const prisma = { + userNotificationSettings: { + findUnique: async () => + options.quietHours + ? { + quietHoursStart: options.quietHours.start, + quietHoursEnd: options.quietHours.end, + nudges: true, + } + : null, + }, + userProfile: { findUnique: async () => ({ timeZone: options.timeZone ?? null }) }, + pushSubscription: { + findMany: async () => + options.devices.map((device) => ({ + ...device, + endpoint: `endpoint-${device.id}`, + p256dh: null, + auth: null, + })), + deleteMany: async ({ where }: { where: { id: string } }) => { + forgotten.push(where.id); + return { count: 1 }; + }, + updateMany: async ({ where }: { where: { id: string } }) => { + used.push(where.id); + return { count: 1 }; + }, + }, + thread: { findUnique: async () => ({ groupId: options.groupId ?? null }) }, + } as unknown as PrismaClient; + return { prisma, forgotten, used }; +} + +function fakeSender(outcome: PushOutcome | Error) { + const sent: PushPayload[] = []; + const sender: PushSender = { + send: vi.fn(async (_target, payload) => { + sent.push(payload); + if (outcome instanceof Error) throw outcome; + return outcome; + }), + }; + return { sender, sent }; +} + +// 23:30 in Vienna (UTC+2 in summer). +const lateEvening = () => new Date("2026-07-01T21:30:00Z"); + +describe("push notification fan-out", () => { + it("sends to every device with a link to the conversation", async () => { + const { prisma, used } = fakeDb({ + devices: [ + { id: "browser", kind: "webpush" }, + { id: "phone", kind: "expo" }, + ], + groupId: "group-1", + }); + const web = fakeSender("sent"); + const expo = fakeSender("sent"); + await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send( + message, + context, + ); + expect(web.sent).toEqual([{ ...message, url: "/app/g/group-1" }]); + expect(expo.sent).toHaveLength(1); + expect(used.sort()).toEqual(["browser", "phone"]); + }); + + it("links a direct thread to its bot and shortens long bodies", async () => { + const { prisma } = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] }); + const web = fakeSender("sent"); + await new PushNotificationProvider(prisma, { webpush: web.sender }).send( + { ...message, body: "x".repeat(1_000) }, + context, + ); + expect(web.sent[0]?.url).toBe("/app/bot-1"); + expect(web.sent[0]?.body.length).toBeLessThanOrEqual(240); + }); + + it("holds every push back during quiet hours in the user's time zone", async () => { + const quiet = fakeDb({ + devices: [{ id: "browser", kind: "webpush" }], + quietHours: { start: 22 * 60, end: 7 * 60 }, + timeZone: "Europe/Vienna", + }); + const web = fakeSender("sent"); + await new PushNotificationProvider(quiet.prisma, { webpush: web.sender }, lateEvening).send( + message, + context, + ); + expect(web.sent).toHaveLength(0); + + // The same moment is mid-afternoon in Los Angeles. + const awake = fakeDb({ + devices: [{ id: "browser", kind: "webpush" }], + quietHours: { start: 22 * 60, end: 7 * 60 }, + timeZone: "America/Los_Angeles", + }); + await new PushNotificationProvider(awake.prisma, { webpush: web.sender }, lateEvening).send( + message, + context, + ); + expect(web.sent).toHaveLength(1); + }); + + it("drops devices the push service no longer knows", async () => { + const { prisma, forgotten, used } = fakeDb({ + devices: [ + { id: "old-browser", kind: "webpush" }, + { id: "phone", kind: "expo" }, + ], + }); + await new PushNotificationProvider(prisma, { + webpush: fakeSender("gone").sender, + expo: fakeSender("sent").sender, + }).send(message, context); + expect(forgotten).toEqual(["old-browser"]); + expect(used).toEqual(["phone"]); + }); + + it("succeeds when one device works and fails only when all of them fail", async () => { + const mixed = fakeDb({ + devices: [ + { id: "browser", kind: "webpush" }, + { id: "phone", kind: "expo" }, + ], + }); + await expect( + new PushNotificationProvider(mixed.prisma, { + webpush: fakeSender(new Error("Web push failed (500)")).sender, + expo: fakeSender("sent").sender, + }).send(message, context), + ).resolves.toBeUndefined(); + + const broken = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] }); + await expect( + new PushNotificationProvider(broken.prisma, { + webpush: fakeSender(new Error("Web push failed (500)")).sender, + }).send(message, context), + ).rejects.toThrow("Push delivery failed"); + }); + + it("does nothing without devices or senders", async () => { + const { prisma } = fakeDb({ devices: [{ id: "phone", kind: "expo" }] }); + const web = fakeSender("sent"); + await new PushNotificationProvider(prisma, { webpush: web.sender }).send(message, context); + expect(web.sent).toHaveLength(0); + }); +}); diff --git a/packages/adapters/src/web-push.test.ts b/packages/adapters/src/web-push.test.ts new file mode 100644 index 0000000..5199553 --- /dev/null +++ b/packages/adapters/src/web-push.test.ts @@ -0,0 +1,155 @@ +import { createECDH, randomBytes } from "node:crypto"; +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it, vi } from "vitest"; +import webPush from "web-push"; +import { EncryptedSecretStore } from "./secrets.js"; +import { type PushPayload, VapidKeys, vapidEnvFromProcess, WebPushSender } from "./web-push.js"; + +const browserKey = createECDH("prime256v1"); +browserKey.generateKeys(); +const device = { + endpoint: "https://push.example.test/device-1", + p256dh: browserKey.getPublicKey().toString("base64url"), + auth: randomBytes(16).toString("base64url"), +}; +const publicHost = async () => [{ address: "93.184.216.34", family: 4 }]; +const payload: PushPayload = { + kind: "completion", + title: "Chief finished", + body: "Done", + botId: "bot-1", + threadId: "thread-1", + url: "/app/g/group-1", +}; + +function settingsTable() { + const rows = new Map(); + const prisma = { + integrationProviderConfig: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + rows.has(where.id) ? { ciphertext: rows.get(where.id)! } : null, + ), + createMany: vi.fn(async ({ data }: { data: Array<{ id: string; ciphertext: string }> }) => { + for (const row of data) if (!rows.has(row.id)) rows.set(row.id, row.ciphertext); + return { count: data.length }; + }), + }, + } as unknown as PrismaClient; + return { prisma, rows }; +} + +const fixedKeys = { subject: "mailto:ops@example.test", ...webPush.generateVAPIDKeys() }; + +describe("VAPID keys", () => { + it("uses the configured key pair", async () => { + const { prisma, rows } = settingsTable(); + const keys = new VapidKeys(prisma, new EncryptedSecretStore("test-key"), fixedKeys); + await expect(keys.details()).resolves.toEqual(fixedKeys); + expect(rows.size).toBe(0); + }); + + it("generates one key pair, stores it encrypted and shares it across processes", async () => { + const { prisma, rows } = settingsTable(); + const secrets = new EncryptedSecretStore("test-key"); + const api = new VapidKeys(prisma, secrets, { publicKey: fixedKeys.publicKey }); + const worker = new VapidKeys(prisma, secrets); + const [fromApi, fromWorker] = await Promise.all([api.details(), worker.details()]); + expect(fromWorker).toEqual(fromApi); + expect(fromApi.publicKey).not.toBe(fixedKeys.publicKey); + expect(fromApi.subject).toBe("mailto:admin@localhost"); + expect(rows.size).toBe(1); + const [ciphertext] = [...rows.values()]; + expect(ciphertext).not.toContain(fromApi.privateKey); + await expect(new VapidKeys(prisma, secrets).publicKey()).resolves.toBe(fromApi.publicKey); + }); + + it("reports no public key when the configured keys are invalid", async () => { + const { prisma } = settingsTable(); + const keys = new VapidKeys(prisma, new EncryptedSecretStore("test-key"), { + ...fixedKeys, + privateKey: "not-a-key", + }); + await expect(keys.publicKey()).resolves.toBeNull(); + }); + + it("reads the environment and uses an https web origin as the contact", () => { + expect( + vapidEnvFromProcess({ + VAPID_PUBLIC_KEY: " pub ", + VAPID_PRIVATE_KEY: "priv", + WEB_ORIGIN: "https://app.example.test", + }), + ).toEqual({ publicKey: "pub", privateKey: "priv", subject: "https://app.example.test" }); + expect(vapidEnvFromProcess({ WEB_ORIGIN: "http://127.0.0.1:5173" }).subject).toBeUndefined(); + expect( + vapidEnvFromProcess({ VAPID_SUBJECT: "mailto:a@example.test", WEB_ORIGIN: "https://x.test" }) + .subject, + ).toBe("mailto:a@example.test"); + }); +}); + +describe("web push sender", () => { + const vapid = { details: async () => fixedKeys }; + + it("posts an encrypted, VAPID-signed message to the endpoint", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 201 })); + const sender = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost }); + await expect(sender.send(device, payload)).resolves.toBe("sent"); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(device.endpoint); + expect(init.method).toBe("POST"); + expect(init.redirect).toBe("manual"); + const headers = new Headers(init.headers); + expect(headers.get("content-encoding")).toBe("aes128gcm"); + expect(headers.get("ttl")).toBe("86400"); + expect(headers.get("authorization")).toMatch(/^vapid t=.+, k=/); + expect(headers.has("content-length")).toBe(false); + // The body is ciphertext: the notification text never travels in the clear. + expect(Buffer.from(init.body as Uint8Array).toString("utf8")).not.toContain("Chief finished"); + }); + + it("reports an expired subscription as gone", async () => { + for (const status of [404, 410]) { + const sender = new WebPushSender(vapid, { + fetch: async () => new Response(null, { status }), + resolveHostname: publicHost, + }); + await expect(sender.send(device, payload)).resolves.toBe("gone"); + } + }); + + it("throws on other failures without naming the endpoint", async () => { + const sender = new WebPushSender(vapid, { + fetch: async () => new Response("quota exceeded", { status: 429 }), + resolveHostname: publicHost, + }); + const error = await sender.send(device, payload).catch((caught: Error) => caught); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Web push failed (429)"); + expect((error as Error).message).not.toContain("push.example.test"); + }); + + it("never connects to a private or non-https endpoint", async () => { + const fetchMock = vi.fn(); + const privateHost = new WebPushSender(vapid, { + fetch: fetchMock, + resolveHostname: async () => [{ address: "10.0.0.5", family: 4 }], + }); + await expect(privateHost.send(device, payload)).rejects.toThrow("private address"); + const plainHttp = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost }); + await expect( + plainHttp.send({ ...device, endpoint: "http://push.example.test/device-1" }, payload), + ).rejects.toThrow("https"); + const loopback = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost }); + await expect( + loopback.send({ ...device, endpoint: "https://127.0.0.1/device-1" }, payload), + ).rejects.toThrow("private or internal host"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("treats a subscription without keys as gone", async () => { + const sender = new WebPushSender(vapid, { fetch: vi.fn(), resolveHostname: publicHost }); + await expect(sender.send({ ...device, p256dh: null }, payload)).resolves.toBe("gone"); + }); +}); diff --git a/packages/core/src/quiet-hours.test.ts b/packages/core/src/quiet-hours.test.ts new file mode 100644 index 0000000..183ee65 --- /dev/null +++ b/packages/core/src/quiet-hours.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { isQuietMinute, isQuietTime, localDateAndMinute } from "./quiet-hours.js"; + +const minute = (clock: string) => { + const [hour, min] = clock.split(":").map(Number); + return hour! * 60 + min!; +}; + +describe("quiet hours", () => { + it("covers a same-day range with an inclusive start and an exclusive end", () => { + const lunch = { start: minute("12:00"), end: minute("13:30") }; + expect(isQuietMinute(lunch, minute("11:59"))).toBe(false); + expect(isQuietMinute(lunch, minute("12:00"))).toBe(true); + expect(isQuietMinute(lunch, minute("13:29"))).toBe(true); + expect(isQuietMinute(lunch, minute("13:30"))).toBe(false); + }); + + it("covers a range that crosses midnight", () => { + const night = { start: minute("22:00"), end: minute("07:00") }; + expect(isQuietMinute(night, minute("21:59"))).toBe(false); + expect(isQuietMinute(night, minute("22:00"))).toBe(true); + expect(isQuietMinute(night, minute("23:59"))).toBe(true); + expect(isQuietMinute(night, minute("00:00"))).toBe(true); + expect(isQuietMinute(night, minute("06:59"))).toBe(true); + expect(isQuietMinute(night, minute("07:00"))).toBe(false); + expect(isQuietMinute(night, minute("12:00"))).toBe(false); + }); + + it("is never quiet without a range or with an empty one", () => { + expect(isQuietMinute(null, minute("03:00"))).toBe(false); + expect(isQuietMinute({ start: 300, end: 300 }, 300)).toBe(false); + }); + + it("reads the wall clock in the user's time zone", () => { + // 21:30 UTC is 23:30 in Vienna (CEST, UTC+2) and 14:30 in Los Angeles (PDT, UTC-7). + const now = new Date("2026-07-01T21:30:00Z"); + const night = { start: minute("22:00"), end: minute("07:00") }; + expect(isQuietTime(night, "Europe/Vienna", now)).toBe(true); + expect(isQuietTime(night, "America/Los_Angeles", now)).toBe(false); + expect(isQuietTime(night, null, now)).toBe(false); + }); + + it("falls back to UTC for an unknown zone and reports the local date", () => { + const now = new Date("2026-01-31T23:15:00Z"); + expect(localDateAndMinute(now, "Not/AZone")).toEqual({ + date: "2026-01-31", + minute: minute("23:15"), + }); + // Already the next day in Tokyo (UTC+9). + expect(localDateAndMinute(now, "Asia/Tokyo")).toEqual({ + date: "2026-02-01", + minute: minute("08:15"), + }); + }); + + it("follows daylight saving changes", () => { + const night = { start: minute("22:00"), end: minute("07:00") }; + // 05:30 UTC is 06:30 in Vienna in winter (CET) and 07:30 in summer (CEST). + expect(isQuietTime(night, "Europe/Vienna", new Date("2026-01-15T05:30:00Z"))).toBe(true); + expect(isQuietTime(night, "Europe/Vienna", new Date("2026-07-15T05:30:00Z"))).toBe(false); + }); +}); diff --git a/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql index c684498..cd83c49 100644 --- a/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql +++ b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql @@ -44,7 +44,9 @@ ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_webpush_keys ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_quiet_hours_chk" CHECK ( ("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL) OR ( - "quietHoursStart" BETWEEN 0 AND 1439 + "quietHoursStart" IS NOT NULL + AND "quietHoursEnd" IS NOT NULL + AND "quietHoursStart" BETWEEN 0 AND 1439 AND "quietHoursEnd" BETWEEN 0 AND 1439 AND "quietHoursStart" <> "quietHoursEnd" ) diff --git a/packages/db/src/notification-settings.postgres.test.ts b/packages/db/src/notification-settings.postgres.test.ts new file mode 100644 index 0000000..624f9dc --- /dev/null +++ b/packages/db/src/notification-settings.postgres.test.ts @@ -0,0 +1,184 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { PrismaClient } from "./client.js"; +import { createDb } from "./client.js"; +import { + claimDailyNudge, + getNotificationSettings, + loadNotificationPolicy, + updateNotificationSettings, +} from "./notification-settings.js"; +import { + listPushSubscriptions, + PUSH_SUBSCRIPTION_LIMIT, + removePushSubscription, + removePushSubscriptionsOfKind, + savePushSubscription, +} from "./push-subscriptions.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; + +describePostgres("notification settings and push subscriptions (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser() { + const id = `ns${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + return id; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("starts with no quiet hours and nudges on, and applies partial updates", async () => { + const userId = await newUser(); + await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({ + quietHours: null, + nudges: true, + }); + + await expect( + updateNotificationSettings(prisma, userId, { quietHours: { start: "22:30", end: "07:00" } }), + ).resolves.toEqual({ quietHours: { start: "22:30", end: "07:00" }, nudges: true }); + const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } }); + expect([row.quietHoursStart, row.quietHoursEnd]).toEqual([22 * 60 + 30, 7 * 60]); + + await expect(updateNotificationSettings(prisma, userId, { nudges: false })).resolves.toEqual({ + quietHours: { start: "22:30", end: "07:00" }, + nudges: false, + }); + await expect(updateNotificationSettings(prisma, userId, { quietHours: null })).resolves.toEqual( + { quietHours: null, nudges: false }, + ); + }); + + it("rejects an empty quiet-hours range in the repository and in the database", async () => { + const userId = await newUser(); + await expect( + updateNotificationSettings(prisma, userId, { quietHours: { start: "08:00", end: "08:00" } }), + ).rejects.toThrow(); + await expect( + prisma.userNotificationSettings.create({ + data: { userId, quietHoursStart: 60, quietHoursEnd: null }, + }), + ).rejects.toThrow(); + await expect( + prisma.userNotificationSettings.create({ + data: { userId, quietHoursStart: 1440, quietHoursEnd: 60 }, + }), + ).rejects.toThrow(); + }); + + it("judges quiet hours in the profile time zone", async () => { + const userId = await newUser(); + await updateNotificationSettings(prisma, userId, { + quietHours: { start: "22:00", end: "07:00" }, + }); + const now = new Date("2026-07-01T21:30:00Z"); + await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toMatchObject({ + quiet: false, + localDate: "2026-07-01", + }); + await prisma.userProfile.create({ data: { userId, timeZone: "Europe/Vienna" } }); + await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toEqual({ + quiet: true, + nudges: true, + localDate: "2026-07-01", + }); + }); + + it("caps nudges per local day, also under concurrency", async () => { + const userId = await newUser(); + const claims = await Promise.all( + Array.from({ length: 6 }, () => claimDailyNudge(prisma, userId, "2026-07-01")), + ); + expect(claims.filter(Boolean)).toHaveLength(3); + await expect(claimDailyNudge(prisma, userId, "2026-07-01")).resolves.toBe(false); + await expect(claimDailyNudge(prisma, userId, "2026-07-02")).resolves.toBe(true); + const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } }); + expect(row.nudgeCount).toBe(1); + // Claiming never changes the user's settings. + await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({ + quietHours: null, + nudges: true, + }); + }); + + it("keeps devices per user, moves a re-registered endpoint and caps the list", async () => { + const owner = await newUser(); + const other = await newUser(); + const endpoint = `https://push.example.test/${randomUUID()}`; + await savePushSubscription(prisma, owner, { kind: "webpush", endpoint, ...keys }); + await savePushSubscription(prisma, owner, { kind: "expo", endpoint: "ExponentPushToken[a]" }); + expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual([ + "webpush", + "expo", + ]); + + // Another user cannot remove the owner's device by naming its endpoint. + await removePushSubscription(prisma, other, endpoint); + expect(await listPushSubscriptions(prisma, owner)).toHaveLength(2); + + // The same browser signed into another account now delivers to that account. + await savePushSubscription(prisma, other, { kind: "webpush", endpoint, ...keys }); + expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual(["expo"]); + expect((await listPushSubscriptions(prisma, other)).map((row) => row.endpoint)).toEqual([ + endpoint, + ]); + + await removePushSubscriptionsOfKind(prisma, owner, "expo"); + expect(await listPushSubscriptions(prisma, owner)).toEqual([]); + + for (let index = 0; index <= PUSH_SUBSCRIPTION_LIMIT; index += 1) { + await savePushSubscription(prisma, owner, { + kind: "expo", + endpoint: `ExponentPushToken[${owner}-${index}]`, + }); + } + const kept = await listPushSubscriptions(prisma, owner); + expect(kept).toHaveLength(PUSH_SUBSCRIPTION_LIMIT); + expect(kept.map((row) => row.endpoint)).not.toContain(`ExponentPushToken[${owner}-0]`); + }); + + it("requires keys for web push and removes everything with the account", async () => { + const userId = await newUser(); + await expect( + prisma.pushSubscription.create({ + data: { userId, kind: "webpush", endpoint: `https://push.example.test/${userId}` }, + }), + ).rejects.toThrow(); + await expect( + prisma.pushSubscription.create({ + data: { userId, kind: "sms", endpoint: `sms-${userId}` }, + }), + ).rejects.toThrow(); + await savePushSubscription(prisma, userId, { + kind: "expo", + endpoint: `ExponentPushToken[${userId}]`, + }); + await updateNotificationSettings(prisma, userId, { nudges: false }); + + await prisma.user.delete({ where: { id: userId } }); + expect(await prisma.pushSubscription.count({ where: { userId } })).toBe(0); + expect(await prisma.userNotificationSettings.count({ where: { userId } })).toBe(0); + }); +}); diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..dbd9559 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -106,9 +106,11 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/notifications.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", + "packages/db/src/notification-settings.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts", "packages/adapters/src/wakeup.postgres.test.ts", @@ -117,6 +119,7 @@ async function main() { "packages/adapters/src/cloud-agent.postgres.test.ts", "packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/profile-consolidation.postgres.test.ts", + "packages/adapters/src/notify-user.postgres.test.ts", ]; // Each app reconciles all durable work in its database, including intentionally // unfinished fixture runs. Clone the pristine migrated schema so one suite diff --git a/packages/testkit/src/notifications.postgres.test.ts b/packages/testkit/src/notifications.postgres.test.ts new file mode 100644 index 0000000..9668046 --- /dev/null +++ b/packages/testkit/src/notifications.postgres.test.ts @@ -0,0 +1,198 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposioEmulator } from "@rakazo/adapters"; +import type { NotificationSettings, WebPushConfig } from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; + +const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; + +describeWithDatabase("notifications through the API", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-notifications-")); + + beforeAll(async () => { + const { createApp } = await import("../../../apps/api/src/app.ts"); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + composio: new ComposioEmulator(), + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + async function devices(cookie: string) { + const me = await rpc<{ userId: string }>(app, cookie, "me"); + return handles.prisma.pushSubscription.findMany({ + where: { userId: me.userId }, + orderBy: { createdAt: "asc" }, + select: { kind: true, endpoint: true, userAgent: true }, + }); + } + + it("reads and updates the caller's own settings", async () => { + const owner = await signup(app, `settings-owner-${stamp}@rakazo.test`, "Settings Owner"); + const other = await signup(app, `settings-other-${stamp}@rakazo.test`, "Settings Other"); + + await expect( + rpc(app, owner, "notifications/settings/get"), + ).resolves.toEqual({ quietHours: null, nudges: true }); + await expect( + rpc(app, owner, "notifications/settings/update", { + quietHours: { start: "21:00", end: "06:30" }, + nudges: false, + }), + ).resolves.toEqual({ quietHours: { start: "21:00", end: "06:30" }, nudges: false }); + await expect( + rpc(app, other, "notifications/settings/get"), + ).resolves.toEqual({ quietHours: null, nudges: true }); + + for (const invalid of [ + { quietHours: { start: "9:00", end: "17:00" } }, + { quietHours: { start: "24:00", end: "06:00" } }, + { quietHours: { start: "08:00", end: "08:00" } }, + ]) { + const response = await raw(app, owner, "notifications/settings/update", invalid); + expect(response.status, JSON.stringify(invalid)).toBe(400); + } + await expect( + rpc(app, owner, "notifications/settings/get"), + ).resolves.toEqual({ quietHours: { start: "21:00", end: "06:30" }, nudges: false }); + }); + + it("hands browsers one stable public key", async () => { + const cookie = await signup(app, `push-config-${stamp}@rakazo.test`, "Push Config"); + const first = await rpc(app, cookie, "notifications/webPush/config"); + expect(first.available).toBe(true); + expect(first.publicKey).toMatch(/^[A-Za-z0-9_-]{87}$/); + await expect(rpc(app, cookie, "notifications/webPush/config")).resolves.toEqual( + first, + ); + const stored = await handles.prisma.integrationProviderConfig.findUniqueOrThrow({ + where: { id: "web-push-vapid" }, + }); + expect(stored.ciphertext).not.toContain(first.publicKey!); + }); + + it("registers browsers and phones per user, and only the owner removes them", async () => { + const owner = await signup(app, `push-owner-${stamp}@rakazo.test`, "Push Owner"); + const intruder = await signup(app, `push-intruder-${stamp}@rakazo.test`, "Push Intruder"); + const endpoint = `https://push.example.test/${stamp}`; + + await rpc(app, owner, "notifications/webPush/subscribe", { endpoint, keys }); + await rpc(app, owner, "notifications/registerPush", { token: `ExponentPushToken[${stamp}]` }); + expect(await devices(owner)).toEqual([ + { kind: "webpush", endpoint, userAgent: "" }, + { kind: "expo", endpoint: `ExponentPushToken[${stamp}]`, userAgent: "" }, + ]); + + await rpc(app, intruder, "notifications/webPush/unsubscribe", { endpoint }); + await rpc(app, intruder, "notifications/unregisterPush"); + expect(await devices(owner)).toHaveLength(2); + + await rpc(app, owner, "notifications/unregisterPush"); + expect(await devices(owner)).toEqual([{ kind: "webpush", endpoint, userAgent: "" }]); + await rpc(app, owner, "notifications/webPush/unsubscribe", { endpoint }); + expect(await devices(owner)).toEqual([]); + }); + + it("rejects endpoints that are not public https push services", async () => { + const cookie = await signup(app, `push-invalid-${stamp}@rakazo.test`, "Push Invalid"); + for (const endpoint of [ + "http://push.example.test/device", + "https://127.0.0.1/device", + "https://localhost/device", + "https://10.0.0.8/device", + "https://user:secret@push.example.test/device", + "not a url", + ]) { + const response = await raw(app, cookie, "notifications/webPush/subscribe", { + endpoint, + keys, + }); + expect(response.status, endpoint).toBe(400); + } + const badKeys = await raw(app, cookie, "notifications/webPush/subscribe", { + endpoint: `https://push.example.test/${stamp}-keys`, + keys: { p256dh: "short", auth: keys.auth }, + }); + expect(badKeys.status).toBe(400); + const badToken = await raw(app, cookie, "notifications/registerPush", { + token: "https://push.example.test/not-an-expo-token", + }); + expect(badToken.status).toBe(400); + expect(await devices(cookie)).toEqual([]); + }); + + it("refuses every notification procedure without a session", async () => { + for (const [procedure, body] of [ + ["notifications/settings/get", {}], + ["notifications/settings/update", { nudges: false }], + ["notifications/webPush/config", {}], + ["notifications/webPush/subscribe", { endpoint: `https://push.example.test/${stamp}`, keys }], + ["notifications/webPush/unsubscribe", { endpoint: `https://push.example.test/${stamp}` }], + ["notifications/registerPush", { token: "ExponentPushToken[anon]" }], + ["notifications/unregisterPush", {}], + ] as const) { + const response = await raw(app, "", procedure, body); + expect(response.status, procedure).toBe(401); + } + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(cookie ? { cookie } : {}), + origin: "http://127.0.0.1:5173", + }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +} From 7185dcdd3cccb5b050e95b7263df684f72e4a8e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:34:38 +0000 Subject: [PATCH 15/38] feat(web): notification settings, and sign-out stops this browser's push Users had no way to turn on push for a browser, set quiet hours or opt out of nudges. Settings gains a compact Notifications section: a switch for push on this device (only where web push works: a secure context outside the desktop app), quiet hours with from/to times (22:00-07:00 when first turned on) and a switch for nudges. Changes save in order as they are made. A browser that blocked notifications says so instead of offering a dead switch. Logging out unregisters the browser first, so a shared computer stops receiving the account's pushes. The E2E stubs the Push API (headless Chromium has no push service), walks all three controls, checks they survive a reload and captures the notification-settings screenshot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/helpers.ts | 1 + apps/web/e2e/notification-settings.spec.ts | 112 ++++++++++ .../components/NotificationSettingsPanel.tsx | 197 ++++++++++++++++++ apps/web/src/pages/SettingsOverlay.tsx | 5 + apps/web/src/pages/Shell.tsx | 13 +- 5 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 apps/web/e2e/notification-settings.spec.ts create mode 100644 apps/web/src/components/NotificationSettingsPanel.tsx diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 84d7794..32b77a3 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -242,6 +242,7 @@ export async function openUserSettings( | "memory" | "profile" | "voice" + | "notifications" | "usage" | "computer" | "updates", diff --git a/apps/web/e2e/notification-settings.spec.ts b/apps/web/e2e/notification-settings.spec.ts new file mode 100644 index 0000000..0716977 --- /dev/null +++ b/apps/web/e2e/notification-settings.spec.ts @@ -0,0 +1,112 @@ +/** + * E2E: Settings → Notifications turns push on for this browser (the Push API is stubbed: headless + * Chromium has no push service), sets quiet hours and switches nudges off; all of it survives a + * reload. + */ +import { expect, type Page, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; + +const ENDPOINT = "https://push.example.test/e2e-device"; + +/** A fake service worker registration whose push subscription lives in sessionStorage. */ +async function stubPushApi(page: Page, endpoint: string) { + await page.addInitScript((pushEndpoint) => { + const storageKey = "e2e:push-subscription"; + const current = () => { + const stored = sessionStorage.getItem(storageKey); + if (!stored) return null; + const saved = JSON.parse(stored) as { serverKey: number[] }; + return { + endpoint: pushEndpoint, + options: { applicationServerKey: new Uint8Array(saved.serverKey).buffer }, + toJSON: () => ({ + endpoint: pushEndpoint, + keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }, + }), + unsubscribe: async () => { + sessionStorage.removeItem(storageKey); + return true; + }, + }; + }; + const registration = { + scope: `${location.origin}/`, + pushManager: { + getSubscription: async () => current(), + subscribe: async (options: { applicationServerKey: Uint8Array }) => { + sessionStorage.setItem( + storageKey, + JSON.stringify({ serverKey: [...new Uint8Array(options.applicationServerKey)] }), + ); + return current(); + }, + }, + }; + Object.defineProperty(navigator, "serviceWorker", { + configurable: true, + value: { + register: async () => registration, + getRegistration: async () => registration, + ready: Promise.resolve(registration), + }, + }); + if (!("PushManager" in window)) { + Object.defineProperty(window, "PushManager", { configurable: true, value: class {} }); + } + }, endpoint); +} + +test("notification settings: push on this device, quiet hours and nudges", async ({ + page, +}, testInfo) => { + await page.context().grantPermissions(["notifications"]); + await stubPushApi(page, ENDPOINT); + await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester"); + await completeOnboarding(page); + + let settings = await openUserSettings(page, "notifications"); + const panel = settings.getByTestId("notification-settings"); + const push = panel.getByTestId("notification-push-switch"); + await expect(push).not.toBeChecked(); + + const subscribed = page.waitForRequest("**/rpc/notifications/webPush/subscribe"); + await push.click(); + expect((await subscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } }); + await expect(push).toBeChecked(); + + const quiet = panel.getByTestId("notification-quiet-hours-switch"); + await expect(quiet).not.toBeChecked(); + await quiet.click(); + await expect(quiet).toBeChecked(); + const start = panel.getByTestId("notification-quiet-hours-start"); + await expect(start).toHaveValue("22:00"); + await expect(panel.getByTestId("notification-quiet-hours-end")).toHaveValue("07:00"); + const saved = page.waitForResponse( + (response) => + response.url().includes("/rpc/notifications/settings/update") && + response.request().postData()?.includes("23:30") === true, + ); + await start.fill("23:30"); + await saved; + + const nudges = panel.getByTestId("notification-nudges-switch"); + await expect(nudges).toBeChecked(); + await nudges.click(); + await expect(nudges).not.toBeChecked(); + await expect + .poll(() => rpc<{ nudges: boolean }>(page, "notifications/settings/get", null)) + .toMatchObject({ nudges: false, quietHours: { start: "23:30", end: "07:00" } }); + + await captureScreenshot(page, testInfo, "notification-settings"); + + await page.reload(); + settings = await openUserSettings(page, "notifications"); + await expect(settings.getByTestId("notification-push-switch")).toBeChecked(); + await expect(settings.getByTestId("notification-quiet-hours-start")).toHaveValue("23:30"); + await expect(settings.getByTestId("notification-nudges-switch")).not.toBeChecked(); + + const unsubscribed = page.waitForRequest("**/rpc/notifications/webPush/unsubscribe"); + await settings.getByTestId("notification-push-switch").click(); + expect((await unsubscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } }); + await expect(settings.getByTestId("notification-push-switch")).not.toBeChecked(); +}); diff --git a/apps/web/src/components/NotificationSettingsPanel.tsx b/apps/web/src/components/NotificationSettingsPanel.tsx new file mode 100644 index 0000000..48df0f9 --- /dev/null +++ b/apps/web/src/components/NotificationSettingsPanel.tsx @@ -0,0 +1,197 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { NotificationSettings, NotificationSettingsPatch } from "@rakazo/contracts"; +import { Input, Switch } from "@rakazo/ui-web"; +import type { ReactNode } from "react"; +import { useEffect, useId, useRef, useState } from "react"; +import { rpc } from "../lib/rpc"; +import { disableWebPush, enableWebPush, webPushEnabled, webPushSupported } from "../lib/web-push"; + +const DEFAULT_QUIET_HOURS = { start: "22:00", end: "07:00" }; +const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/; + +type PushState = "loading" | "on" | "off" | "blocked"; + +function SettingRow({ + label, + testId, + checked, + disabled, + onCheckedChange, + children, +}: { + label: ReactNode; + testId: string; + checked: boolean; + disabled?: boolean; + onCheckedChange: (checked: boolean) => void; + children?: ReactNode; +}) { + const labelId = useId(); + return ( +
  • +
    + + {label} + + +
    + {children} +
  • + ); +} + +/** Push on this browser, quiet hours and nudges. Quiet hours and nudges apply to every device. */ +export function NotificationSettingsPanel() { + const { t } = useLingui(); + const pushSupported = webPushSupported(); + const [settings, setSettings] = useState(null); + const [push, setPush] = useState("loading"); + const [pushBusy, setPushBusy] = useState(false); + const [error, setError] = useState(null); + const [quietDraft, setQuietDraft] = useState(DEFAULT_QUIET_HOURS); + const saves = useRef(Promise.resolve()); + + useEffect(() => { + let cancelled = false; + rpc.notifications.settings + .get() + .then((next) => { + if (cancelled) return; + setSettings(next); + if (next.quietHours) setQuietDraft(next.quietHours); + }) + .catch(() => { + if (!cancelled) setError(t`Couldn't load`); + }); + return () => { + cancelled = true; + }; + }, [t]); + + useEffect(() => { + if (!pushSupported) return; + let cancelled = false; + void webPushEnabled().then((enabled) => { + if (cancelled) return; + setPush(enabled ? "on" : Notification.permission === "denied" ? "blocked" : "off"); + }); + return () => { + cancelled = true; + }; + }, [pushSupported]); + + /** Saves in order, so the last change the user made is the one that sticks. */ + function save(patch: NotificationSettingsPatch) { + setError(null); + setSettings((current) => (current ? { ...current, ...patch } : current)); + saves.current = saves.current.then(async () => { + try { + setSettings(await rpc.notifications.settings.update(patch)); + } catch { + setError(t`Couldn't save`); + setSettings(await rpc.notifications.settings.get().catch(() => null)); + } + }); + } + + async function changePush(next: boolean) { + setPushBusy(true); + setError(null); + try { + if (!next) { + await disableWebPush(); + setPush("off"); + return; + } + const result = await enableWebPush(); + if (result === "enabled") setPush("on"); + else if (result === "denied") setPush("blocked"); + else setError(t`Couldn't save`); + } catch { + setError(t`Couldn't save`); + } finally { + setPushBusy(false); + } + } + + function changeQuietTime(field: "start" | "end", value: string) { + const next = { ...quietDraft, [field]: value }; + setQuietDraft(next); + if (CLOCK_TIME.test(next.start) && CLOCK_TIME.test(next.end) && next.start !== next.end) { + save({ quietHours: next }); + } + } + + return ( +
    + {error ? ( +

    + {error} +

    + ) : null} + {settings ? ( +
    +
      + {pushSupported ? ( + Push on this device} + testId="notification-push-switch" + checked={push === "on"} + disabled={push === "loading" || push === "blocked" || pushBusy} + onCheckedChange={(next) => void changePush(next)} + > + {push === "blocked" ? ( +

      + Blocked in your browser settings +

      + ) : null} +
      + ) : null} + Quiet hours} + testId="notification-quiet-hours-switch" + checked={settings.quietHours !== null} + onCheckedChange={(next) => save({ quietHours: next ? quietDraft : null })} + > + {settings.quietHours ? ( +
      + changeQuietTime("start", event.target.value)} + className="w-32" + /> + + changeQuietTime("end", event.target.value)} + className="w-32" + /> +
      + ) : null} +
      + Nudges from your Oo} + testId="notification-nudges-switch" + checked={settings.nudges} + onCheckedChange={(next) => save({ nudges: next })} + /> +
    +
    + ) : null} +
    + ); +} diff --git a/apps/web/src/pages/SettingsOverlay.tsx b/apps/web/src/pages/SettingsOverlay.tsx index d71d40a..9f1b8f8 100644 --- a/apps/web/src/pages/SettingsOverlay.tsx +++ b/apps/web/src/pages/SettingsOverlay.tsx @@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro"; import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts"; import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web"; import { + Bell, Brain, CloudDownload, Cpu, @@ -16,6 +17,7 @@ import { } from "lucide-react"; import { type ComponentType, useEffect, useRef, useState } from "react"; import { computersAreUnavailable } from "../components/ComputersUnavailableHint"; +import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel"; import { UserProfileSettings } from "../components/UserProfileSettings"; import type { TradingBotUiState } from "../lib/trading-bot-status"; import { @@ -37,6 +39,7 @@ export type SettingsSection = | "memory" | "profile" | "voice" + | "notifications" | "usage" | "computer" | "updates" @@ -111,6 +114,7 @@ export function SettingsOverlay({ { id: "memory", label: t`Memory`, icon: Brain }, { id: "profile", label: t`About you`, icon: UserRound }, { id: "voice", label: t`Voice`, icon: Volume2 }, + { id: "notifications", label: t`Notifications`, icon: Bell }, { id: "usage", label: t`Usage`, icon: Gauge }, ...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []), { id: "updates", label: t`Updates`, icon: CloudDownload }, @@ -250,6 +254,7 @@ export function SettingsOverlay({ /> ) : null} {section === "profile" ? : null} + {section === "notifications" ? : null} {section === "usage" ? ( ) : null} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index bf25ff9..878063f 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -247,6 +247,7 @@ import { transcriptMovedDown, } from "../lib/transcript-scroll"; import { speaker } from "../lib/tts"; +import { disableWebPush } from "../lib/web-push"; import { ActivityList } from "./ActivityList"; import type { ContextMenuPosition } from "./BotContextMenu"; import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel"; @@ -4296,10 +4297,14 @@ export function ShellPage() { variant="ghost" className="w-full justify-start font-normal" onClick={() => - void authClient.signOut().then(() => { - clearSpaceSelection(); - navigate("/"); - }) + // This browser stops receiving the account's pushes once it signs out. + void disableWebPush() + .catch(() => undefined) + .then(() => authClient.signOut()) + .then(() => { + clearSpaceSelection(); + navigate("/"); + }) } > From 7a58bc7ba99be996a961cf93f225cdfe9b9554d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:42:24 +0000 Subject: [PATCH 16/38] fix(profile): keep the fact editor's Save label visible Small buttons in ui-web merge their text-ui-label size over the default variant's text color, so the small Save button rendered ink on ink. The editor now uses default-size controls, with the category select at the same height. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/src/components/UserFactEditor.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/UserFactEditor.tsx b/apps/web/src/components/UserFactEditor.tsx index a7b7e9a..17306e1 100644 --- a/apps/web/src/components/UserFactEditor.tsx +++ b/apps/web/src/components/UserFactEditor.tsx @@ -64,7 +64,7 @@ export function UserFactEditor({ />
    @@ -80,7 +80,7 @@ export function UserFactEditor({ {showSpaces ? ( } + render={ -
    From 2d95db70f2fe777c422a9fe237258079079be344 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:49:49 +0000 Subject: [PATCH 17/38] refactor(notifications): keep the push sender contract next to the fan-out The payload, target and outcome types are shared by the Expo and web push senders, so they now live with PushSender in push-notifications.ts instead of in the web push adapter. The settings E2E also stubs the Notification permission (headless Chromium reports it as denied even when granted), and the quiet-hours inputs are wide enough for 12-hour times. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/notification-settings.spec.ts | 20 +++++++++++++++++-- .../components/NotificationSettingsPanel.tsx | 4 ++-- packages/adapters/src/expo-push.test.ts | 2 +- packages/adapters/src/expo-push.ts | 2 +- .../adapters/src/notify-user.postgres.test.ts | 7 +++++-- .../adapters/src/push-notifications.test.ts | 8 ++++++-- packages/adapters/src/push-notifications.ts | 15 +++++++++++++- packages/adapters/src/web-push.test.ts | 3 ++- packages/adapters/src/web-push.ts | 16 +-------------- 9 files changed, 50 insertions(+), 27 deletions(-) diff --git a/apps/web/e2e/notification-settings.spec.ts b/apps/web/e2e/notification-settings.spec.ts index 0716977..4d7893f 100644 --- a/apps/web/e2e/notification-settings.spec.ts +++ b/apps/web/e2e/notification-settings.spec.ts @@ -8,9 +8,26 @@ import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } const ENDPOINT = "https://push.example.test/e2e-device"; -/** A fake service worker registration whose push subscription lives in sessionStorage. */ +/** + * A fake Notification permission and service worker registration, kept in sessionStorage so they + * survive a reload. Headless Chromium reports notifications as denied even when granted. + */ async function stubPushApi(page: Page, endpoint: string) { await page.addInitScript((pushEndpoint) => { + const permissionKey = "e2e:notification-permission"; + Object.defineProperty(window, "Notification", { + configurable: true, + writable: true, + value: class { + static get permission() { + return sessionStorage.getItem(permissionKey) ?? "default"; + } + static async requestPermission() { + sessionStorage.setItem(permissionKey, "granted"); + return "granted"; + } + }, + }); const storageKey = "e2e:push-subscription"; const current = () => { const stored = sessionStorage.getItem(storageKey); @@ -59,7 +76,6 @@ async function stubPushApi(page: Page, endpoint: string) { test("notification settings: push on this device, quiet hours and nudges", async ({ page, }, testInfo) => { - await page.context().grantPermissions(["notifications"]); await stubPushApi(page, ENDPOINT); await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester"); await completeOnboarding(page); diff --git a/apps/web/src/components/NotificationSettingsPanel.tsx b/apps/web/src/components/NotificationSettingsPanel.tsx index 48df0f9..de71979 100644 --- a/apps/web/src/components/NotificationSettingsPanel.tsx +++ b/apps/web/src/components/NotificationSettingsPanel.tsx @@ -167,7 +167,7 @@ export function NotificationSettingsPanel() { aria-label={t`Quiet hours start`} value={quietDraft.start} onChange={(event) => changeQuietTime("start", event.target.value)} - className="w-32" + className="w-36" />
    ) : null} diff --git a/packages/adapters/src/expo-push.test.ts b/packages/adapters/src/expo-push.test.ts index c42f8a1..9052444 100644 --- a/packages/adapters/src/expo-push.test.ts +++ b/packages/adapters/src/expo-push.test.ts @@ -10,7 +10,7 @@ import { legacyPushTokenDir, MAX_EXPO_PUSH_RESPONSE_BYTES, } from "./expo-push.js"; -import type { PushPayload } from "./web-push.js"; +import type { PushPayload } from "./push-notifications.js"; const dirs: string[] = []; diff --git a/packages/adapters/src/expo-push.ts b/packages/adapters/src/expo-push.ts index c22f416..20192f4 100644 --- a/packages/adapters/src/expo-push.ts +++ b/packages/adapters/src/expo-push.ts @@ -5,7 +5,7 @@ import { ExpoPushTokenSchema } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; import { savePushSubscription } from "@rakazo/db"; import { combineSignals } from "./connector-safety.js"; -import type { PushOutcome, PushPayload, PushTarget } from "./web-push.js"; +import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js"; import { readBodyCapped, withAbort } from "./web-ssrf.js"; const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; diff --git a/packages/adapters/src/notify-user.postgres.test.ts b/packages/adapters/src/notify-user.postgres.test.ts index f0105f5..e5e63d2 100644 --- a/packages/adapters/src/notify-user.postgres.test.ts +++ b/packages/adapters/src/notify-user.postgres.test.ts @@ -9,8 +9,11 @@ import { } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { notifyUserFromTool } from "./notify-user-tool.js"; -import { PushNotificationProvider, type PushSender } from "./push-notifications.js"; -import type { PushPayload } from "./web-push.js"; +import { + PushNotificationProvider, + type PushPayload, + type PushSender, +} from "./push-notifications.js"; const databaseUrl = process.env.DATABASE_URL; const describePostgres = diff --git a/packages/adapters/src/push-notifications.test.ts b/packages/adapters/src/push-notifications.test.ts index c305f94..e457023 100644 --- a/packages/adapters/src/push-notifications.test.ts +++ b/packages/adapters/src/push-notifications.test.ts @@ -1,8 +1,12 @@ import type { NotificationMessage } from "@rakazo/adapter-kit"; import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; -import { PushNotificationProvider, type PushSender } from "./push-notifications.js"; -import type { PushOutcome, PushPayload } from "./web-push.js"; +import { + PushNotificationProvider, + type PushOutcome, + type PushPayload, + type PushSender, +} from "./push-notifications.js"; const context = { operationId: "notify", diff --git a/packages/adapters/src/push-notifications.ts b/packages/adapters/src/push-notifications.ts index 74f5956..820a132 100644 --- a/packages/adapters/src/push-notifications.ts +++ b/packages/adapters/src/push-notifications.ts @@ -11,11 +11,24 @@ import { markPushSubscriptionUsed, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; -import type { PushOutcome, PushPayload, PushTarget } from "./web-push.js"; /** Lock screens show a line or two; the rest lives in the conversation. */ const PUSH_BODY_MAX_LENGTH = 240; +/** What a device shows: the notification plus where a tap leads (a path in the web app). */ +export interface PushPayload extends NotificationMessage { + url: string; +} + +/** "gone": the push service no longer knows this device, so its registration can be dropped. */ +export type PushOutcome = "sent" | "gone"; + +export interface PushTarget { + endpoint: string; + p256dh: string | null; + auth: string | null; +} + export interface PushSender { send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise; } diff --git a/packages/adapters/src/web-push.test.ts b/packages/adapters/src/web-push.test.ts index 5199553..df381de 100644 --- a/packages/adapters/src/web-push.test.ts +++ b/packages/adapters/src/web-push.test.ts @@ -2,8 +2,9 @@ import { createECDH, randomBytes } from "node:crypto"; import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import webPush from "web-push"; +import type { PushPayload } from "./push-notifications.js"; import { EncryptedSecretStore } from "./secrets.js"; -import { type PushPayload, VapidKeys, vapidEnvFromProcess, WebPushSender } from "./web-push.js"; +import { VapidKeys, vapidEnvFromProcess, WebPushSender } from "./web-push.js"; const browserKey = createECDH("prime256v1"); browserKey.generateKeys(); diff --git a/packages/adapters/src/web-push.ts b/packages/adapters/src/web-push.ts index 3abb071..e851d9e 100644 --- a/packages/adapters/src/web-push.ts +++ b/packages/adapters/src/web-push.ts @@ -1,11 +1,11 @@ import { lookup } from "node:dns/promises"; -import type { NotificationMessage } from "@rakazo/adapter-kit"; import type { PrismaClient } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { Agent } from "undici"; import webPush from "web-push"; import { combineSignals } from "./connector-safety.js"; import { createAddressCheckedLookup, type ResolveHostname } from "./network-address.js"; +import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js"; import type { EncryptedSecretStore } from "./secrets.js"; import { dispatcherFetch } from "./undici-fetch.js"; import { assertPublicAddresses, assertSafeWebUrl } from "./web-ssrf.js"; @@ -137,20 +137,6 @@ export class VapidKeys { } } -/** What a device shows: the notification plus where a tap leads (a path in the web app). */ -export interface PushPayload extends NotificationMessage { - url: string; -} - -/** "gone": the push service no longer knows this device, so its registration can be dropped. */ -export type PushOutcome = "sent" | "gone"; - -export interface PushTarget { - endpoint: string; - p256dh: string | null; - auth: string | null; -} - export interface WebPushSenderOptions { fetch?: typeof globalThis.fetch; resolveHostname?: ResolveHostname; From 4191a4b6e637d51c308a4a317fb03db33379beeb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:58:29 +0000 Subject: [PATCH 18/38] fix(core): load child_process normally in the git checkout reader readGitCheckout loaded node:child_process through require(), which does not exist in the ESM API runtime. Linking a project space to a folder that is a git checkout therefore failed after the space was created. The lazy load is no longer needed: the reader is only reachable through the server-only "@rakazo/core/git-checkout" subpath, never through the package entry the web app bundles. Import spawn at the top instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/core/src/git-checkout.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/core/src/git-checkout.ts b/packages/core/src/git-checkout.ts index ccf1ff7..899d2ac 100644 --- a/packages/core/src/git-checkout.ts +++ b/packages/core/src/git-checkout.ts @@ -1,19 +1,10 @@ +// Server only: reached through the "@rakazo/core/git-checkout" subpath, never the browser entry. +import { spawn } from "node:child_process"; import { access } from "node:fs/promises"; import path from "node:path"; import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js"; import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js"; -// Lazy import to avoid bundling `node:child_process` in browser bundles. -// `defaultGitRunner` is server-only; lazy-loading ensures tree-shaking works. -type SpawnFn = typeof import("node:child_process").spawn; -let _spawn: SpawnFn | undefined; -const getSpawn = (): SpawnFn => { - if (_spawn === undefined) { - _spawn = require("node:child_process").spawn as SpawnFn; - } - return _spawn; -}; - export interface GitCheckoutStatus { present: boolean; remoteUrl: string | null; @@ -32,7 +23,6 @@ export type GitRunner = (args: string[], cwd: string) => Promise { return new Promise((resolve) => { - const spawn = getSpawn(); const child = spawn("git", args, { cwd, env: process.env }); let output = ""; child.stdout.on("data", (chunk: Buffer | string) => { From 2c91a80f691f5098cf48057900daca1b0b27baf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:58:39 +0000 Subject: [PATCH 19/38] feat(onboarding): ask before discovering projects and let the user choose The onboarding scan started on its own, discarded what it found, claimed to read email and calendars, simulated progress for everyone who could not scan, and its project creation always failed (wrong Space fields and a space lookup by title across tenants). Discovery now asks first and creates nothing without a choice: - The scan step appears only for the deployment owner when ONBOARDING_SCAN_HOME is set; everyone else skips it. The simulated scan is gone. - It shows the folder and asks. Only directory listings are read: git projects up to four levels deep, their languages and a few tools (Docker, GitHub Actions, Terraform). Symlinks are never followed and each chosen folder is resolved again before use, so nothing outside the folder is reached. Results stay with the account that scanned. - Found projects are a checklist (none preselected); found languages and tools are facts (preselected). "Add" creates one project space per chosen project with createSpaceForMember and wireProjectWorkspace in the user's own organization, reusing the user's own space already linked to that folder, and saves the chosen facts as onboarding facts under the profile lock and fact limit. - When a connection becomes connected (direct connect, authorization round trip, or catalog reconciliation), "Uses " is remembered as a tools fact from the connector. Revoking keeps it. The E2E harness now points ONBOARDING_SCAN_HOME at a fixture folder and starts without a deployment owner, so owner-only onboarding steps appear only in specs that take the seat through /__e2e/deployment-owner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .env.example | 6 +- apps/api/src/connected-app-facts.ts | 50 + apps/api/src/onboarding-scan.test.ts | 208 ++- apps/api/src/onboarding-scan.ts | 1134 ++++++----------- apps/api/src/router.ts | 49 +- apps/web/e2e/onboarding-discovery.spec.ts | 76 ++ .../onboarding/OnboardingScanProgress.tsx | 186 --- .../onboarding/OnboardingScanStep.tsx | 250 ++++ apps/web/src/pages/Onboarding.tsx | 18 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/onboarding-scan.ts | 49 + packages/contracts/src/rpc.ts | 22 +- packages/testkit/src/authorization.test.ts | 25 +- packages/testkit/src/cli/harness.ts | 59 +- .../src/onboarding-discovery.postgres.test.ts | 363 ++++++ .../testkit/src/onboarding-scan-fixture.ts | 35 + 16 files changed, 1494 insertions(+), 1037 deletions(-) create mode 100644 apps/api/src/connected-app-facts.ts create mode 100644 apps/web/e2e/onboarding-discovery.spec.ts delete mode 100644 apps/web/src/components/onboarding/OnboardingScanProgress.tsx create mode 100644 apps/web/src/components/onboarding/OnboardingScanStep.tsx create mode 100644 packages/contracts/src/onboarding-scan.ts create mode 100644 packages/testkit/src/onboarding-discovery.postgres.test.ts create mode 100644 packages/testkit/src/onboarding-scan-fixture.ts diff --git a/.env.example b/.env.example index 5d588c8..8ebb743 100644 --- a/.env.example +++ b/.env.example @@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY= PUBLIC_POSTHOG_HOST=https://us.i.posthog.com SIGNUPS_ENABLED=true SIGNUP_ALLOWLIST= -# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem. -# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the -# deployment owner's own onboarding discover local projects. Other accounts never scan the host. +# First-run discovery. Leave unset and onboarding never looks at the host filesystem. +# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the +# git projects in it, reading file and folder names only. Other accounts never see the step. # ONBOARDING_SCAN_HOME= ENCRYPTION_KEY=replace-with-64-random-hex-characters DATA_DIR=./data diff --git a/apps/api/src/connected-app-facts.ts b/apps/api/src/connected-app-facts.ts new file mode 100644 index 0000000..5b83abe --- /dev/null +++ b/apps/api/src/connected-app-facts.ts @@ -0,0 +1,50 @@ +import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core"; +import type { PrismaClient } from "@rakazo/db"; +import { updateUserProfile } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; + +/** + * The app's own name: a featured app's label, otherwise the label of the user's first account for + * it (later accounts may be named "Gmail 2" or renamed). + */ +async function connectedAppName( + prisma: PrismaClient, + connection: { userId: string; connectorId: string; provider: string }, +): Promise { + const featured = matchFeaturedConnectorId(connection.provider); + if (featured) return FEATURED_CONNECTOR_LABELS[featured]; + const first = await prisma.connection.findFirst({ + where: { + userId: connection.userId, + connectorId: connection.connectorId, + provider: connection.provider, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { displayName: true }, + }); + const name = first?.displayName.trim().slice(0, 80); + return name || null; +} + +/** + * Remembers "Uses " in the user's profile when one of their connections becomes connected. + * Revoking the connection keeps the fact; the user removes it in About you. Best effort: the + * connection itself has already succeeded, so a full profile or a database error only logs. + */ +export async function recordConnectedApp( + prisma: PrismaClient, + connection: { userId: string; connectorId: string; provider: string }, +): Promise { + try { + const name = await connectedAppName(prisma, connection); + if (!name) return; + await updateUserProfile(prisma, connection.userId, { + addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }], + }); + } catch (error) { + getLogger().error("connected app fact was not recorded", error, { + connectorId: connection.connectorId, + provider: connection.provider, + }); + } +} diff --git a/apps/api/src/onboarding-scan.test.ts b/apps/api/src/onboarding-scan.test.ts index ea2e625..9cbafd9 100644 --- a/apps/api/src/onboarding-scan.test.ts +++ b/apps/api/src/onboarding-scan.test.ts @@ -1,64 +1,192 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; +import path from "node:path"; +import type { PrismaClient } from "@rakazo/db"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { + applyOnboardingScan, + discoverOnboardingScan, + onboardingScanFolder, + onboardingScanStatus, + startOnboardingScan, +} from "./onboarding-scan.js"; -const owner = { userId: "user-owner", isDeploymentOwner: true }; -const member = { userId: "user-member", isDeploymentOwner: false }; +const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true }; +const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false }; +const roots: string[] = []; -async function scanHomeWithOneFile(): Promise { - const home = await mkdtemp(join(tmpdir(), "evoo-scan-")); - await mkdir(join(home, "Projects")); - await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n"); - return home; +/** Fails the test if the scan writes anything before its checks pass. */ +const untouchedPrisma = new Proxy( + {}, + { + get() { + throw new Error("the database must not be touched"); + }, + }, +) as PrismaClient; + +async function tempDir(): Promise { + const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-"))); + roots.push(dir); + return dir; +} + +async function files(root: string, entries: string[]): Promise { + for (const entry of entries) { + const file = path.join(root, entry); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, "x\n"); + } +} + +async function project(root: string, relative: string, entries: string[]): Promise { + await mkdir(path.join(root, relative, ".git"), { recursive: true }); + await files(path.join(root, relative), entries); } async function completed(scanId: string, userId: string) { return await vi.waitFor( - async () => { - const status = await getScanStatus(scanId, userId); - if (status.status !== "complete" && status.status !== "failed") throw new Error("pending"); + () => { + const status = onboardingScanStatus(scanId, userId); + if (status.status === "scanning") throw new Error("pending"); return status; }, - { timeout: 10_000, interval: 50 }, + { timeout: 10_000, interval: 20 }, ); } +afterAll(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +}); + describe("onboarding scan", () => { - afterEach(() => { - vi.unstubAllEnvs(); + it("lists git projects with their languages and tools and never follows a symlink", async () => { + const home = await tempDir(); + const outside = await tempDir(); + // A home folder tracked by git is not itself a project. + await mkdir(path.join(home, ".git")); + await project(home, "code/atlas", [ + "src/a.ts", + "src/b.ts", + "src/c.ts", + "src/d.tsx", + "scripts/build.js", + "Dockerfile", + ".github/workflows/ci.yml", + "infra/main.tf", + "src/odd.constructor", + "node_modules/dep/index.js", + "node_modules/dep/more.js", + ]); + await project(home, "code/garden", ["main.py"]); + await project(home, "deep/a/b/edge", ["tool.py"]); + await project(home, "deep/a/b/c/too-deep", ["main.go"]); + await project(home, ".hidden/secret", ["main.go"]); + await project(home, "notes/node_modules/vendored", ["main.go"]); + await files(home, ["notes/todo.md"]); + await project(outside, "escaped", ["main.rs"]); + await symlink(path.join(outside, "escaped"), path.join(home, "linked")); + await symlink(outside, path.join(home, "code/atlas/src/outside")); + + const findings = await discoverOnboardingScan(home); + + expect(findings.projects).toEqual([ + { + id: "project-0", + name: "atlas", + path: "code/atlas", + languages: ["TypeScript", "JavaScript"], + tools: ["Docker", "GitHub Actions", "Terraform"], + }, + { id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] }, + { id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] }, + ]); + expect(findings.facts).toEqual([ + { id: "language:Python", label: "Python", category: "work", value: "Codes in Python" }, + { + id: "language:JavaScript", + label: "JavaScript", + category: "work", + value: "Codes in JavaScript", + }, + { + id: "language:TypeScript", + label: "TypeScript", + category: "work", + value: "Codes in TypeScript", + }, + { id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" }, + { + id: "tool:GitHub Actions", + label: "GitHub Actions", + category: "tools", + value: "Uses GitHub Actions", + }, + { id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" }, + ]); }); - it("hides a scan from every account except the one that started it", async () => { - const { scanId } = await startOnboardingScan({ actor: member }, {}); - await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/); - await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({ - status: expect.any(String), - }); - await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/); + it("offers a scan folder only to the deployment owner and only when it exists", async () => { + const home = await tempDir(); + await files(home, ["file.txt"]); + await expect(onboardingScanFolder(member, home)).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, home)).resolves.toBe(home); + await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" }); }); - it("never reads the host without a configured scan home", async () => { - const { scanId } = await startOnboardingScan({ actor: owner }, {}); + it("keeps a scan with the account that started it and returns only relative paths", async () => { + const home = await tempDir(); + await project(home, "code/atlas", ["index.ts"]); + const { scanId } = await startOnboardingScan(owner, home); + const status = await completed(scanId, owner.userId); - expect(status.status).toBe("complete"); - expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } }); + expect(status).toEqual({ + status: "complete", + projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }], + facts: [{ id: "language:TypeScript", label: "TypeScript" }], + }); + expect(JSON.stringify(status)).not.toContain(home); + expect(() => onboardingScanStatus(scanId, member.userId)).toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + await expect( + applyOnboardingScan(untouchedPrisma, member, home, { + scanId, + projectIds: ["project-0"], + factIds: [], + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); - it("lets only the deployment owner discover the configured scan home", async () => { - // The shared test setup pins the emulated runtime, which also keeps the scan simulated. - vi.stubEnv("AGENT_RUNTIME", ""); - vi.stubEnv("SANDBOX_PROVIDER", ""); - const scanHome = await scanHomeWithOneFile(); + it("checks ownership, the folder and every chosen project again before writing", async () => { + const home = await tempDir(); + const outside = await tempDir(); + await project(home, "code/atlas", ["index.ts"]); + await project(home, "code/garden", ["main.py"]); + await project(outside, "elsewhere", ["main.rs"]); + const { scanId } = await startOnboardingScan(owner, home); + await completed(scanId, owner.userId); + const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] }; - const asMember = await startOnboardingScan({ actor: member, scanHome }, {}); - const memberStatus = await completed(asMember.scanId, member.userId); - expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } }); + await expect( + applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + applyOnboardingScan(untouchedPrisma, owner, outside, choice), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); - const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {}); - const ownerStatus = await completed(asOwner.scanId, owner.userId); - expect(ownerStatus.status).toBe("complete"); - expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } }); + // The chosen project became a symlink out of the scan folder after the scan. + await rm(path.join(home, "code/garden"), { recursive: true }); + await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden")); + await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "That folder does not exist or is not readable.", + }); }); }); diff --git a/apps/api/src/onboarding-scan.ts b/apps/api/src/onboarding-scan.ts index 8d1c786..504cc84 100644 --- a/apps/api/src/onboarding-scan.ts +++ b/apps/api/src/onboarding-scan.ts @@ -1,815 +1,435 @@ -// Onboarding scan — real filesystem scan, Git analysis, and profile compilation. -// Runs synchronously on the host machine (no sandbox needed). -import { spawn } from "node:child_process"; -import { access, readdir, stat } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import type { Actor } from "@rakazo/contracts"; +// Onboarding discovery. With the deployment owner's consent, it lists the code projects (git +// checkouts) in the configured scan folder and the languages and tools they use. It reads +// directory listings only, never file contents, and never follows a symlink, so nothing outside +// the folder is reached. Results stay with the account that started the scan, and nothing is +// created until the owner picks projects and facts from them. +import { randomUUID } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { ORPCError } from "@orpc/server"; +import type { + Actor, + OnboardingScanApplyInput, + OnboardingScanApplyResult, + OnboardingScanStatus, + UserFactCategory, +} from "@rakazo/contracts"; +import { ONBOARDING_SCAN_MAX_FACTS, ONBOARDING_SCAN_MAX_PROJECTS } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + createSpaceForMember, + InvalidProjectWorkspacePathError, + InvalidSpaceNameError, + InvalidUserFactError, + SpaceLimitError, + updateUserProfile, + wireProjectWorkspace, +} from "@rakazo/db"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- +/** Project roots are looked for at most this deep below the scan folder. */ +const PROJECT_DEPTH = 4; +/** Bounds the walk of a large home folder. */ +const MAX_DIRECTORIES = 5_000; +/** Bounds the language count inside one project. */ +const MAX_FILES_PER_PROJECT = 5_000; +const MAX_FILE_DEPTH = 8; +const MAX_LANGUAGES_PER_PROJECT = 3; +const SCAN_TTL_MS = 60 * 60_000; -interface ScanResult { - localRepos: RepoInfo[]; - projectFolders: ProjectFolder[]; - fileSummary: FileSummary; - profileSummary: ProfileSummary; - agentBriefing: string; - createdProjects: CreatedProject[]; - subAgentRoles: SubAgentRole[]; -} +const SKIPPED_DIRECTORIES = new Set([ + "node_modules", + "dist", + "build", + "out", + "target", + "vendor", + "venv", + "__pycache__", + "Library", +]); -interface RepoInfo { - path: string; +const LANGUAGES = new Map( + Object.entries({ + ts: "TypeScript", + tsx: "TypeScript", + mts: "TypeScript", + cts: "TypeScript", + js: "JavaScript", + jsx: "JavaScript", + mjs: "JavaScript", + cjs: "JavaScript", + py: "Python", + go: "Go", + rs: "Rust", + java: "Java", + kt: "Kotlin", + kts: "Kotlin", + swift: "Swift", + rb: "Ruby", + php: "PHP", + c: "C", + h: "C", + cc: "C++", + cpp: "C++", + cxx: "C++", + hpp: "C++", + cs: "C#", + dart: "Dart", + ex: "Elixir", + exs: "Elixir", + scala: "Scala", + lua: "Lua", + }), +); + +const COMPOSE_FILES = new Set([ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +]); + +interface FoundProject { + id: string; name: string; - remote: string | null; - branch: string; - lastCommit: string | null; - languages: Record; - sizeBytes: number; -} - -interface ProjectFolder { + /** Relative to the scan folder. */ path: string; - name: string; - type: "git" | "workspace" | "documents" | "media" | "music" | "code"; - fileCount: number; - modifiedAt: string; -} - -interface FileSummary { - totalFiles: number; - totalSizeBytes: number; - byType: Record; - byExtension: Record; -} - -interface ProfileSummary { - interests: string[]; - skills: string[]; - workStyle: string; - communication: string; languages: string[]; - techStack: string[]; + tools: string[]; } -interface CreatedProject { +interface FoundFact { id: string; - name: string; - spaceId: string; - description: string; + label: string; + category: UserFactCategory; + /** Stored as data for the user's bots, so it stays a plain English statement. */ + value: string; } -interface ScanRecord { +export interface OnboardingScanFindings { + projects: FoundProject[]; + facts: FoundFact[]; +} + +interface ScanRecord extends OnboardingScanFindings { id: string; - botId: string; ownerUserId: string; - status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed"; - progress: number; - message: string; - result: string; - startedAt: Date; - completedAt: Date | null; + /** Real path of the scanned folder. */ + home: string; + status: "scanning" | "complete" | "failed"; + finishedAt: number | null; + applying: boolean; } -// --------------------------------------------------------------------------- -// Registry (in-memory, 1h TTL) -// --------------------------------------------------------------------------- +/** Per API process; a scan only lives as long as the onboarding that started it. */ +const scans = new Map(); -const scanRegistry = new Map(); - -const emptyScanResult = (): ScanResult => ({ - localRepos: [], - projectFolders: [], - fileSummary: { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }, - profileSummary: { - interests: [], - skills: [], - workStyle: "structured", - communication: "direct", - languages: [], - techStack: [], - }, - agentBriefing: "Profile ready", - createdProjects: [], - subAgentRoles: [], -}); - -async function runSimulatedScan(record: ScanRecord) { - const phases: Array<{ status: ScanRecord["status"]; progress: number; message: string }> = [ - { status: "scanning", progress: 25, message: "Scanning local files and repositories..." }, - { status: "connecting", progress: 40, message: "Connecting to your services..." }, - { status: "building", progress: 60, message: "Building your profile..." }, - { status: "briefing", progress: 80, message: "Briefing your agent team..." }, - ]; - for (const phase of phases) { - record.status = phase.status; - record.progress = phase.progress; - record.message = phase.message; - await new Promise((resolve) => setTimeout(resolve, 400)); - } - record.status = "complete"; - record.progress = 100; - record.message = "Scan complete — profile ready"; - record.result = JSON.stringify(emptyScanResult()); - record.completedAt = new Date(); +/** Code-point order, so results do not depend on the server's locale. */ +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; } -export interface OnboardingScanRequest { - actor: Pick; - botId?: string; - /** Home-style directory to discover. Unset keeps the scan off the host filesystem. */ - scanHome?: string; +/** Most frequent first, then by name. */ +function byCount([leftName, left]: [string, number], [rightName, right]: [string, number]): number { + return right - left || compareText(leftName, rightName); } -export async function startOnboardingScan( - { actor, botId, scanHome }: OnboardingScanRequest, - prisma: any, -): Promise<{ scanId: string }> { - const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const record: ScanRecord = { - id: scanId, - botId: botId ?? "", - ownerUserId: actor.userId, - status: "scanning", - progress: 0, - message: "Scanning your computer...", - result: "[]", - startedAt: new Date(), - completedAt: null, - }; - scanRegistry.set(scanId, record); - - // Run async - void (async () => { - try { - // The host is the deployment owner's machine: only their onboarding may discover it, and - // only inside the directory the deployment explicitly configured. - if ( - !scanHome || - !actor.isDeploymentOwner || - process.env.AGENT_RUNTIME === "scripted" || - process.env.SANDBOX_PROVIDER === "fake" - ) { - await runSimulatedScan(record); - return; - } - const result = await executeFullScan(scanHome, botId ?? "", prisma); - record.status = "complete"; - record.progress = 100; - record.message = "Scan complete — profile ready"; - record.result = JSON.stringify(result); - record.completedAt = new Date(); - } catch (error) { - record.status = "failed"; - record.message = error instanceof Error ? error.message : "Scan failed"; - record.completedAt = new Date(); - } - })(); - - return { scanId }; -} - -export async function getScanStatus( - scanId: string, - userId: string, -): Promise<{ - status: ScanRecord["status"]; - progress: number; - message: string; - result?: unknown; -}> { - const record = scanRegistry.get(scanId); - if (!record || record.ownerUserId !== userId) throw new Error("Scan not found"); - const result = record.result ? JSON.parse(record.result) : undefined; - return { status: record.status, progress: record.progress, message: record.message, result }; -} - -// --------------------------------------------------------------------------- -// Full scan pipeline -// --------------------------------------------------------------------------- - -async function executeFullScan(homeDir: string, _botId: string, prisma: any): Promise { - // Phase 1: Local file scan - const [fileSummary, projectFolders] = await scanFiles(homeDir); - - // Phase 2: Git repository discovery - const localRepos = await discoverGitRepos(homeDir); - - // Phase 3: External service analysis (GitHub, etc.) - // Phase 4: Profile compilation from findings - const profileSummary = compileProfile(localRepos, fileSummary, projectFolders); - - // Phase 6: Project folder creation - const createdProjects = await createProjectFolders(localRepos, prisma); - - // Phase 7: Sub-agent role creation - const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos); - - // Phase 5: Agent briefing (includes sub-agent roles) - const agentBriefing = generateBriefing(localRepos, profileSummary, fileSummary, subAgentRoles); - - return { - localRepos, - projectFolders, - fileSummary, - profileSummary, - agentBriefing, - createdProjects, - subAgentRoles, - }; -} - -// --------------------------------------------------------------------------- -// Phase 1: File scan -// --------------------------------------------------------------------------- - -async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[]]> { - const summary: FileSummary = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; - const folders: ProjectFolder[] = []; - - // Scan common directories - const scanDirs = [ - "Projects", - "Documents", - "Pictures", - "Music", - "Downloads", - "Desktop", - "src", - "code", - "work", - "data", - "repos", - "dev", - "workspace", - ]; - - for (const subDir of scanDirs) { - const dirPath = join(homeDir, subDir); - try { - await access(dirPath); - const stats = await scanDirectory(dirPath, homeDir, subDir); - summary.totalFiles += stats.totalFiles; - summary.totalSizeBytes += stats.totalSizeBytes; - Object.assign(summary.byType, stats.byType); - Object.assign(summary.byExtension, stats.byExtension); - - if (stats.totalFiles > 0) { - folders.push({ - path: dirPath, - name: subDir, - type: classifyFolderType(subDir), - fileCount: stats.totalFiles, - modifiedAt: new Date().toISOString(), - }); - } - } catch { - /* directory doesn't exist, skip */ - } - } - - return [summary, folders]; -} - -interface DirStats { - totalFiles: number; - totalSizeBytes: number; - byType: Record; - byExtension: Record; -} - -async function scanDirectory( - dirPath: string, - _homeDir: string, - _relativePath: string, -): Promise { - const stats: DirStats = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; - +async function listDirectory(directory: string): Promise { try { - const entries = await readdir(dirPath, { withFileTypes: true }); + return (await readdir(directory, { withFileTypes: true })).sort((left, right) => + compareText(left.name, right.name), + ); + } catch { + return []; + } +} +/** isDirectory() is false for a symlink, so links (and anything they point to) are never walked. */ +function walkable(entry: Dirent): boolean { + return entry.isDirectory() && !entry.name.startsWith(".") && !SKIPPED_DIRECTORIES.has(entry.name); +} + +async function findProjectRoots(home: string): Promise { + const roots: string[] = []; + let visited = 0; + let level = [home]; + // Breadth first, so shallow projects win when a limit is reached. The scan folder itself is + // never a project: a home folder tracked by git still holds the projects below it. + for (let depth = 0; depth <= PROJECT_DEPTH && level.length > 0; depth += 1) { + const next: string[] = []; + for (const directory of level) { + if (roots.length >= ONBOARDING_SCAN_MAX_PROJECTS || visited >= MAX_DIRECTORIES) return roots; + visited += 1; + const entries = await listDirectory(directory); + if (depth > 0 && entries.some((entry) => entry.name === ".git")) { + roots.push(directory); + continue; + } + for (const entry of entries) { + if (walkable(entry)) next.push(path.join(directory, entry.name)); + } + } + level = next; + } + return roots; +} + +async function analyzeProject(root: string): Promise<{ languages: string[]; tools: string[] }> { + const counts = new Map(); + const tools = new Set(); + let files = 0; + const rootEntries = await listDirectory(root); + if (rootEntries.some((entry) => entry.name === ".github" && entry.isDirectory())) { + const github = await listDirectory(path.join(root, ".github")); + if (github.some((entry) => entry.name === "workflows" && entry.isDirectory())) { + tools.add("GitHub Actions"); + } + } + const visit = async (directory: string, depth: number, entries: Dirent[]): Promise => { for (const entry of entries) { - const fullPath = join(dirPath, entry.name); - - if (entry.isDirectory()) { - // Skip node_modules, .git, etc. - if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".venv") - continue; - const subStats = await scanDirectory(fullPath, _homeDir, _relativePath); - stats.totalFiles += subStats.totalFiles; - stats.totalSizeBytes += subStats.totalSizeBytes; - Object.assign(stats.byType, subStats.byType); - Object.assign(stats.byExtension, subStats.byExtension); - } else if (entry.isFile()) { - try { - const fileStat = await stat(fullPath); - stats.totalFiles++; - stats.totalSizeBytes += fileStat.size; - - // Classify by type - const type = classifyFileType(entry.name); - stats.byType[type] = (stats.byType[type] || 0) + 1; - - // Classify by extension - const ext = entry.name.split(".").pop()?.toLowerCase() || "none"; - stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1; - } catch { - /* permission denied */ + if (files >= MAX_FILES_PER_PROJECT) return; + if (entry.isFile()) { + files += 1; + const name = entry.name.toLowerCase(); + const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + const language = LANGUAGES.get(extension); + if (language) counts.set(language, (counts.get(language) ?? 0) + 1); + if (name === "dockerfile" || name.startsWith("dockerfile.") || COMPOSE_FILES.has(name)) { + tools.add("Docker"); } + if (extension === "tf") tools.add("Terraform"); + } else if (depth < MAX_FILE_DEPTH && walkable(entry)) { + const child = path.join(directory, entry.name); + await visit(child, depth + 1, await listDirectory(child)); } } - } catch { - /* permission denied */ - } - - return stats; + }; + await visit(root, 0, rootEntries); + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + const languages = [...counts.entries()] + .filter(([, count]) => count >= total * 0.1) + .sort(byCount) + .slice(0, MAX_LANGUAGES_PER_PROJECT) + .map(([language]) => language); + return { languages, tools: [...tools].sort() }; } -function classifyFileType(filename: string): string { - const ext = filename.split(".").pop()?.toLowerCase() || ""; - const codeExts = [ - "ts", - "tsx", - "js", - "jsx", - "py", - "go", - "rs", - "java", - "c", - "cpp", - "h", - "rb", - "php", - "swift", - "kt", - ]; - const docExts = ["md", "txt", "doc", "docx", "pdf", "rst", "tex"]; - const imageExts = ["png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "bmp"]; - const musicExts = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"]; - - if (codeExts.includes(ext)) return "code"; - if (docExts.includes(ext)) return "documents"; - if (imageExts.includes(ext)) return "images"; - if (musicExts.includes(ext)) return "music"; - if (["json", "yaml", "yml", "toml", "cfg", "ini", "env", "sh", "bash", "zsh"].includes(ext)) - return "config"; - return "other"; +/** Labels used by the most projects first, then by name. */ +function rankLabels(lists: string[][]): string[] { + const counts = new Map(); + for (const list of lists) + for (const label of list) counts.set(label, (counts.get(label) ?? 0) + 1); + return [...counts.entries()].sort(byCount).map(([label]) => label); } -function classifyFolderType(name: string): ProjectFolder["type"] { - const lower = name.toLowerCase(); - if (lower.includes("music")) return "music"; - if (lower.includes("picture") || lower.includes("image") || lower.includes("photo")) - return "media"; - if (lower.includes("document") || lower.includes("doc")) return "documents"; - if ( - lower.includes("project") || - lower.includes("code") || - lower.includes("src") || - lower.includes("dev") || - lower.includes("workspace") || - lower.includes("repo") - ) - return "code"; - return "workspace"; +/** Lists the projects below a real (symlink-free) folder path, and what they use. */ +export async function discoverOnboardingScan(home: string): Promise { + const roots = await findProjectRoots(home); + const analyzed = await Promise.all( + roots.map(async (root) => ({ root, ...(await analyzeProject(root)) })), + ); + const projects = analyzed + .map(({ root, languages, tools }) => ({ + name: path.basename(root), + path: path.relative(home, root), + languages, + tools, + })) + .sort((left, right) => compareText(left.path, right.path)) + .map((project, index) => ({ id: `project-${index}`, ...project })); + const facts: FoundFact[] = [ + ...rankLabels(projects.map((project) => project.languages)).map((label) => ({ + id: `language:${label}`, + label, + category: "work" as const, + value: `Codes in ${label}`, + })), + ...rankLabels(projects.map((project) => project.tools)).map((label) => ({ + id: `tool:${label}`, + label, + category: "tools" as const, + value: `Uses ${label}`, + })), + ].slice(0, ONBOARDING_SCAN_MAX_FACTS); + return { projects, facts }; } -// --------------------------------------------------------------------------- -// Phase 2: Git repository discovery -// --------------------------------------------------------------------------- - -async function discoverGitRepos(homeDir: string): Promise { - const repos: RepoInfo[] = []; - +/** + * The scan reads the server's filesystem, so only the deployment owner may scan, and only the + * folder the deployment configured. Null hides the step. + */ +export async function onboardingScanFolder( + actor: Pick, + scanHome: string | undefined, +): Promise { + if (!actor.isDeploymentOwner || !scanHome) return null; try { - const gitDirs = await findGitDirectories(homeDir); - - for (const repoDir of gitDirs) { - try { - const repo = await analyzeGitRepo(repoDir, homeDir); - if (repo) repos.push(repo); - } catch { - /* skip broken repos */ - } - } - } catch { - /* git not available */ - } - - return repos; -} - -async function findGitDirectories(dir: string, depth: number = 0): Promise { - if (depth > 4) return []; // Limit search depth - const results: string[] = []; - - try { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === ".git") { - results.push(dirname(fullPath)); - } else if ( - entry.name !== "node_modules" && - entry.name !== ".git" && - entry.name !== ".venv" && - entry.name !== "dist" - ) { - results.push(...(await findGitDirectories(fullPath, depth + 1))); - } - } - } - } catch { - /* permission denied */ - } - - return results; -} - -async function analyzeGitRepo(repoDir: string, _homeDir: string): Promise { - try { - const name = repoDir.split("/").pop() || ""; - - // Get remote URL - const remoteResult = await spawnPromise("git", ["remote", "get-url", "origin"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const remote = remoteResult.stdout.trim() || null; - - // Get current branch - const branchResult = await spawnPromise("git", ["branch", "--show-current"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const branch = branchResult.stdout.trim() || "unknown"; - - // Get last commit - const commitResult = await spawnPromise("git", ["log", "-1", "--format=%H"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const lastCommit = commitResult.stdout.trim() || null; - - // Get language stats via git ls-files + wc - const languages = await getRepoLanguages(repoDir); - - // Get directory size - const sizeResult = await spawnPromise("du", ["-sb", repoDir]).catch(() => ({ stdout: "0" })); - const sizeBytes = parseInt(sizeResult.stdout.split("\t")[0] || "0", 10); - - return { path: repoDir, name, remote, branch, lastCommit, languages, sizeBytes }; + const home = await realpath(scanHome); + return (await stat(home)).isDirectory() ? home : null; } catch { return null; } } -async function getRepoLanguages(repoDir: string): Promise> { - const langs: Record = {}; - try { - const { stdout } = await spawnPromise("git", ["ls-files"], { cwd: repoDir }).catch(() => ({ - stdout: "", - })); - const files = stdout.trim().split("\n").filter(Boolean); - for (const file of files) { - const ext = file.split(".").pop()?.toLowerCase() || ""; - const langMap: Record = { - ts: "TypeScript", - tsx: "TypeScript", - js: "JavaScript", - jsx: "JavaScript", - py: "Python", - go: "Go", - rs: "Rust", - java: "Java", - c: "C", - cpp: "C++", - rb: "Ruby", - php: "PHP", - swift: "Swift", - kt: "Kotlin", - }; - const lang = langMap[ext] || ext || "unknown"; - langs[lang] = (langs[lang] || 0) + 1; - } - } catch { - /* no languages */ +function forgetExpiredScans(now: number): void { + for (const [id, scan] of scans) { + if (scan.finishedAt !== null && now - scan.finishedAt > SCAN_TTL_MS) scans.delete(id); } - return langs; } -// --------------------------------------------------------------------------- -// Phase 3: Profile compilation -// --------------------------------------------------------------------------- - -function compileProfile( - repos: RepoInfo[], - fileSummary: FileSummary, - _folders: ProjectFolder[], -): ProfileSummary { - const interests: string[] = []; - const skills: string[] = []; - const techStack: string[] = []; - const languageSet = new Set(); - - // Extract tech stack from repos - for (const repo of repos) { - for (const lang of Object.keys(repo.languages)) { - techStack.push(lang); - languageSet.add(lang); - } - } - - // Extract interests from file types - for (const [type, count] of Object.entries(fileSummary.byType)) { - if (count > 5) { - interests.push(`${type} (${count} files)`); - } - } - - // Derive skills from languages - const langToSkill: Record = { - TypeScript: "TypeScript/JavaScript", - Python: "Python", - Go: "Go", - Rust: "Rust", - Java: "Java", - C: "C/C++", - "C++": "C/C++", - Ruby: "Ruby", - Swift: "Swift/Kotlin", - PHP: "PHP", +export async function startOnboardingScan( + actor: Pick, + scanHome: string | undefined, +): Promise<{ scanId: string }> { + const home = await onboardingScanFolder(actor, scanHome); + if (!home) throw new ORPCError("FORBIDDEN"); + forgetExpiredScans(Date.now()); + const running = [...scans.values()].find( + (scan) => scan.ownerUserId === actor.userId && scan.status === "scanning", + ); + if (running) return { scanId: running.id }; + const scan: ScanRecord = { + id: randomUUID(), + ownerUserId: actor.userId, + home, + status: "scanning", + projects: [], + facts: [], + finishedAt: null, + applying: false, }; - for (const lang of techStack) { - const skill = langToSkill[lang]; - if (skill && !skills.includes(skill)) skills.push(skill); - } + scans.set(scan.id, scan); + void discoverOnboardingScan(home) + .then((findings) => { + scan.projects = findings.projects; + scan.facts = findings.facts; + scan.status = "complete"; + }) + .catch(() => { + scan.status = "failed"; + }) + .finally(() => { + scan.finishedAt = Date.now(); + }); + return { scanId: scan.id }; +} - // Deduplicate - const uniqueInterests = [...new Set(interests)].slice(0, 10); - const uniqueSkills = [...new Set(skills)].slice(0, 15); - const uniqueTech = [...new Set(techStack)].sort(); +/** Another account's scan reads as missing. */ +function ownedScan(scanId: string, userId: string): ScanRecord { + const scan = scans.get(scanId); + if (!scan || scan.ownerUserId !== userId) throw new ORPCError("NOT_FOUND"); + return scan; +} +export function onboardingScanStatus(scanId: string, userId: string): OnboardingScanStatus { + const scan = ownedScan(scanId, userId); + if (scan.status !== "complete") return { status: scan.status }; return { - interests: uniqueInterests, - skills: uniqueSkills, - workStyle: - uniqueTech.length > 3 - ? "multi-language" - : uniqueTech[0] - ? `${uniqueTech[0]}-focused` - : "general", - communication: "structured", - languages: Array.from(languageSet), - techStack: uniqueTech, + status: "complete", + projects: scan.projects.map(({ id, name, path }) => ({ id, name, path })), + facts: scan.facts.map(({ id, label }) => ({ id, label })), }; } -// --------------------------------------------------------------------------- -// Phase 4: Agent briefing -// --------------------------------------------------------------------------- +const MISSING_FOLDER = "That folder does not exist or is not readable."; -function generateBriefing( - repos: RepoInfo[], - profile: ProfileSummary, - fileSummary: FileSummary, - subAgentRoles: SubAgentRole[], -): string { - const lines: string[] = []; - lines.push(`# User Profile Briefing`); - lines.push(``); - lines.push(`## Technical Profile`); - lines.push(`- **Tech Stack:** ${profile.techStack.join(", ") || "N/A"}`); - lines.push(`- **Languages:** ${profile.languages.join(", ") || "N/A"}`); - lines.push(`- **Work Style:** ${profile.workStyle}`); - lines.push(``); - lines.push(`## Local Repositories (${repos.length} found)`); - for (const repo of repos) { - lines.push( - `- **${repo.name}** — ${repo.branch}, ${Object.values(repo.languages).reduce((a, b) => a + b, 0)} files, ${repo.remote || "no remote"}`, - ); +/** Resolves a found project again, so a folder swapped for a symlink cannot leave the scan folder. */ +async function confinedProjectFolder(home: string, relative: string): Promise { + let folder: string; + try { + folder = await realpath(path.join(home, relative)); + } catch { + throw new InvalidProjectWorkspacePathError(MISSING_FOLDER); } - lines.push(``); - lines.push(`## File System Summary`); - lines.push(`- **Total Files:** ${fileSummary.totalFiles.toLocaleString()}`); - lines.push(`- **Total Size:** ${(fileSummary.totalSizeBytes / (1024 * 1024)).toFixed(0)} MB`); - lines.push( - `- **Categories:** ${Object.entries(fileSummary.byType) - .map(([t, c]) => `${t}(${c})`) - .join(", ")}`, - ); - lines.push(``); - lines.push(`## User Interests`); - lines.push(`- ${profile.interests.slice(0, 5).join("\n- ")}`); - lines.push(``); - lines.push(`## User Skills`); - lines.push(`- ${profile.skills.slice(0, 5).join("\n- ")}`); - lines.push(``); - lines.push(`## Assigned Specialist Roles`); - for (const role of subAgentRoles) { - lines.push(`### ${role.name}`); - lines.push(`- **Role:** ${role.role}`); - lines.push(`- **Description:** ${role.description}`); - lines.push(`- **Instructions:** ${role.instructions}`); - lines.push(`- **Skills:** ${role.skills.join(", ")}`); - lines.push(""); + const inside = path.relative(home, folder); + if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) { + throw new InvalidProjectWorkspacePathError(MISSING_FOLDER); } - lines.push(`---`); - lines.push( - `This profile should be used to tailor all future interactions, project recommendations, and agent assignments.`, - ); - return lines.join("\n"); + return folder; } -// --------------------------------------------------------------------------- -// Phase 5: Project folder creation -// --------------------------------------------------------------------------- +async function projectSpace( + prisma: PrismaClient, + actor: Pick, + name: string, + folder: string, +): Promise<{ id: string; name: string }> { + // Applying twice, or onboarding again, reuses the user's own space linked to this folder. + const existing = await prisma.space.findFirst({ + where: { + workspacePath: folder, + deletingAt: null, + memberships: { some: { userId: actor.userId } }, + }, + select: { id: true, name: true }, + orderBy: { createdAt: "asc" }, + }); + if (existing) return existing; + const space = await createSpaceForMember(prisma, { + currentSpaceId: actor.spaceId, + userId: actor.userId, + name: name.slice(0, 60), + }); + await wireProjectWorkspace(prisma, { spaceId: space.id, workspacePath: folder }); + return space; +} -async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise { - const created: CreatedProject[] = []; +function toRpcError(error: unknown): unknown { + if ( + error instanceof SpaceLimitError || + error instanceof InvalidSpaceNameError || + error instanceof InvalidProjectWorkspacePathError || + error instanceof InvalidUserFactError + ) { + return new ORPCError("BAD_REQUEST", { message: error.message }); + } + return error; +} - for (const repo of repos) { - try { - // Check if space already exists - const existing = await prisma.space.findFirst({ where: { title: repo.name } }); - if (existing) { - created.push({ - id: existing.id, - name: repo.name, - spaceId: existing.id, - description: repo.remote || repo.path, - }); - continue; - } - // Create new space - const space = await prisma.space.create({ - data: { title: repo.name, path: repo.path }, - }); - created.push({ - id: space.id, - name: repo.name, - spaceId: space.id, - description: repo.remote || repo.path, - }); - } catch { - // Space might already exist or DB unavailable — skip +/** Creates a project space per chosen project and saves the chosen facts to the profile. */ +export async function applyOnboardingScan( + prisma: PrismaClient, + actor: Pick, + scanHome: string | undefined, + input: OnboardingScanApplyInput, +): Promise { + const scan = ownedScan(input.scanId, actor.userId); + if (scan.status !== "complete" || scan.applying) throw new ORPCError("CONFLICT"); + scan.applying = true; + try { + // Ownership or the configured folder can change after the scan; check again before writing. + if ((await onboardingScanFolder(actor, scanHome)) !== scan.home) { + throw new ORPCError("FORBIDDEN"); } + const projectIds = new Set(input.projectIds); + const factIds = new Set(input.factIds); + const projects = scan.projects.filter((project) => projectIds.has(project.id)); + const facts = scan.facts.filter((fact) => factIds.has(fact.id)); + // Every chosen folder is checked before anything is written. + const chosen = await Promise.all( + projects.map(async (project) => ({ + name: project.name, + folder: await confinedProjectFolder(scan.home, project.path), + })), + ); + const added = + facts.length > 0 + ? await updateUserProfile(prisma, actor.userId, { + addFacts: facts.map(({ category, value }) => ({ + category, + value, + source: "onboarding" as const, + })), + }) + : []; + const spaces: Array<{ id: string; name: string }> = []; + for (const { name, folder } of chosen) + spaces.push(await projectSpace(prisma, actor, name, folder)); + return { spaces, factsAdded: added.filter((fact) => fact.created).length }; + } catch (error) { + throw toRpcError(error); + } finally { + scan.applying = false; } - - return created; } - -// --------------------------------------------------------------------------- -// Phase 6: Sub-agent role creation -// --------------------------------------------------------------------------- - -interface SubAgentRole { - name: string; - role: string; - description: string; - instructions: string; - skills: string[]; -} - -function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubAgentRole[] { - const roles: SubAgentRole[] = []; - const hasCode = profile.languages?.length > 0; - const hasDesign = profile.interests?.some( - (i) => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"), - ); - const hasData = profile.interests?.some( - (i) => - i.toLowerCase().includes("data") || - i.toLowerCase().includes("analytics") || - i.toLowerCase().includes("database"), - ); - const hasDevOps = profile.interests?.some( - (i) => - i.toLowerCase().includes("infra") || - i.toLowerCase().includes("ops") || - i.toLowerCase().includes("deploy"), - ); - const hasMusic = profile.interests?.some((i) => i.toLowerCase().includes("music")); - const hasWriting = profile.interests?.some( - (i) => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog"), - ); - - if (hasCode) { - const primaryLang = profile.languages?.[0] || "TypeScript"; - roles.push({ - name: "Code Engineer", - role: "software-engineer", - description: `Specializes in ${primaryLang} development and code review`, - instructions: `You are the Code Engineer specialist. Your expertise is in ${profile.languages?.join(", ") || "software development"}. Review code, write tests, debug issues, and implement features. You have access to the user's local repositories and can run commands to build and test code.`, - skills: profile.skills.filter( - (s) => s.toLowerCase().includes("code") || s.toLowerCase().includes("program"), - ), - }); - } - - if (hasDesign) { - roles.push({ - name: "UI/UX Designer", - role: "ui-designer", - description: "Creates polished interfaces and design systems", - instructions: - "You are the UI/UX Designer specialist. Focus on creating beautiful, accessible interfaces. Review designs, suggest improvements, and ensure consistency across the user's projects.", - skills: ["design", "UI/UX"], - }); - } - - if (hasData) { - roles.push({ - name: "Data Analyst", - role: "data-analyst", - description: "Analyzes data, creates reports, and extracts insights", - instructions: - "You are the Data Analyst specialist. Process data from the user's projects, create visualizations, generate reports, and provide actionable insights.", - skills: ["data analysis", "statistics"], - }); - } - - if (hasDevOps) { - roles.push({ - name: "DevOps Engineer", - role: "devops-engineer", - description: "Manages infrastructure, deployments, and CI/CD", - instructions: - "You are the DevOps Engineer specialist. Handle deployments, manage infrastructure, configure CI/CD pipelines, and ensure system reliability.", - skills: ["DevOps", "infrastructure"], - }); - } - - if (hasMusic) { - roles.push({ - name: "Music Producer", - role: "music-producer", - description: "Assists with music production and audio engineering", - instructions: - "You are the Music Producer specialist. Help with music composition, audio engineering, mixing, and mastering. You understand the user's music projects and can provide expert advice.", - skills: ["music production", "audio engineering"], - }); - } - - if (hasWriting) { - roles.push({ - name: "Content Writer", - role: "content-writer", - description: "Creates and edits written content", - instructions: - "You are the Content Writer specialist. Write blog posts, documentation, articles, and other content for the user's projects and personal use.", - skills: ["writing", "content creation"], - }); - } - - // Always include a general assistant - roles.unshift({ - name: "General Assistant", - role: "general-assistant", - description: "Handles general tasks and coordinates other specialists", - instructions: `You are the General Assistant and team coordinator. Your role is to handle general queries, coordinate with other specialists (${ - roles - .slice(1) - .map((r) => r.name) - .join(", ") || "none yet" - }), and manage the user's daily workflow. You have access to all tools and can delegate tasks to specialists when needed.`, - skills: ["task management", "coordination"], - }); - - return roles; -} - -// --------------------------------------------------------------------------- -// Utility -// --------------------------------------------------------------------------- - -function spawnPromise( - cmd: string, - args: string[], - options?: { cwd?: string }, -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { ...options, timeout: 15000 }); - let stdout = ""; - let stderr = ""; - child.stdout?.on("data", (d: Buffer) => { - stdout += d.toString(); - }); - child.stderr?.on("data", (d: Buffer) => { - stderr += d.toString(); - }); - child.on("error", () => reject(new Error("spawn failed"))); - child.on("close", (code) => { - if (code === 0 || code === null) resolve({ stdout, stderr }); - else reject(new Error(`exit ${code}`)); - }); - }); -} - -// Keep old registry cleanup -setInterval(() => { - const cutoff = Date.now() - 3600000; - for (const [id, r] of scanRegistry) { - if (r.completedAt && r.completedAt.getTime() < cutoff) scanRegistry.delete(id); - } -}, 60000); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..2af5c9a 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -157,6 +157,7 @@ import { resolveBusyBotName, toComputerStatus, } from "./computer-status.js"; +import { recordConnectedApp } from "./connected-app-facts.js"; import { listInbox } from "./inbox.js"; import { searchIntegrationCatalog } from "./integration-catalog.js"; import { @@ -180,7 +181,12 @@ import { promptFocus, startOnboarding, } from "./onboarding.js"; -import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; +import { + applyOnboardingScan, + onboardingScanFolder, + onboardingScanStatus, + startOnboardingScan, +} from "./onboarding-scan.js"; import { listSpaceRuns } from "./runs.js"; import { addScreenProxyCapability } from "./screen-proxy.js"; import { querySpaceSearch } from "./search.js"; @@ -285,6 +291,11 @@ async function reconcilePendingConnections( : []), ]; if (updates.length > 0) await prisma.$transaction(updates); + const connected = new Set(sync.connectIds); + for (const row of rows) { + if (row.status !== "pending" || !connected.has(row.id)) continue; + await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider }); + } } /** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */ @@ -3434,16 +3445,18 @@ export function createRouter(deps: RouterDeps) { ); return { ok: true as const }; }), - scan: authed.onboarding.scan.handler(async ({ context, input }) => { - const { scanId } = await startOnboardingScan( - { actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome }, - deps.prisma, - ); - return { scanId }; - }), - scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => { - return await getScanStatus(input.scanId, context.actor.userId); - }), + scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({ + folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome), + })), + scan: authed.onboarding.scan.handler(({ context }) => + startOnboardingScan(context.actor, deps.env.onboardingScanHome), + ), + scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => + onboardingScanStatus(input.scanId, context.actor.userId), + ), + applyScan: authed.onboarding.applyScan.handler(({ context, input }) => + applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input), + ), }, integrationSetup: { get: authed.integrationSetup.get.handler(async ({ context }) => { @@ -3692,6 +3705,13 @@ export function createRouter(deps: RouterDeps) { } throw new IsolationError(); } + if (!auth.authorizationUrl) { + await recordConnectedApp(deps.prisma, { + userId: context.actor.userId, + connectorId: input.connectorId, + provider: input.provider, + }); + } return { connectionId: row.id, authorizationUrl: auth.authorizationUrl }; } catch (error) { if (error instanceof IsolationError) throw error; @@ -3997,6 +4017,13 @@ export function createRouter(deps: RouterDeps) { }, { timeout: 60_000 }, ); + if (row.status === "connected") { + await recordConnectedApp(deps.prisma, { + userId: context.actor.userId, + connectorId: row.connectorId, + provider: row.provider, + }); + } } return { id: row.id, diff --git a/apps/web/e2e/onboarding-discovery.spec.ts b/apps/web/e2e/onboarding-discovery.spec.ts new file mode 100644 index 0000000..0f639bc --- /dev/null +++ b/apps/web/e2e/onboarding-discovery.spec.ts @@ -0,0 +1,76 @@ +/** + * E2E: the deployment owner's onboarding asks before scanning the fixture home folder the + * harness configures, then adds only the projects and facts the user picks. + */ +import { expect, type Page, test } from "@playwright/test"; +import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts"; +import { captureScreenshot, rpc, signup } from "./helpers"; + +const api = process.env.API_URL ?? "http://127.0.0.1:3110"; + +/** Advances whichever wizard step is showing until the scan step is reached. */ +async function walkToScanStep(page: Page) { + const surface = page.locator('[data-rakazo-surface="onboarding"]'); + await expect(async () => { + if ((await surface.getAttribute("data-onboarding-step")) === "scan") return; + const skip = page.getByRole("button", { name: /^Skip/ }).first(); + const next = (await skip.isVisible()) + ? skip + : page.getByRole("button", { name: "Continue", exact: true }); + await next.click({ timeout: 2_000 }); + await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 }); + }).toPass({ timeout: 60_000 }); +} + +test("asks before looking for projects and adds only what the user picks", async ({ + page, +}, testInfo) => { + const email = `discovery-${Date.now()}@rakazo.test`; + await signup(page, email, "password12", "Discovery"); + await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); + // The harness starts without a deployment owner; this spec takes the seat for its run. + const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } }); + expect(owner.ok()).toBe(true); + const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null }; + try { + await page.goto("/onboarding", { waitUntil: "domcontentloaded" }); + await walkToScanStep(page); + + await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible(); + await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible(); + await captureScreenshot(page, testInfo, "onboarding-discovery-consent"); + await page.getByRole("button", { name: "Look", exact: true }).click(); + + const projects = page.getByRole("group", { name: "Add as projects" }); + await expect(projects.getByRole("checkbox")).toHaveCount(2); + const atlas = projects.getByRole("checkbox", { name: /atlas/ }); + await expect(atlas).not.toBeChecked(); + await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked(); + await atlas.check(); + const facts = page.getByRole("group", { name: "Add to About you" }); + await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked(); + await facts.getByRole("checkbox", { name: "Python" }).uncheck(); + await captureScreenshot(page, testInfo, "onboarding-discovery-results"); + await page.getByRole("button", { name: "Add", exact: true }).click(); + await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); + + const navigation = await rpc(page, "spaces/list", {}); + const names = navigation.spaces.map((space) => space.name); + expect(names).toContain("atlas"); + expect(names).not.toContain("garden"); + expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch( + /code\/atlas$/, + ); + const values = (await rpc(page, "profile/get", {})).facts.map( + (fact) => fact.value, + ); + expect(values).toEqual( + expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]), + ); + expect(values).not.toContain("Codes in Python"); + } finally { + await page.request.post(`${api}/__e2e/deployment-owner`, { + data: { userId: previousOwnerUserId }, + }); + } +}); diff --git a/apps/web/src/components/onboarding/OnboardingScanProgress.tsx b/apps/web/src/components/onboarding/OnboardingScanProgress.tsx deleted file mode 100644 index 16c41b4..0000000 --- a/apps/web/src/components/onboarding/OnboardingScanProgress.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useEffect, useState } from "react"; -import { rpc } from "../../lib/rpc"; - -interface ScanStatus { - status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed"; - progress: number; - message: string; - result?: unknown; -} - -const PHASES = [ - { - key: "scanning" as const, - label: "Scanning your computer...", - sublabel: "Files, folders, git repositories", - }, - { - key: "connecting" as const, - label: "Connecting to your services...", - sublabel: "GitHub, email, calendar", - }, - { - key: "building" as const, - label: "Building your profile...", - sublabel: "Interests, skills, preferences", - }, - { - key: "briefing" as const, - label: "Setting up your agent team...", - sublabel: "Project folders, role assignments", - }, -] as const; - -export function OnboardingScanProgress({ - onComplete, - botId, -}: { - onComplete: () => void; - botId?: string; -}) { - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const [, setScanId] = useState(null); - - useEffect(() => { - let cancelled = false; - let pollingInterval: ReturnType | null = null; - - const run = async () => { - try { - const scanResult = await rpc.onboarding.scan(botId ? { botId } : {}); - if (cancelled) return; - setScanId(scanResult.scanId); - - // Poll for status - const poll = async () => { - try { - const s = await rpc.onboarding.scanStatus({ scanId: scanResult.scanId }); - if (cancelled) return; - setStatus(s); - if (s.status === "complete") { - onComplete(); - } else if (s.status === "failed") { - setError(s.message); - } - } catch { - // poll error — keep trying - } - }; - - // First poll immediately, then every 2s - await poll(); - pollingInterval = setInterval(poll, 2000); - } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : "Scan failed"); - } - }; - - run(); - return () => { - cancelled = true; - if (pollingInterval) clearInterval(pollingInterval); - }; - }, [botId, onComplete]); - - if (error) { - return ( -
    -

    {error}

    - -
    - ); - } - - if (!status) { - return ( -
    -
    -

    Preparing your profile...

    -
    - ); - } - - const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status); - const isComplete = status.status === "complete"; - - return ( -
    -

    - {isComplete ? "All set!" : "Getting to know you..."} -

    -

    - {isComplete - ? "Your profile and agent team are ready." - : "We're scanning your computer and services to personalize your experience."} -

    - -
    - {PHASES.map((phase, idx) => { - const isActive = idx === currentPhaseIndex && !isComplete; - const isDone = idx < currentPhaseIndex || isComplete; - return ( -
    -
    - {isDone ? ( - - ✓ - - ) : isActive ? ( - - ● - - ) : ( - - ○ - - )} -
    -
    -

    - {phase.label} -

    -

    {phase.sublabel}

    -
    - {isActive && ( -
    -
    -
    -
    -
    - )} -
    - ); - })} -
    - - {isComplete && ( -
    - -
    - )} -
    - ); -} diff --git a/apps/web/src/components/onboarding/OnboardingScanStep.tsx b/apps/web/src/components/onboarding/OnboardingScanStep.tsx new file mode 100644 index 0000000..1b700c8 --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingScanStep.tsx @@ -0,0 +1,250 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { OnboardingScanFact, OnboardingScanProject } from "@rakazo/contracts"; +import { Button, Checkbox } from "@rakazo/ui-web"; +import { useEffect, useId, useRef, useState } from "react"; +import { rpc } from "../../lib/rpc"; +import { LoadingState } from "../ai/primitives"; + +const POLL_MS = 1_000; + +interface Found { + projects: OnboardingScanProject[]; + facts: OnboardingScanFact[]; +} + +function toggled(ids: string[], id: string, on: boolean): string[] { + return on ? [...ids, id] : ids.filter((entry) => entry !== id); +} + +/** + * Asks before looking at the server's scan folder, then lets the user choose which found projects + * become project spaces and which languages and tools go to About you. Nothing is created without + * a choice. Skips itself when this account cannot scan. + */ +export function OnboardingScanStep({ onDone }: { onDone: () => void }) { + const { t } = useLingui(); + const ids = useId(); + const done = useRef(onDone); + done.current = onDone; + const [folder, setFolder] = useState(null); + const [looking, setLooking] = useState(false); + const [scanId, setScanId] = useState(null); + const [found, setFound] = useState(null); + const [failed, setFailed] = useState(false); + const [projectIds, setProjectIds] = useState([]); + const [factIds, setFactIds] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + rpc.onboarding + .scanTarget() + .then((target) => { + if (cancelled) return; + if (target.folder) setFolder(target.folder); + else done.current(); + }) + .catch(() => { + if (!cancelled) done.current(); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!scanId) return; + let cancelled = false; + let timer: ReturnType | undefined; + const poll = () => { + rpc.onboarding + .scanStatus({ scanId }) + .then((status) => { + if (cancelled) return; + if (status.status === "scanning") { + timer = setTimeout(poll, POLL_MS); + } else if (status.status === "failed") { + setFailed(true); + } else { + setFound({ projects: status.projects, facts: status.facts }); + setFactIds(status.facts.map((fact) => fact.id)); + } + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + }; + poll(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [scanId]); + + async function look() { + setLooking(true); + try { + setScanId((await rpc.onboarding.scan()).scanId); + } catch { + setFailed(true); + } + } + + async function add() { + if (!scanId) return; + setSaving(true); + setError(null); + try { + await rpc.onboarding.applyScan({ scanId, projectIds, factIds }); + done.current(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + setSaving(false); + } + } + + const continueButton = ( +
    + +
    + ); + + if (failed) { + return ( +
    +

    + Could not look in this folder. +

    + {continueButton} +
    + ); + } + + if (found && found.projects.length === 0) { + return ( +
    +

    + No projects found +

    + {continueButton} +
    + ); + } + + if (found) { + return ( +
    +
    + +

    + Add as projects +

    +
    +
    + {found.projects.map((project) => ( + + ))} +
    +
    + + {found.facts.length > 0 ? ( +
    + + Add to About you + +
    + {found.facts.map((fact) => ( + + ))} +
    +
    + ) : null} + + {error ?

    {error}

    : null} + +
    + + +
    +
    + ); + } + + if (looking) { + return ( +
    + +
    + ); + } + + if (!folder) return null; + + return ( +
    +

    + Look for projects here? +

    +

    + {folder} +

    +

    + Only file and folder names are read. +

    +
    + + +
    +
    + ); +} diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 0423eaa..75b9533 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -9,7 +9,7 @@ import { type OnboardingEmojiConfig, OnboardingOoDesigner, } from "../components/onboarding/OnboardingOoDesigner"; -import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress"; +import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep"; import { KOKORO_LOCAL_TOKEN, OnboardingVoiceStep, @@ -115,7 +115,6 @@ export function OnboardingPage() { const [profilePersisted, setProfilePersisted] = useState(false); const [crewReady, setCrewReady] = useState(false); const [_helpVibe, setHelpVibe] = useState(null); - const [scanBotId, setScanBotId] = useState(null); const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); const [prefs, setPrefs] = useState(defaultPreferences()); @@ -141,14 +140,6 @@ export function OnboardingPage() { if (cancelled) return; setBootstrapMe(me); setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); - // Get bot ID for scan phase - try { - const botList = await rpc.bots.list(); - const mainBot = botList.find((b) => b.kind === "main"); - if (mainBot && !cancelled) setScanBotId(mainBot.id); - } catch { - /* best effort */ - } setIntegrationSetup(integrations); // The first step renders before this resolves; never pull a user back who moved on. if (!me.needsModel && integrations?.needsSetup) { @@ -513,12 +504,7 @@ export function OnboardingPage() {
    ) : null} - {step === "scan" ? ( - setStep("bot")} - botId={scanBotId ?? undefined} - /> - ) : null} + {step === "scan" ? setStep("bot")} /> : null} {step === "bot" && error ? (
    diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3865e1b..af399a3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,6 +10,7 @@ export * from "./integration-settings.js"; export * from "./local-settings.js"; export * from "./mcp.js"; export * from "./model-tiers.js"; +export * from "./onboarding-scan.js"; export * from "./openai-compatible-ui.js"; export * from "./project-goals.js"; export * from "./project-sessions.js"; diff --git a/packages/contracts/src/onboarding-scan.ts b/packages/contracts/src/onboarding-scan.ts new file mode 100644 index 0000000..97faa35 --- /dev/null +++ b/packages/contracts/src/onboarding-scan.ts @@ -0,0 +1,49 @@ +import * as z from "zod"; +import { Id } from "./ids.js"; + +/** Upper bounds on one scan's choices; the server lists at most this many projects and facts. */ +export const ONBOARDING_SCAN_MAX_PROJECTS = 30; +export const ONBOARDING_SCAN_MAX_FACTS = 20; + +/** The folder a consented scan would read, or null when this account cannot scan. */ +export const OnboardingScanTargetSchema = z.object({ folder: z.string().nullable() }); +export type OnboardingScanTarget = z.infer; + +export const OnboardingScanProjectSchema = z.object({ + id: z.string(), + name: z.string(), + /** Relative to the scanned folder. */ + path: z.string(), +}); +export type OnboardingScanProject = z.infer; + +export const OnboardingScanFactSchema = z.object({ + id: z.string(), + /** A language or tool name, e.g. "TypeScript". */ + label: z.string(), +}); +export type OnboardingScanFact = z.infer; + +export const OnboardingScanStatusSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("scanning") }), + z.object({ status: z.literal("failed") }), + z.object({ + status: z.literal("complete"), + projects: z.array(OnboardingScanProjectSchema), + facts: z.array(OnboardingScanFactSchema), + }), +]); +export type OnboardingScanStatus = z.infer; + +export const OnboardingScanApplyInputSchema = z.object({ + scanId: z.string(), + projectIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_PROJECTS), + factIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_FACTS), +}); +export type OnboardingScanApplyInput = z.infer; + +export const OnboardingScanApplyResultSchema = z.object({ + spaces: z.array(z.object({ id: Id, name: z.string() })), + factsAdded: z.number().int().nonnegative(), +}); +export type OnboardingScanApplyResult = z.infer; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..48506ba 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -83,6 +83,12 @@ import { IntegrationSetupStateSchema, } from "./integration-settings.js"; import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js"; +import { + OnboardingScanApplyInputSchema, + OnboardingScanApplyResultSchema, + OnboardingScanStatusSchema, + OnboardingScanTargetSchema, +} from "./onboarding-scan.js"; import { CreateProjectGoalInput, ProjectGoalSchema, @@ -676,15 +682,13 @@ export const appContract = { z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }), ) .output(z.object({ ok: z.literal(true) })), - scan: oc.input(z.object({ botId: Id.optional() })).output(z.object({ scanId: z.string() })), - scanStatus: oc.input(z.object({ scanId: z.string() })).output( - z.object({ - status: z.enum(["scanning", "connecting", "building", "briefing", "complete", "failed"]), - progress: z.number(), - message: z.string(), - result: z.any().optional(), - }), - ), + /** The folder a scan would read; null when this account cannot scan, so the step is skipped. */ + scanTarget: oc.output(OnboardingScanTargetSchema), + /** Start scanning that folder. Only called after the user agreed. */ + scan: oc.output(z.object({ scanId: z.string() })), + scanStatus: oc.input(z.object({ scanId: z.string() })).output(OnboardingScanStatusSchema), + /** Create a project space per chosen project and save the chosen facts. */ + applyScan: oc.input(OnboardingScanApplyInputSchema).output(OnboardingScanApplyResultSchema), }, integrationSetup: { get: oc.output(IntegrationSetupStateSchema), diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ce1e785..21ffadd 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -556,19 +556,18 @@ describeWithDatabase("API authorization and resource isolation", () => { ).toMatchObject({ content: "owner-only-memory" }); // The user profile is per user: another user's fact ids and spaces are out of reach. - const ownerProfile = await rpc<{ facts: Array<{ id: string; status: string }> }>( - app, - owner, - "profile/update", - { - addFacts: [ - { category: "work", value: "Owner works nights" }, - { category: "health", value: "Owner health note", source: "onboarding" }, - ], - }, - ); - const ownerFactIds = ownerProfile.facts.map((fact) => fact.id); - expect(ownerProfile.facts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]); + const ownerProfile = await rpc<{ + facts: Array<{ id: string; status: string; source: string }>; + }>(app, owner, "profile/update", { + addFacts: [ + { category: "work", value: "Owner works nights" }, + { category: "health", value: "Owner health note", source: "onboarding" }, + ], + }); + // Connecting an app above already remembered it; this check is about the facts added here. + const ownerFacts = ownerProfile.facts.filter((fact) => fact.source !== "connector"); + const ownerFactIds = ownerFacts.map((fact) => fact.id); + expect(ownerFacts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]); const profileCalls = [ ["profile/update", { confirmFactIds: ownerFactIds }], ["profile/update", { editFacts: [{ id: ownerFactIds[0], value: "Stolen" }] }], diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..5305a76 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -1,9 +1,11 @@ import { execSync } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { loadRootEnv } from "@rakazo/core/node/load-root-env"; import { PostgreSqlContainer } from "@testcontainers/postgresql"; import type { createApp } from "../../../../apps/api/src/app.ts"; +import { writeOnboardingScanFixture } from "../onboarding-scan-fixture.js"; import { runProcess } from "./process.js"; loadRootEnv(); @@ -106,6 +108,7 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/onboarding-discovery.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", @@ -180,6 +183,10 @@ async function main() { { fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname }, ); const email = new EmailEmulator(); + // A fixture home folder, so the deployment owner's onboarding offers the real project scan. + const scanHome = await mkdtemp(path.join(tmpdir(), "rakazo-e2e-scan-home-")); + await writeOnboardingScanFixture(scanHome); + process.env.ONBOARDING_SCAN_HOME = scanHome; const handles = await createApp({ databaseUrl, prisma: undefined, @@ -192,13 +199,30 @@ async function main() { }, integrationsCatalogUrl: "https://catalog.example.test/", }); + // No spec user owns the deployment unless it asks (POST /__e2e/deployment-owner), so + // owner-only onboarding steps appear only in the specs that test them, whatever runs first. + await handles.prisma.deploymentSettings.upsert({ + where: { id: "default" }, + create: { + id: "default", + ownerUserId: E2E_NO_OWNER, + signupsEnabled: true, + signupAllowlist: "", + signupPolicyInitialized: true, + }, + update: { ownerUserId: E2E_NO_OWNER }, + }); let activeRequests = 0; const requestWaiters = new Set<() => void>(); const server = serve({ fetch: async (request) => { - if (new URL(request.url).pathname === "/__e2e/emails") { + const { pathname } = new URL(request.url); + if (pathname === "/__e2e/emails") { return Response.json(email.sent, { headers: { "cache-control": "no-store" } }); } + if (pathname === "/__e2e/deployment-owner" && request.method === "POST") { + return Response.json(await setDeploymentOwner(handles, await request.json())); + } activeRequests += 1; try { return await handles.app.fetch(request); @@ -266,6 +290,7 @@ async function main() { await new Promise((resolve) => requestWaiters.add(resolve)); } await handles.stop().catch(() => undefined); + await rm(scanHome, { recursive: true, force: true }).catch(() => undefined); for (let index = 0; index < computers.length; index += 4) { const results = await Promise.allSettled( computers.slice(index, index + 4).map((computer) => @@ -304,6 +329,9 @@ async function main() { type AppHandles = Awaited>; +/** Matches no user, so the first signup does not claim the deployment. */ +const E2E_NO_OWNER = "e2e-no-owner"; + async function managedComputers(handles: AppHandles) { if (!["e2b", "daytona", "box"].includes(sandboxProvider)) return []; return handles.prisma.computer.findMany({ @@ -312,6 +340,33 @@ async function managedComputers(handles: AppHandles) { }); } +/** + * Lets a spec act as the deployment owner (by email) for owner-only steps, then restore the + * previous owner (by id). Returns the owner it replaced. + */ +async function setDeploymentOwner( + handles: AppHandles, + input: { email?: string; userId?: string | null }, +): Promise<{ previousOwnerUserId: string | null }> { + const previous = await handles.prisma.deploymentSettings.findUnique({ + where: { id: "default" }, + select: { ownerUserId: true }, + }); + const ownerUserId = input.email + ? ( + await handles.prisma.user.findUniqueOrThrow({ + where: { email: input.email }, + select: { id: true }, + }) + ).id + : (input.userId ?? E2E_NO_OWNER); + await handles.prisma.deploymentSettings.update({ + where: { id: "default" }, + data: { ownerUserId }, + }); + return { previousOwnerUserId: previous?.ownerUserId ?? null }; +} + async function writeSummary(reportDir: string, summary: Record) { await writeFile( path.join(reportDir, "summary.json"), diff --git a/packages/testkit/src/onboarding-discovery.postgres.test.ts b/packages/testkit/src/onboarding-discovery.postgres.test.ts new file mode 100644 index 0000000..671131b --- /dev/null +++ b/packages/testkit/src/onboarding-discovery.postgres.test.ts @@ -0,0 +1,363 @@ +import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + ComposioEmulator, + PipedreamConnector, + ThirdPartyConnectorEmulator, +} from "@rakazo/adapters"; +import type { + OnboardingScanApplyResult, + OnboardingScanStatus, + SpaceNavigation, + UserProfileState, +} from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; +import { writeOnboardingScanFixture } from "./onboarding-scan-fixture.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; +type Actor = { userId: string; spaceId: string }; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; +const TEST_ENCRYPTION_KEY = "offline-discovery-test-encryption-key"; + +describeWithDatabase("onboarding discovery through the API", () => { + let handles: AppHandles; + let app: App; + let composio: ComposioEmulator; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-discovery-")); + const home = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-home-"))); + const outside = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-outside-"))); + + beforeAll(async () => { + await writeOnboardingScanFixture(home); + const { createApp } = await import("../../../apps/api/src/app.ts"); + composio = new ComposioEmulator(); + const thirdParties = new ThirdPartyConnectorEmulator(); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + encryptionKey: TEST_ENCRYPTION_KEY, + onboardingScanHome: home, + composio, + pipedream: new PipedreamConnector( + { + clientId: "fake-client-id", + clientSecret: "fake-client-secret", + projectId: "fake-project-id", + environment: "development", + identitySecret: TEST_ENCRYPTION_KEY, + }, + { fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname }, + ), + remoteConnectors: { + fetch: thirdParties.fetch, + resolveHostname: thirdParties.resolveHostname, + }, + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + for (const dir of [dataDir, home, outside]) rmSync(dir, { recursive: true, force: true }); + }); + + async function account(label: string): Promise<{ cookie: string; actor: Actor }> { + const cookie = await signup(app, `${label}-${stamp}@rakazo.test`, label); + return { cookie, actor: await rpc(app, cookie, "me") }; + } + + async function makeDeploymentOwner(actor: Actor): Promise { + await handles.prisma.deploymentSettings.update({ + where: { id: "default" }, + data: { ownerUserId: actor.userId }, + }); + } + + async function finishedScan(cookie: string) { + const { scanId } = await rpc<{ scanId: string }>(app, cookie, "onboarding/scan"); + const status = await vi.waitFor( + async () => { + const current = await rpc(app, cookie, "onboarding/scanStatus", { + scanId, + }); + if (current.status === "scanning") throw new Error("still scanning"); + return current; + }, + { timeout: 10_000, interval: 50 }, + ); + if (status.status !== "complete") throw new Error("scan failed"); + return { scanId, ...status }; + } + + it("offers the scan only to the deployment owner and keeps it with them", async () => { + const owner = await account("discovery-owner"); + const other = await account("discovery-other"); + await makeDeploymentOwner(owner.actor); + + await expect(rpc(app, other.cookie, "onboarding/scanTarget")).resolves.toEqual({ + folder: null, + }); + expect((await raw(app, other.cookie, "onboarding/scan")).status).toBe(403); + await expect(rpc(app, owner.cookie, "onboarding/scanTarget")).resolves.toEqual({ + folder: home, + }); + + const scan = await finishedScan(owner.cookie); + expect(scan.projects).toEqual([ + { id: "project-0", name: "atlas", path: "code/atlas" }, + { id: "project-1", name: "garden", path: "code/garden" }, + ]); + expect(scan.facts.map((fact) => fact.label)).toEqual([ + "Python", + "TypeScript", + "Docker", + "GitHub Actions", + ]); + expect( + (await raw(app, other.cookie, "onboarding/scanStatus", { scanId: scan.scanId })).status, + ).toBe(404); + expect( + ( + await raw(app, other.cookie, "onboarding/applyScan", { + scanId: scan.scanId, + projectIds: ["project-0"], + factIds: ["language:Python"], + }) + ).status, + ).toBe(404); + await expect(rpc(app, other.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); + + it("creates the chosen projects in the owner's own spaces and saves only the chosen facts", async () => { + const owner = await account("discovery-creator"); + const intruder = await account("discovery-intruder"); + await makeDeploymentOwner(owner.actor); + const atlasFolder = path.join(home, "code/atlas"); + // Another tenant already linked a space with the same name to the same folder. + const foreign = await rpc<{ id: string }>(app, intruder.cookie, "spaces/create", { + name: "atlas", + workspacePath: atlasFolder, + }); + + const scan = await finishedScan(owner.cookie); + const choice = { + scanId: scan.scanId, + projectIds: ["project-0"], + factIds: ["language:TypeScript", "tool:Docker"], + }; + const applied = await rpc( + app, + owner.cookie, + "onboarding/applyScan", + choice, + ); + + expect(applied.factsAdded).toBe(2); + expect(applied.spaces).toEqual([{ id: expect.any(String), name: "atlas" }]); + const created = applied.spaces[0]!.id; + expect(created).not.toBe(foreign.id); + const ownerOrg = await handles.prisma.space.findUniqueOrThrow({ + where: { id: owner.actor.spaceId }, + select: { organizationId: true }, + }); + await expect( + handles.prisma.space.findUniqueOrThrow({ + where: { id: created }, + select: { organizationId: true, workspacePath: true }, + }), + ).resolves.toEqual({ organizationId: ownerOrg.organizationId, workspacePath: atlasFolder }); + await expect( + handles.prisma.spaceMember.findMany({ + where: { spaceId: { in: [created, foreign.id] } }, + select: { spaceId: true, userId: true, role: true }, + orderBy: { spaceId: "asc" }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { spaceId: created, userId: owner.actor.userId, role: "owner" }, + { spaceId: foreign.id, userId: intruder.actor.userId, role: "owner" }, + ]), + ); + expect( + await handles.prisma.spaceMember.count({ where: { spaceId: { in: [created, foreign.id] } } }), + ).toBe(2); + await expect( + handles.prisma.memoryDocument.count({ + where: { spaceId: created, userId: owner.actor.userId, scope: "user" }, + }), + ).resolves.toBe(1); + + const ownerSpaces = await rpc(app, owner.cookie, "spaces/list"); + expect(ownerSpaces.spaces).toContainEqual( + expect.objectContaining({ + id: created, + name: "atlas", + workspace: expect.objectContaining({ path: atlasFolder }), + }), + ); + expect(ownerSpaces.spaces.map((space) => space.id)).not.toContain(foreign.id); + const intruderSpaces = await rpc(app, intruder.cookie, "spaces/list"); + expect(intruderSpaces.spaces.map((space) => space.id)).not.toContain(created); + + const profile = await rpc(app, owner.cookie, "profile/get"); + expect( + profile.facts.map(({ category, value, source }) => ({ category, value, source })), + ).toEqual( + expect.arrayContaining([ + { category: "work", value: "Codes in TypeScript", source: "onboarding" }, + { category: "tools", value: "Uses Docker", source: "onboarding" }, + ]), + ); + expect(profile.facts).toHaveLength(2); + await expect(rpc(app, intruder.cookie, "profile/get")).resolves.toMatchObject( + { facts: [] }, + ); + + // Applying again reuses the space and the facts. + await expect( + rpc(app, owner.cookie, "onboarding/applyScan", choice), + ).resolves.toEqual({ spaces: [{ id: created, name: "atlas" }], factsAdded: 0 }); + expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe( + 2, + ); + }); + + it("refuses a chosen project that left the scan folder and writes nothing", async () => { + const owner = await account("discovery-escape"); + await makeDeploymentOwner(owner.actor); + const scan = await finishedScan(owner.cookie); + await mkdir(path.join(outside, "elsewhere", ".git"), { recursive: true }); + await rm(path.join(home, "code/garden"), { recursive: true, force: true }); + symlinkSync(path.join(outside, "elsewhere"), path.join(home, "code/garden")); + + const response = await raw(app, owner.cookie, "onboarding/applyScan", { + scanId: scan.scanId, + projectIds: ["project-1"], + factIds: ["language:Python"], + }); + expect(response.status).toBe(400); + expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe( + 1, + ); + await expect(rpc(app, owner.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); + + it("remembers each connected app once as a tools fact and keeps it after revoking", async () => { + const user = await account("discovery-apps"); + const other = await account("discovery-apps-other"); + // A featured app is named by its label, whatever the account is called. + const gmail = await rpc<{ connectionId: string }>(app, user.cookie, "connections/begin", { + connectorId: "composio", + provider: "GMAIL", + displayName: "Personal", + }); + // Other apps take the first account's name; a second account adds nothing. + for (const displayName of ["GitHub", "GitHub 2"]) { + await rpc(app, user.cookie, "connections/begin", { + connectorId: "composio", + provider: "GITHUB", + displayName, + }); + } + // Connected through the authorization round trip. + const linear = await rpc<{ connectionId: string; authorizationUrl: string | null }>( + app, + user.cookie, + "connections/begin", + { connectorId: "pipedream", provider: "linear", displayName: "Linear" }, + ); + expect(linear.authorizationUrl).not.toBeNull(); + await expect( + rpc<{ status: string }>(app, user.cookie, "connections/complete", { + connectionId: linear.connectionId, + }), + ).resolves.toMatchObject({ status: "connected" }); + // Connected remotely, then noticed when the catalog reconciles a pending row. + await handles.prisma.connection.create({ + data: { + spaceId: user.actor.spaceId, + userId: user.actor.userId, + provider: "SLACK", + displayName: "SLACK", + status: "pending", + }, + }); + await composio.begin( + { provider: "SLACK", redirectUrl: "http://127.0.0.1.invalid/callback" }, + { + operationId: "discovery-test", + traceId: "discovery-test", + spaceId: user.actor.spaceId, + userId: user.actor.userId, + signal: new AbortController().signal, + }, + ); + await rpc(app, user.cookie, "connections/catalog", { connectorId: "composio" }); + await rpc(app, user.cookie, "connections/revoke", { connectionId: gmail.connectionId }); + + const profile = await rpc(app, user.cookie, "profile/get"); + expect( + profile.facts + .map(({ category, value, source }) => ({ category, value, source })) + .sort((left, right) => left.value.localeCompare(right.value)), + ).toEqual([ + { category: "tools", value: "Uses GitHub", source: "connector" }, + { category: "tools", value: "Uses Gmail", source: "connector" }, + { category: "tools", value: "Uses Linear", source: "connector" }, + { category: "tools", value: "Uses Slack", source: "connector" }, + ]); + await expect(rpc(app, other.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +} diff --git a/packages/testkit/src/onboarding-scan-fixture.ts b/packages/testkit/src/onboarding-scan-fixture.ts new file mode 100644 index 0000000..f79e884 --- /dev/null +++ b/packages/testkit/src/onboarding-scan-fixture.ts @@ -0,0 +1,35 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** An empty but valid git directory, so git commands stop here instead of searching upwards. */ +async function writeGitDirectory(project: string): Promise { + await mkdir(path.join(project, ".git", "objects"), { recursive: true }); + await mkdir(path.join(project, ".git", "refs"), { recursive: true }); + await writeFile(path.join(project, ".git", "HEAD"), "ref: refs/heads/main\n"); +} + +async function writeFiles(root: string, files: Record): Promise { + for (const [relative, content] of Object.entries(files)) { + const file = path.join(root, relative); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content); + } +} + +/** + * A home folder for the onboarding scan: two projects ("code/atlas" in TypeScript with Docker and + * GitHub Actions, "code/garden" in Python) and a notes folder that is not a project. + */ +export async function writeOnboardingScanFixture(home: string): Promise { + await writeFiles(home, { + "code/atlas/src/index.ts": "export const atlas = 1;\n", + "code/atlas/src/app.tsx": "export const App = () => null;\n", + "code/atlas/Dockerfile": "FROM scratch\n", + "code/atlas/.github/workflows/ci.yml": "on: push\n", + "code/garden/main.py": "print('garden')\n", + "code/garden/util.py": "VALUE = 1\n", + "notes/todo.md": "- water the plants\n", + }); + await writeGitDirectory(path.join(home, "code/atlas")); + await writeGitDirectory(path.join(home, "code/garden")); +} From 74eb27447e327801d9025921d434635b7c37c365 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:09:51 +0000 Subject: [PATCH 20/38] feat(web): coach on the Heute page Heute only listed runs and linked to a hard-coded local Mission Control address, so the coach's work had nowhere to show. - Today's brief at the top, habits with a check-off and their streak, and active goals, each only when there is something to show; then the existing needs-you, doing and done lists. - One icon button opens the check-in times (morning, evening, weekly), loaded when opened and saved on every change. - The page reads coach.today, refreshes it when a run finishes or the tab becomes visible, and checks habits off optimistically; the date header uses the user's time zone from the profile. - Removes the Mission Control link and its constant. - Tests: helper unit tests and a web E2E that writes the brief through the main bot, checks a habit off across a reload and sets check-in times (screenshots heute-coach and heute-rhythm). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/heute-brief.spec.ts | 113 ++++++++- .../components/heute/CoachRhythmControl.tsx | 214 ++++++++++++++++++ apps/web/src/components/heute/HeuteCoach.tsx | 105 +++++++++ apps/web/src/lib/heute-brief.test.ts | 67 +++++- apps/web/src/lib/heute-brief.ts | 37 ++- apps/web/src/pages/HeuteBrief.tsx | 127 +++++++---- packages/adapters/src/executor-prompt.ts | 4 +- 7 files changed, 616 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/components/heute/CoachRhythmControl.tsx create mode 100644 apps/web/src/components/heute/HeuteCoach.tsx diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index 28bf669..c473c98 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -1,20 +1,127 @@ +import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; -import { captureScreenshot, completeOnboarding, signup } from "./helpers"; +import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +/** The nav button reads "Heute" or "Heute, N need you". */ +function openHeute(page: Page) { + return page.getByRole("button", { name: /^Heute(,|$)/ }).click(); +} + +function shiftDate(date: string, days: number) { + return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); +} test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await completeOnboarding(page); - await page.getByRole("button", { name: "Heute", exact: true }).click(); + await openHeute(page); await expect(page).toHaveURL(/\/app\/[^/]+$/); const brief = page.getByTestId("heute-brief"); await expect(brief).toBeVisible(); await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0); await expect(page.getByText("evOo Mission Control")).toHaveCount(0); + await expect(page.getByTestId("heute-mission-control-link")).toHaveCount(0); await expect(page.getByTestId("heute-status")).toBeVisible(); - await expect(page.getByTestId("heute-mission-control-link")).toBeVisible(); + // Nothing tracked yet: no coach sections. + await expect(page.getByTestId("heute-coach-brief")).toHaveCount(0); + await expect(page.getByTestId("heute-habits")).toHaveCount(0); await captureScreenshot(page, testInfo, "heute-native-brief"); }); + +test("Heute shows the coach's brief, habits and goals, checks habits off and sets check-ins", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `heute-coach-${stamp}@rakazo.test`, "password12", "Heute Coach"); + await completeOnboarding(page); + + // The main bot writes today's brief through its tool. + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("write today's brief: Two calm hours on the beta, then a walk."); + await page.keyboard.press("Enter"); + await expect(page.getByText("today's brief is ready.").first()).toBeVisible({ timeout: 30_000 }); + + const { date } = await rpc(page, "coach/today", {}); + const goal = await rpc(page, "coach/goals/create", { + title: "Ship the beta", + targetDate: shiftDate(date, 30), + }); + const habit = await rpc(page, "coach/habits/create", { title: "Walk 20 minutes" }); + for (const day of [1, 2]) { + await rpc(page, "coach/checkIns/log", { + kind: "habit", + habitId: habit.id, + date: shiftDate(date, -day), + done: true, + }); + } + + await openHeute(page); + await expect(page.getByTestId("heute-coach-brief")).toHaveText( + "Two calm hours on the beta, then a walk.", + ); + await expect(page.getByTestId(`heute-goal-${goal.id}`)).toContainText("Ship the beta"); + const habitRow = page.getByTestId(`heute-habit-${habit.id}`); + await expect(habitRow).toContainText("Walk 20 minutes"); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("2 days"); + + const checkbox = habitRow.getByRole("checkbox"); + await expect(checkbox).not.toBeChecked(); + const logged = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/checkIns/log") && response.ok(), + ); + await checkbox.click(); + await logged; + await expect(checkbox).toBeChecked(); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days"); + await captureScreenshot(page, testInfo, "heute-coach"); + + await page.reload(); + await openHeute(page); + await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked(); + + // Check-in times live behind one compact control. + await page.getByTestId("heute-rhythm").click(); + const panel = page.getByTestId("heute-rhythm-panel"); + await expect(panel).toBeVisible(); + const morning = panel.getByTestId("heute-rhythm-morning"); + await expect(morning.getByRole("switch")).not.toBeChecked(); + const enabled = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await morning.getByRole("switch").click(); + await enabled; + const time = morning.locator('input[type="time"]'); + await expect(time).toHaveValue("08:00"); + const moved = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await time.fill("07:30"); + await moved; + await expect(time).toHaveValue("07:30"); + const weekly = panel.getByTestId("heute-rhythm-weekly"); + const weeklyOn = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await weekly.getByRole("switch").click(); + await weeklyOn; + await expect(weekly.getByRole("combobox")).toHaveValue("0"); + await captureScreenshot(page, testInfo, "heute-rhythm"); + + expect(await rpc(page, "coach/rhythm/get", {})).toEqual({ + morning: "07:30", + evening: null, + weekly: { day: 0, time: "18:00" }, + }); + const main = await rpc<{ id: string }>(page, "bots/ensureMain", {}); + const routines = await rpc(page, "routines/list", { botId: main.id }); + expect(routines.map((routine) => routine.name).sort()).toEqual([ + "Morning brief", + "Weekly review", + ]); +}); diff --git a/apps/web/src/components/heute/CoachRhythmControl.tsx b/apps/web/src/components/heute/CoachRhythmControl.tsx new file mode 100644 index 0000000..102c085 --- /dev/null +++ b/apps/web/src/components/heute/CoachRhythmControl.tsx @@ -0,0 +1,214 @@ +import { i18n } from "@lingui/core"; +import { useLingui } from "@lingui/react/macro"; +import type { CoachRhythm } from "@rakazo/contracts"; +import { + Button, + Input, + NativeSelect, + NativeSelectOption, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Switch, +} from "@rakazo/ui-web"; +import { CalendarClock } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useId, useRef, useState } from "react"; +import { rpc } from "../../lib/rpc"; + +/** Times a check-in starts at when it is switched on. */ +const DEFAULTS = { morning: "08:00", evening: "20:00", weekly: { day: 0, time: "18:00" } }; +const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; + +function weekdayName(day: number): string { + // 2026-10-04 was a Sunday, so day 0 maps to Sunday. + return new Date(Date.UTC(2026, 9, 4 + day)).toLocaleDateString(i18n.locale || "en", { + weekday: "long", + timeZone: "UTC", + }); +} + +/** When the main bot checks in: loaded when opened, saved on every change. */ +export function CoachRhythmControl() { + const { t } = useLingui(); + const [rhythm, setRhythm] = useState(null); + const latestRequest = useRef(0); + + const load = useCallback(async () => { + const request = ++latestRequest.current; + try { + const loaded = await rpc.coach.rhythm.get(); + if (request === latestRequest.current) setRhythm(loaded); + } catch { + // Opening the control again retries. + } + }, []); + + const save = useCallback( + async (next: CoachRhythm) => { + setRhythm(next); + const request = ++latestRequest.current; + try { + const saved = await rpc.coach.rhythm.set(next); + if (request === latestRequest.current) setRhythm(saved); + } catch { + if (request === latestRequest.current) void load(); + } + }, + [load], + ); + + return ( + { + if (open) void load(); + }} + > + } + > + + + {rhythm ? ( + <> + void save({ ...rhythm, morning: on ? DEFAULTS.morning : null })} + > + {(labelId) => + rhythm.morning ? ( + void save({ ...rhythm, morning })} + /> + ) : null + } + + void save({ ...rhythm, evening: on ? DEFAULTS.evening : null })} + > + {(labelId) => + rhythm.evening ? ( + void save({ ...rhythm, evening })} + /> + ) : null + } + + void save({ ...rhythm, weekly: on ? DEFAULTS.weekly : null })} + > + {(labelId) => { + const weekly = rhythm.weekly; + if (!weekly) return null; + return ( + <> + + void save({ + ...rhythm, + weekly: { ...weekly, day: Number(event.target.value) }, + }) + } + > + {WEEKDAYS.map((day) => ( + + {weekdayName(day)} + + ))} + + void save({ ...rhythm, weekly: { ...weekly, time } })} + /> + + ); + }} + + + ) : ( +
    + +
    + )} +
    +
    + ); +} + +function RhythmRow({ + label, + testId, + on, + onToggle, + children, +}: { + label: string; + testId: string; + on: boolean; + onToggle: (on: boolean) => void; + children: (labelId: string) => ReactNode; +}) { + const labelId = useId(); + return ( +
    + onToggle(checked)} + /> + + {label} + +
    {children(labelId)}
    +
    + ); +} + +function TimeInput({ + labelId, + value, + onChange, +}: { + labelId: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + { + if (event.target.value) onChange(event.target.value.slice(0, 5)); + }} + className="h-control-sm w-32 px-2 tabular-nums" + /> + ); +} diff --git a/apps/web/src/components/heute/HeuteCoach.tsx b/apps/web/src/components/heute/HeuteCoach.tsx new file mode 100644 index 0000000..c88a3c9 --- /dev/null +++ b/apps/web/src/components/heute/HeuteCoach.tsx @@ -0,0 +1,105 @@ +import { i18n } from "@lingui/core"; +import { Plural, Trans } from "@lingui/react/macro"; +import type { CoachToday, HabitToday } from "@rakazo/contracts"; +import { Checkbox, cn } from "@rakazo/ui-web"; +import { Flame } from "lucide-react"; +import { useId } from "react"; +import { formatLocalDate } from "../../lib/heute-brief"; + +/** Shown once a habit has been kept at least twice in a row. */ +const STREAK_MIN = 2; + +/** Today's brief, habits to check off and active goals; each part only when it has content. */ +export function HeuteCoach({ + today, + onHabitDone, +}: { + today: CoachToday; + onHabitDone: (habit: HabitToday, done: boolean) => void; +}) { + return ( + <> + {today.brief ? ( +
    +

    {today.brief.text}

    +
    + ) : null} + + {today.habits.length > 0 ? ( +
    +

    + Habits +

    +
      + {today.habits.map((habit) => ( +
    • + onHabitDone(habit, done)} /> +
    • + ))} +
    +
    + ) : null} + + {today.goals.length > 0 ? ( +
    +

    + Goals +

    +
      + {today.goals.map((goal) => ( +
    • + {goal.title} + {goal.targetDate ? ( + + {formatLocalDate(goal.targetDate, i18n.locale || "en", { + day: "numeric", + month: "short", + })} + + ) : null} +
    • + ))} +
    +
    + ) : null} + + ); +} + +function HabitRow({ habit, onDone }: { habit: HabitToday; onDone: (done: boolean) => void }) { + const id = useId(); + return ( + + ); +} diff --git a/apps/web/src/lib/heute-brief.test.ts b/apps/web/src/lib/heute-brief.test.ts index d023919..18e85e9 100644 --- a/apps/web/src/lib/heute-brief.test.ts +++ b/apps/web/src/lib/heute-brief.test.ts @@ -1,6 +1,13 @@ -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts"; import { describe, expect, it } from "vitest"; -import { buildHeuteBrief, HEUTE_NEEDS_YOU_CAP, isToday } from "./heute-brief"; +import { + buildHeuteBrief, + coachRefreshSignal, + formatLocalDate, + HEUTE_NEEDS_YOU_CAP, + isToday, + withHabitDone, +} from "./heute-brief"; const now = new Date("2026-09-22T12:00:00.000Z"); @@ -142,3 +149,59 @@ describe("buildHeuteBrief", () => { expect(brief.actionCount).toBe(5); }); }); + +function habit(overrides: Partial = {}): HabitToday { + return { + id: "habit-1", + title: "Walk", + cadence: "daily", + goalId: null, + active: true, + source: "user", + createdByBotId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + doneToday: false, + streak: 3, + ...overrides, + }; +} + +describe("coach on the Heute page", () => { + it("reloads the coach when a run finishes, not while runs are only working", () => { + const working = [run({ runId: "run-1", status: "running" })]; + expect(coachRefreshSignal(working)).toBe(""); + expect(coachRefreshSignal([...working, run({ runId: "run-2", status: "completed" })])).toBe( + "run-2", + ); + }); + + it("checks a habit off before the server confirms, moving the streak by one", () => { + const today: CoachToday = { + date: "2026-10-03", + timeZone: "Europe/Vienna", + brief: null, + goals: [], + habits: [habit(), habit({ id: "habit-2", doneToday: true, streak: 1 })], + checkIn: null, + }; + const checked = withHabitDone(today, "habit-1", true); + expect(checked.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([ + { doneToday: true, streak: 4 }, + { doneToday: true, streak: 1 }, + ]); + expect(withHabitDone(checked, "habit-2", false).habits[1]).toMatchObject({ + doneToday: false, + streak: 0, + }); + // Repeating the same state changes nothing. + expect(withHabitDone(checked, "habit-1", true)).toEqual(checked); + }); + + it("labels the user's calendar date without shifting it into the browser's zone", () => { + expect(formatLocalDate("2026-10-03", "en-US", { weekday: "long" })).toBe("Saturday"); + expect(formatLocalDate("2026-11-01", "en-US", { month: "short", day: "numeric" })).toBe( + "Nov 1", + ); + }); +}); diff --git a/apps/web/src/lib/heute-brief.ts b/apps/web/src/lib/heute-brief.ts index dfebb61..a102370 100644 --- a/apps/web/src/lib/heute-brief.ts +++ b/apps/web/src/lib/heute-brief.ts @@ -1,4 +1,4 @@ -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, RunActivityRow } from "@rakazo/contracts"; import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity"; export type JetztRunItem = { @@ -137,3 +137,38 @@ export function buildHeuteBrief(params: { weiter: [], }; } + +/** + * Finished runs are when the coach learns something (a check-in, today's brief), so a change in + * them is the cue to reload the coach's view of the day. + */ +export function coachRefreshSignal(recentRuns: RunActivityRow[]): string { + return recentRuns + .filter((run) => run.status === "completed") + .map((run) => run.runId) + .join(","); +} + +/** + * A habit checked or unchecked before the server confirms. The current period only counts + * toward the streak once it is done, so the streak moves by one. + */ +export function withHabitDone(today: CoachToday, habitId: string, done: boolean): CoachToday { + return { + ...today, + habits: today.habits.map((habit) => + habit.id !== habitId || habit.doneToday === done + ? habit + : { ...habit, doneToday: done, streak: Math.max(0, habit.streak + (done ? 1 : -1)) }, + ), + }; +} + +/** A calendar date ("YYYY-MM-DD") as a label, independent of the browser's time zone. */ +export function formatLocalDate( + date: string, + locale: string, + options: Intl.DateTimeFormatOptions, +): string { + return new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, { ...options, timeZone: "UTC" }); +} diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx index cdf12b7..1511736 100644 --- a/apps/web/src/pages/HeuteBrief.tsx +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -1,22 +1,22 @@ import { i18n } from "@lingui/core"; import { Plural, Trans, useLingui } from "@lingui/react/macro"; -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts"; import { botColors } from "@rakazo/ui-tokens"; import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web"; -import { ExternalLink } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CoachRhythmControl } from "../components/heute/CoachRhythmControl"; +import { HeuteCoach } from "../components/heute/HeuteCoach"; import { runActivityLabel } from "../lib/agent-activity"; +import type { DoingItem, DoneItem, JetztItem } from "../lib/heute-brief"; import { buildHeuteBrief, cleanBriefPreview, - type DoingItem, - type DoneItem, - type JetztItem, + coachRefreshSignal, + formatLocalDate, + withHabitDone, } from "../lib/heute-brief"; import { rpc } from "../lib/rpc"; -const MISSION_CONTROL_URL = "http://127.0.0.1:3088/"; - type HeuteBriefProps = { bots: Bot[]; groups: Group[]; @@ -26,6 +26,8 @@ type HeuteBriefProps = { onStartChat: () => void; }; +const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" }; + export function HeuteBrief({ bots, groups, @@ -37,6 +39,9 @@ export function HeuteBrief({ const [activeRuns, setActiveRuns] = useState([]); const [recentRuns, setRecentRuns] = useState([]); const [loading, setLoading] = useState(true); + const [coach, setCoach] = useState(null); + const [coachSettled, setCoachSettled] = useState(false); + const latestCoachRequest = useRef(0); useEffect(() => { let cancelled = false; @@ -68,6 +73,50 @@ export function HeuteBrief({ }; }, []); + const refreshCoach = useCallback(async () => { + const request = ++latestCoachRequest.current; + try { + const today = await rpc.coach.today(); + if (request === latestCoachRequest.current) setCoach(today); + } catch { + // Keep the last view; the next refresh retries. + } finally { + setCoachSettled(true); + } + }, []); + + // `coachSignal` is the trigger: a finished run may have logged a check-in or written the brief. + const coachSignal = useMemo(() => coachRefreshSignal(recentRuns), [recentRuns]); + useEffect(() => { + void refreshCoach(); + }, [refreshCoach, coachSignal]); + + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === "visible") void refreshCoach(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + document.removeEventListener("visibilitychange", onVisible); + // Ignore responses that arrive after unmount. + latestCoachRequest.current += 1; + }; + }, [refreshCoach]); + + const setHabitDone = useCallback( + async (habit: HabitToday, done: boolean) => { + if (!coach) return; + setCoach((current) => (current ? withHabitDone(current, habit.id, done) : current)); + latestCoachRequest.current += 1; + try { + await rpc.coach.checkIns.log({ kind: "habit", habitId: habit.id, date: coach.date, done }); + } finally { + void refreshCoach(); + } + }, + [coach, refreshCoach], + ); + const brief = useMemo( () => buildHeuteBrief({ @@ -81,31 +130,45 @@ export function HeuteBrief({ const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]); - const dateLabel = formatHeuteDate(new Date()); - const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; + const locale = i18n.locale || "de"; + const dateLabel = coach + ? formatLocalDate(coach.date, locale, DATE_LABEL) + : new Date().toLocaleDateString(locale, DATE_LABEL); + const hasCoachContent = Boolean( + coach && (coach.brief || coach.habits.length > 0 || coach.goals.length > 0), + ); + const hasContent = + hasCoachContent || brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; return (
    -
    -

    {dateLabel}

    -

    - -

    +
    +
    +

    + {dateLabel} +

    +

    + +

    +
    +
    - {loading && !hasContent ? ( + {(loading || !coachSettled) && !hasContent ? (

    Loading…

    ) : !hasContent ? ( 0} /> ) : ( -
    +
    + {coach ? : null} + {brief.needsYou.length > 0 ? (

    @@ -172,19 +235,6 @@ export function HeuteBrief({ ) : null}

    )} - -
    ); @@ -399,12 +449,3 @@ function jetztRowKey(item: JetztItem): string { if (item.kind === "run") return `run:${item.run.runId}`; return `unread:${item.chatKind}:${item.chat.id}`; } - -function formatHeuteDate(date: Date): string { - const locale = i18n.locale || "de"; - return date.toLocaleDateString(locale, { - weekday: "long", - day: "numeric", - month: "long", - }); -} diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 09704a4..5bdddea 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -79,8 +79,8 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): * by the user or inferred from conversations, so every value is escaped and the block says * it never carries instructions. Empty fields are left out, and with nothing to say the block * is omitted. A run whose replies other people read (a shared messaging channel, or another - * owner's agent over an agent connection) gets only the basics: the summary and facts stay - * with the user. + * owner's agent over an agent connection) gets only the basics: the summary, facts, goals and + * habits stay with the user. */ export function formatUserContext( user: UserContext, From 97210805beff99c87d52bb39f0fa135fc93ea8b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:09:56 +0000 Subject: [PATCH 21/38] test(onboarding): walk the onboarding questions end to end; mobile reads cards as text - onboarding-intake.spec walks focus, goal (after typing into the composer, which still works), check-ins, quiet hours and the notifications card when the browser offers it, with the onboarding-intake-goal, -rhythm and -done screenshots. - completeOnboarding and the wizard specs drop the preferences step; the scan step is optional (skipped when it asks, otherwise it advances by itself). - The user-profile spec now checks the browser-filled profile and the focus fact; the conversation spec checks the shorter app suggestion and the following goal question. - The hover spec measures a reply that fills its row (a bot row's rail sits at the row's end, so the old long onboarding line was what made it pass) and scopes Send to the composer, since an open goal card has its own. - Mobile shows a choice card as its question and answer instead of an empty bubble. - The focus options drop their unused summary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/onboarding-intake.ts | 5 -- apps/mobile/lib/api.test.ts | 19 +++++ apps/mobile/lib/api.ts | 6 ++ apps/web/e2e/helpers.ts | 21 +++-- apps/web/e2e/message-hover-actions.spec.ts | 17 ++-- apps/web/e2e/onboarding-conversation.spec.ts | 14 +++- apps/web/e2e/onboarding-emoji-first.spec.ts | 55 ++---------- apps/web/e2e/onboarding-intake.spec.ts | 83 +++++++++++++++++++ .../web/e2e/onboarding-model-required.spec.ts | 4 +- apps/web/e2e/onboarding-oo-swipe.spec.ts | 2 +- apps/web/e2e/user-profile.spec.ts | 44 +++++----- 11 files changed, 172 insertions(+), 98 deletions(-) create mode 100644 apps/web/e2e/onboarding-intake.spec.ts diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts index a5ea131..8a5828a 100644 --- a/apps/api/src/onboarding-intake.ts +++ b/apps/api/src/onboarding-intake.ts @@ -52,7 +52,6 @@ export interface FocusOption { id: string; letter: string; label: string; - summary: string; apps: string[]; /** Stored as a `goals` fact, so it stays a plain English statement. */ fact: string; @@ -63,7 +62,6 @@ export const FOCUS_OPTIONS: FocusOption[] = [ id: "day", letter: "A", label: "Day-to-day work", - summary: "Slack, calendar, and email", apps: ["slack", "gmail", "googlecalendar"], fact: "Wants help with day-to-day work first", }, @@ -71,7 +69,6 @@ export const FOCUS_OPTIONS: FocusOption[] = [ id: "inbox", letter: "B", label: "Inbox & email", - summary: "email and calendar", apps: ["gmail", "googlecalendar", "slack"], fact: "Wants help with inbox and email first", }, @@ -79,7 +76,6 @@ export const FOCUS_OPTIONS: FocusOption[] = [ id: "research", letter: "C", label: "Research & writing", - summary: "the web, notes, and docs", apps: ["hackernews", "notion", "googledocs"], fact: "Wants help with research and writing first", }, @@ -87,7 +83,6 @@ export const FOCUS_OPTIONS: FocusOption[] = [ id: "crew", letter: "D", label: "Specialist crew", - summary: "a coordinated team of focused Oos", apps: ["slack", "gmail", "googlecalendar"], fact: "Wants a coordinated crew of specialist bots", }, diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index ddc8e93..053a60c 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -1857,6 +1857,25 @@ describe("mobile thread event reduction", () => { ).toBe("Slack · Alex: Hello from the group"); }); + it("reads a choice card as its question and answer", () => { + const card = { + kind: "choice" as const, + step: "goal" as const, + question: "What’s one thing you want to get done in the next few weeks?", + textInput: { placeholder: "Your goal" }, + options: [{ id: "skip", letter: "", label: "Skip" }], + }; + expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question); + expect( + blockText( + mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]), + ), + ).toBe(`${card.question}\nRun a 10K`); + expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe( + `${card.question}\nSkip`, + ); + }); + it("deduplicates durable messages and replaces matching transient subagent state", () => { const initial = snapshot([ mobileMessage("message-1", [{ kind: "text", text: "old" }]), diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index f328abc..45e5777 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -874,6 +874,12 @@ export function blockText(message: MobileMessage) { if (block.kind === "child_bot") { return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`; } + // Choice cards are answered on the web; here they read as the question and its answer. + if (block.kind === "choice") { + const answer = + block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label; + return answer ? `${block.question}\n${answer}` : block.question; + } if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`; if (block.kind === "file") { diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 84d7794..c5241fe 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) { } /** - * Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation` - * is set, it then opens the main bot's own thread, where most specs start. + * Walks the first-run wizard, which lands in the main conversation with the main bot's first + * onboarding question pending (the composer works regardless). Unless `mainConversation` is + * set, it then opens the main bot's own thread, where most specs start. */ export async function completeOnboarding( page: Page, @@ -101,13 +102,12 @@ export async function completeOnboarding( if (page.url().includes("/onboarding")) { await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations|scan|bot)$/, + /^(emoji|language|voice|integrations|scan|bot)$/, { timeout: 30_000 }, ); } const design = page.getByRole("heading", { name: /Design your Oo/i }); - const preferences = page.getByRole("heading", { name: /Tell us about yourself/i }); const language = page.getByRole("heading", { name: /Choose your language/i }); const voice = page.getByRole("heading", { name: /Pick a voice/i }); const integrations = page.getByRole("heading", { name: "Server integrations", exact: true }); @@ -119,8 +119,6 @@ export async function completeOnboarding( await expect(entry.first()).toBeVisible({ timeout: 20_000 }); if (await design.isVisible()) { - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(preferences).toBeVisible(); await page.getByRole("button", { name: "Continue", exact: true }).click(); await expect(language).toBeVisible(); await page.getByRole("button", { name: /Skip/i }).click(); @@ -133,8 +131,15 @@ export async function completeOnboarding( await page.getByRole("button", { name: "Skip", exact: true }).click(); } - // Scan step auto-advances to the main bot, then onboarding lands in the main conversation. - await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); + // The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding + // then lands in the main conversation. + const skipScan = page + .locator('[data-onboarding-step="scan"]') + .getByRole("button", { name: "Skip", exact: true }); + await expect(async () => { + if (await skipScan.isVisible()) await skipScan.click(); + expect(page.url()).toMatch(/\/app\/g\/[^/]+$/); + }).toPass({ timeout: 30_000 }); await expect(chief).toBeVisible({ timeout: 15_000 }); await waitForChatComposer(page); diff --git a/apps/web/e2e/message-hover-actions.spec.ts b/apps/web/e2e/message-hover-actions.spec.ts index 4667825..d35ade3 100644 --- a/apps/web/e2e/message-hover-actions.spec.ts +++ b/apps/web/e2e/message-hover-actions.spec.ts @@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr /** Submit from the Send control so an in-flight prior send cannot swallow Enter. */ async function sendComposerMessage(page: Page, composer: Locator, text: string) { await composer.fill(text); - const send = page.getByRole("button", { name: "Send", exact: true }); + // An open onboarding card in the transcript has its own Send. + const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true }); await expect(send).toBeEnabled(); await send.click(); await expect(composer).toHaveValue(""); @@ -72,13 +73,20 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( const transcript = page.getByTestId("transcript"); - // Empty start: no Fresh-start greeting. Answer the focus card so a plain - // bot text bubble exists for hover layout checks. + // Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot + // row's rail sits at the row's end, so the measured reply must wrap and fill its row. await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({ timeout: 20_000, }); await page.getByTestId("focus-choice-day").click(); - const botText = page.getByText(/Got it\./); + await expect(page.getByText(/Got it\./)).toBeVisible({ timeout: 20_000 }); + const composer = page.getByRole("combobox", { name: /^Message/ }); + await sendComposerMessage( + page, + composer, + `hover-bot-${stamp} ${"please look into this ".repeat(7)}`, + ); + const botText = page.getByText(/i handled: hover-bot-/); await expect(botText).toBeVisible({ timeout: 20_000 }); const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first(); await expect(botRow).toBeVisible(); @@ -121,7 +129,6 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( const parentText = `hover-parent-${stamp}`; const replyText = `hover-reply-${stamp}`; - const composer = page.getByRole("combobox", { name: /^Message/ }); await expect(composer).toBeVisible(); await sendComposerMessage(page, composer, parentText); diff --git a/apps/web/e2e/onboarding-conversation.spec.ts b/apps/web/e2e/onboarding-conversation.spec.ts index 57bd3bb..e028c66 100644 --- a/apps/web/e2e/onboarding-conversation.spec.ts +++ b/apps/web/e2e/onboarding-conversation.spec.ts @@ -21,6 +21,9 @@ test("focus choice suggests apps and preserves a completed connection", async ({ await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work"); await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing"); await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew"); + // Onboarding questions offer a quiet way out instead of a dismiss button. + await expect(page.getByTestId("focus-choice-skip")).toBeVisible(); + await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "01-focus-choice"); await captureScreenshot(page, testInfo, "choice-card-onboarding"); @@ -33,6 +36,9 @@ test("focus choice suggests apps and preserves a completed connection", async ({ ).toBeVisible(); expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief"); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); + await expect( + page.getByText("Got it. Slack, Gmail, and Google Calendar are a good place to start."), + ).toBeVisible(); await expect(page.getByText("Slack", { exact: true })).toBeVisible(); await expect(page.getByText("Gmail", { exact: true })).toBeVisible(); const connectionCards = page.getByRole("group", { name: / connection$/ }); @@ -45,10 +51,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({ ); expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8); expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8); - await page - .getByTestId("transcript") - .getByText("Hit those three and I’ll start pulling the picture.") - .scrollIntoViewIfNeeded(); + // The next onboarding question follows the suggestions. + const goal = page.locator('[data-testid="choice-card"][data-step="goal"]'); + await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?"); + await slackCard(page).scrollIntoViewIfNeeded(); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "02-app-suggestions"); const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" }); diff --git a/apps/web/e2e/onboarding-emoji-first.spec.ts b/apps/web/e2e/onboarding-emoji-first.spec.ts index 0a49936..51e1bf8 100644 --- a/apps/web/e2e/onboarding-emoji-first.spec.ts +++ b/apps/web/e2e/onboarding-emoji-first.spec.ts @@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => { }); /** - * E2E test for the new emoji-first onboarding flow: + * E2E test for the emoji-first onboarding flow: * 1. Sign up → lands on onboarding * 2. Design Oo: pick shape + color → live preview - * 3. Tell us about yourself: name, timezone, hobbies, work style, communication - * 4. Choose language - * 5. Skip integrations → bot created → land in chat + * 3. Choose language, skip the voice + * 4. Skip integrations → bot created → land in chat, where the main bot asks the rest */ test("emoji-first onboarding: shape + color selection with live preview", async ({ @@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview"); - // Click Continue → goes to preferences step + // Click Continue → goes to the language step await page .getByRole("button", { name: /Continue/i }) .first() .click(); - // Step 2: Tell us about yourself - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - - if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences"); - - // Fill in name - await page.getByPlaceholder(/Your name/i).fill("Alice"); - - // Fill in timezone - await page.getByLabel(/Timezone/i).fill("Europe/Vienna"); - - // Select a hobby - const hobbyButtons = page.locator("button[aria-pressed]"); - if (await hobbyButtons.count()) { - await hobbyButtons.first().click(); // toggle first hobby - } - - // Select work style - const workStyleButtons = page.locator("button[aria-pressed]:visible"); - if ((await workStyleButtons.count()) >= 2) { - await workStyleButtons.nth(0).click(); - } - - // Select communication style - const commButtons = page.locator("button[aria-pressed]:visible"); - if ((await commButtons.count()) >= 2) { - await commButtons.nth(0).click(); - } - - if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled"); - - // Click Continue → goes to language step - await page - .getByRole("button", { name: /Continue/i }) - .first() - .click(); - - // Step 3: Language selection + // Step 2: Language selection await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); if (testInfo) await captureScreenshot(page, testInfo, "05-language-step"); @@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => { .first() .click(); - // Preferences step — just continue - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - await page - .getByRole("button", { name: /Continue/i }) - .first() - .click(); - // Language step — click Skip, then skip voice. await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await page.getByRole("button", { name: /Skip/i }).click(); diff --git a/apps/web/e2e/onboarding-intake.spec.ts b/apps/web/e2e/onboarding-intake.spec.ts new file mode 100644 index 0000000..3b7903a --- /dev/null +++ b/apps/web/e2e/onboarding-intake.spec.ts @@ -0,0 +1,83 @@ +/** + * E2E: after the wizard the main bot gets to know the user in the main conversation, one card + * after each answer, without ever blocking the composer. + */ +import { expect, type Page, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +function card(page: Page, step: string) { + return page.locator(`[data-testid="choice-card"][data-step="${step}"]`); +} + +test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({ + page, +}, testInfo) => { + await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester"); + await completeOnboarding(page, undefined, { mainConversation: true }); + + await page.getByTestId("focus-choice-day").click(); + const goal = card(page, "goal"); + await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?"); + + // Typing instead of answering goes to the main bot, and the card stays answerable. + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("Quick question first"); + await composer.press("Enter"); + await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, { + timeout: 60_000, + }); + const goalInput = goal.getByRole("textbox", { + name: "What’s one thing you want to get done in the next few weeks?", + }); + await goalInput.fill("Run a 10K"); + await goal.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-goal"); + await goalInput.press("Enter"); + await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K"); + + const rhythm = card(page, "rhythm"); + await expect(rhythm).toContainText("When should I check in with you?"); + await rhythm.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-rhythm"); + await rhythm.getByTestId("rhythm-choice-morning").click(); + await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning"); + + const quiet = card(page, "quiet_hours"); + await expect(quiet).toContainText("When should I stay quiet?"); + await quiet.getByTestId("quiet_hours-choice-22-07").click(); + + // Only a browser that can still turn push on is asked about notifications. + const closing = page.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/); + const notifications = card(page, "notifications"); + await expect(notifications.or(closing).first()).toBeVisible(); + if (await notifications.isVisible()) { + await notifications.getByTestId("notifications-choice-later").click(); + } + await expect(closing).toBeVisible(); + await expect( + page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'), + ).toHaveCount(0); + await closing.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-done"); + + const settings = await rpc<{ quietHours: { start: string; end: string } | null }>( + page, + "notifications/settings/get", + null, + ); + expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" }); + const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>( + page, + "profile/get", + null, + ); + expect(profile.facts).toContainEqual( + expect.objectContaining({ + value: "Wants help with day-to-day work first", + source: "onboarding", + }), + ); +}); diff --git a/apps/web/e2e/onboarding-model-required.spec.ts b/apps/web/e2e/onboarding-model-required.spec.ts index bfd722e..224608d 100644 --- a/apps/web/e2e/onboarding-model-required.spec.ts +++ b/apps/web/e2e/onboarding-model-required.spec.ts @@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({ await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations)$/, + /^(emoji|language|voice|integrations)$/, { timeout: 30_000 }, ); await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ @@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) { await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations)$/, + /^(emoji|language|voice|integrations)$/, { timeout: 30_000 }, ); await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ diff --git a/apps/web/e2e/onboarding-oo-swipe.spec.ts b/apps/web/e2e/onboarding-oo-swipe.spec.ts index 8e175f2..0789ace 100644 --- a/apps/web/e2e/onboarding-oo-swipe.spec.ts +++ b/apps/web/e2e/onboarding-oo-swipe.spec.ts @@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page } .getByRole("button", { name: /Continue/i }) .first() .click(); - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); }); diff --git a/apps/web/e2e/user-profile.spec.ts b/apps/web/e2e/user-profile.spec.ts index 9b57445..da47b22 100644 --- a/apps/web/e2e/user-profile.spec.ts +++ b/apps/web/e2e/user-profile.spec.ts @@ -1,6 +1,7 @@ /** - * E2E: onboarding answers land in the user profile, and Settings → About you shows every - * fact with its origin, confirms a pending one, and forgets one or all of them. + * E2E: onboarding fills the user profile without asking (time zone, locale and language from + * the browser) and records the focus answer as a fact; Settings → About you shows every fact + * with its origin, confirms a pending one, and forgets one or all of them. */ import { expect, test } from "@playwright/test"; import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers"; @@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } test.use({ timezoneId: "Europe/Vienna" }); test.beforeEach(async ({ page }) => { - // Walk the full emoji-first flow, including the preferences step. + // Walk the full emoji-first flow. await page.route("**/rpc/integrationSetup/get", (route) => route.fulfill({ json: { @@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async }); await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna"); - await page.getByPlaceholder("Your name").fill("Pat Example"); - await page.getByRole("button", { name: "Music", exact: true }).click(); - await page.getByRole("button", { name: "Deep-dive focused" }).click(); - await page.getByRole("button", { name: "Direct & concise" }).click(); - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await page.getByRole("button", { name: "English", exact: true }).click(); await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible(); await page.getByRole("button", { name: "Skip", exact: true }).click(); - await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 }); + await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 }); await waitForChatComposer(page); + // The main bot asks the rest in the main conversation; the focus answer becomes a fact. + await page.getByTestId("focus-choice-day").click(); + await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible(); // The answers no longer overwrite a bot's instructions. const bots = await rpc>(page, "bots/list", null); expect(bots.length).toBeGreaterThan(0); for (const bot of bots) expect(bot.instructions).not.toContain("User Context"); - expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example"); + // The account name is used as is; onboarding no longer asks for it again. + expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester"); // Sensitive facts from anywhere but the user wait for confirmation. await rpc(page, "profile/update", { @@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna"); await expect(profile.getByTestId("profile-field-language")).toContainText("English"); await expect(profile.getByTestId("profile-field-locale")).toContainText("United States"); - await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText( - "Direct and concise", - ); - const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" }); - await expect(music).toContainText("Onboarding"); - await expect(profile.getByTestId("user-facts-habits")).toContainText( - "Prefers focused deep dives", + const focus = profile + .getByTestId("user-fact") + .filter({ hasText: "Wants help with day-to-day work first" }); + await expect(focus).toContainText("Onboarding"); + await expect(profile.getByTestId("user-facts-goals")).toContainText( + "Wants help with day-to-day work first", ); const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" }); @@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async await expect(allergy).toHaveAttribute("data-status", "active"); await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0); - await music.getByRole("button", { name: "Delete" }).click(); - await expect(music).toHaveCount(0); + await focus.getByRole("button", { name: "Delete" }).click(); + await expect(focus).toHaveCount(0); await captureScreenshot(page, testInfo, "user-profile-settings"); const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>( page, @@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async null, ); expect(afterDelete.facts).toEqual( - expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]), + expect.not.arrayContaining([ + expect.objectContaining({ value: "Wants help with day-to-day work first" }), + ]), ); expect(afterDelete.facts).toContainEqual( expect.objectContaining({ value: "Allergic to peanuts", status: "active" }), From 59d02c46fb691e41f449e8b96924dcf64250462e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:11:10 +0000 Subject: [PATCH 22/38] test(web): stub the Notification permission without a static-only class Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/notification-settings.spec.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/e2e/notification-settings.spec.ts b/apps/web/e2e/notification-settings.spec.ts index 4d7893f..27e1571 100644 --- a/apps/web/e2e/notification-settings.spec.ts +++ b/apps/web/e2e/notification-settings.spec.ts @@ -15,18 +15,19 @@ const ENDPOINT = "https://push.example.test/e2e-device"; async function stubPushApi(page: Page, endpoint: string) { await page.addInitScript((pushEndpoint) => { const permissionKey = "e2e:notification-permission"; + const notification = Object.defineProperties(function Notification() {}, { + permission: { get: () => sessionStorage.getItem(permissionKey) ?? "default" }, + requestPermission: { + value: async () => { + sessionStorage.setItem(permissionKey, "granted"); + return "granted"; + }, + }, + }); Object.defineProperty(window, "Notification", { configurable: true, writable: true, - value: class { - static get permission() { - return sessionStorage.getItem(permissionKey) ?? "default"; - } - static async requestPermission() { - sessionStorage.setItem(permissionKey, "granted"); - return "granted"; - } - }, + value: notification, }); const storageKey = "e2e:push-subscription"; const current = () => { From d3689b88fcebf9a77de1116608507b09e51967d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:51:59 +0000 Subject: [PATCH 23/38] feat(onboarding): show the onboarding cards and check-in names in the user's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding cards, the closing line and the coach routine names were English server text for every user. The server keeps the state and an English fallback; the web now words them through Lingui, so there is one translation source. - Choice cards: the web says the question, the option labels and the placeholder from the step and option ids, and keeps the server text for anything it does not know (quiet-hour ranges are already written in the user's locale). Another bot's focus card is recognized by its option ids. Mobile and agent history keep the server text. - The "Got it. … are a good place to start." line is gone: the app cards speak for themselves, and with no cards nothing is said. - The closing line becomes an intake_note block (note "done", nextCheckInAt, English text). The web words it with the first check-in in the user's clock; mobile blockText and blocksToAgentHistoryText read its text through their generic fallback. - Routines carry coachKey. The routine list and editor show a check-in under its label in the UI language until the user renames it; saving an untouched label keeps the stored name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/onboarding-intake.test.ts | 40 +++-- apps/api/src/onboarding-intake.ts | 18 ++- apps/api/src/onboarding.test.ts | 14 +- apps/api/src/onboarding.ts | 29 +--- apps/api/src/router.ts | 3 + apps/mobile/lib/api.test.ts | 7 + apps/web/e2e/heute-brief.spec.ts | 19 ++- apps/web/e2e/message-hover-actions.spec.ts | 9 +- apps/web/e2e/onboarding-conversation.spec.ts | 5 +- apps/web/e2e/onboarding-intake.spec.ts | 5 +- apps/web/src/lib/onboarding-copy.test.ts | 140 ++++++++++++++++++ apps/web/src/lib/onboarding-copy.ts | 107 +++++++++++++ apps/web/src/lib/routine-name.test.ts | 28 ++++ apps/web/src/lib/routine-name.ts | 27 ++++ apps/web/src/pages/RoutineEditor.tsx | 5 +- apps/web/src/pages/Shell.tsx | 10 +- apps/web/src/pages/shell/message-cards.tsx | 17 ++- packages/adapters/src/coach-rhythm.ts | 20 ++- packages/contracts/src/coach.ts | 12 ++ packages/contracts/src/domain.ts | 3 + packages/contracts/src/events.ts | 11 ++ packages/contracts/src/rpc.ts | 2 +- packages/core/src/attachments.test.ts | 13 ++ .../src/onboarding-intake.postgres.test.ts | 20 ++- 24 files changed, 482 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/lib/onboarding-copy.test.ts create mode 100644 apps/web/src/lib/onboarding-copy.ts create mode 100644 apps/web/src/lib/routine-name.test.ts create mode 100644 apps/web/src/lib/routine-name.ts diff --git a/apps/api/src/onboarding-intake.test.ts b/apps/api/src/onboarding-intake.test.ts index 0c8ccbd..42102f9 100644 --- a/apps/api/src/onboarding-intake.test.ts +++ b/apps/api/src/onboarding-intake.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - closingLine, + closingNote, focusCard, intakeContext, intakeStep, @@ -61,32 +61,42 @@ describe("readIntakeAnswer", () => { }); }); -describe("closingLine", () => { +describe("closingNote", () => { const vienna = (iso: string, locale = "de-AT") => intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso)); + const note = (rhythm: Parameters[0], context: ReturnType) => { + const { nextCheckInAt, text } = closingNote(rhythm, context); + return { nextCheckInAt, text: plain(text) }; + }; it("names the first check-in in the user's time zone", () => { const morning = { ...off, morning: "08:00" }; - expect(closingLine(morning, vienna("2026-10-07T04:00:00Z"))).toBe( - "All set. I’ll check in today at 8:00.", - ); - expect(closingLine(morning, vienna("2026-10-07T09:00:00Z"))).toBe( - "All set. I’ll check in tomorrow at 8:00.", - ); - expect(closingLine({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z"))).toBe( + expect(note(morning, vienna("2026-10-07T04:00:00Z"))).toEqual({ + nextCheckInAt: "2026-10-07T06:00:00.000Z", + text: "All set. I’ll check in today at 8:00.", + }); + expect(note(morning, vienna("2026-10-07T09:00:00Z"))).toEqual({ + nextCheckInAt: "2026-10-08T06:00:00.000Z", + text: "All set. I’ll check in tomorrow at 8:00.", + }); + expect(note({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z")).text).toBe( "All set. I’ll check in today at 18:00.", ); // Wednesday in Vienna; the weekly review is on Sunday. const weekly = { ...off, weekly: { day: 0, time: "18:00" } }; - expect(plain(closingLine(weekly, vienna("2026-10-07T09:00:00Z", "en-US")))).toBe( - "All set. I’ll check in on Sunday at 6:00 PM.", - ); + expect(note(weekly, vienna("2026-10-07T09:00:00Z", "en-US"))).toEqual({ + nextCheckInAt: "2026-10-11T16:00:00.000Z", + text: "All set. I’ll check in on Sunday at 6:00 PM.", + }); }); it("says the user can just write when no check-in is set", () => { - expect(closingLine(off, vienna("2026-10-07T09:00:00Z"))).toBe( - "All set. I’m here whenever you need me.", - ); + expect(closingNote(off, vienna("2026-10-07T09:00:00Z"))).toEqual({ + kind: "intake_note", + note: "done", + nextCheckInAt: null, + text: "All set. I’m here whenever you need me.", + }); }); it("falls back to English and UTC for an unusable profile", () => { diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts index 8a5828a..d67381a 100644 --- a/apps/api/src/onboarding-intake.ts +++ b/apps/api/src/onboarding-intake.ts @@ -267,10 +267,24 @@ function localDay(date: Date, timeZone: string): number { return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000; } -/** One line that says what happens next: the first check-in, when there is one. */ -export function closingLine(rhythm: CoachRhythm, context: IntakeContext): string { +export type IntakeNote = Extract; + +/** + * The closing note: what happens next, i.e. the first check-in when there is one. The web says + * it in the user's language from `nextCheckInAt`; `text` is the English line for everything else. + */ +export function closingNote(rhythm: CoachRhythm, context: IntakeContext): IntakeNote { const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []); const next = nextCronDateAcross(crons, context.now, context.timeZone); + return { + kind: "intake_note", + note: "done", + nextCheckInAt: next?.toISOString() ?? null, + text: closingText(next, context), + }; +} + +function closingText(next: Date | null, context: IntakeContext): string { if (!next) return "All set. I’m here whenever you need me."; const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone); const day = diff --git a/apps/api/src/onboarding.test.ts b/apps/api/src/onboarding.test.ts index 66f8328..4d68859 100644 --- a/apps/api/src/onboarding.test.ts +++ b/apps/api/src/onboarding.test.ts @@ -41,12 +41,9 @@ describe("onboarding connection suggestions", () => { it("does not invent authorization cards when no connector has an app catalog", async () => { const { deps, actor } = fixture([]); await chooseFocus(deps, actor, "bot", { optionId: "day" }); - expect(posted.flatMap((message) => message.blocks)).not.toContainEqual( - expect.objectContaining({ kind: "app_connect" }), - ); - expect(posted.flatMap((message) => message.blocks)).toEqual([ - { kind: "text", text: "Got it. What would you like to work on first?" }, - ]); + // The answered card is all that changes; nothing is said around it. + expect(posted).toEqual([]); + expect(deps.events.notify).toHaveBeenCalled(); }); it("uses the available connector and omits unavailable apps", async () => { const { deps, actor } = fixture([ @@ -60,9 +57,8 @@ describe("onboarding connection suggestions", () => { ).toEqual([ expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }), ]); - expect(posted[0]?.blocks).toEqual([ - { kind: "text", text: "Got it. Slack is a good place to start." }, - ]); + // The cards speak for themselves. + expect(posted).toHaveLength(1); }); }); diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index aa570e7..868acb4 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -15,7 +15,7 @@ import { } from "@rakazo/db"; import { type ChoiceBlock, - closingLine, + closingNote, FOCUS_OPTIONS, type FocusOption, focusCard, @@ -248,9 +248,7 @@ export async function chooseFocus( ? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id) : undefined; // Fetched before the transaction: the connector catalog can take seconds. - const followUp = focus - ? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step)) - : []; + const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus) : []; const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { await lockThread(tx, thread.id); @@ -315,16 +313,15 @@ async function askNext( // The rhythm step saved its answer outside this transaction, so the routines are readable. const rhythm = await getCoachRhythm(deps.prisma, userId); await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } }); - return postInTransaction(tx, target, [{ kind: "text", text: closingLine(rhythm, context) }]); + return postInTransaction(tx, target, [closingNote(rhythm, context)]); } -/** The messages after a focus answer: a short acknowledgement and the apps worth connecting. */ +/** The apps worth connecting for a focus, as cards; they need no words around them. */ async function focusFollowUp( deps: OnboardingDeps, actor: Actor, botId: string, option: FocusOption, - onboarding: boolean, ): Promise { const providers = deps.connectors.managedProviders(); const catalog = ( @@ -362,23 +359,7 @@ async function focusFollowUp( }, ]; }); - if (!cards.length) { - // In onboarding the next question follows; another bot asks what to start with. - const text = onboarding ? "Got it." : "Got it. What would you like to work on first?"; - return [[{ kind: "text", text }]]; - } - const names = cards.map((card) => card.name); - const named = - names.length > 1 ? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}` : names[0]; - return [ - [ - { - kind: "text", - text: `Got it. ${named} ${names.length > 1 ? "are" : "is"} a good place to start.`, - }, - ], - cards, - ]; + return cards.length > 0 ? [cards] : []; } export async function markAppConnected( diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 1c8f5eb..2fa49c5 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -92,6 +92,7 @@ import type { Auth } from "@rakazo/auth"; import type { Actor, ComputerStatus, McpServer, Me, SpaceNavigation } from "@rakazo/contracts"; import { appContract, + CoachRoutineKeySchema, IntegrationProviderIdSchema, OPENAI_COMPATIBLE_PROVIDER_ID, } from "@rakazo/contracts"; @@ -5563,6 +5564,7 @@ function mapRoutine(row: { lastRunAt: Date | null; nextRunAt: Date | null; createdAt: Date; + coachKey: string | null; }) { return { id: row.id, @@ -5579,6 +5581,7 @@ function mapRoutine(row: { lastRunAt: row.lastRunAt?.toISOString() ?? null, nextRunAt: row.nextRunAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), + coachKey: CoachRoutineKeySchema.safeParse(row.coachKey).data ?? null, }; } diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 053a60c..53ae1e8 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -1874,6 +1874,13 @@ describe("mobile thread event reduction", () => { expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe( `${card.question}\nSkip`, ); + const note = { + kind: "intake_note" as const, + note: "done" as const, + nextCheckInAt: null, + text: "All set. I’m here whenever you need me.", + }; + expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text); }); it("deduplicates durable messages and replaces matching transient subagent state", () => { diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index c473c98..1019a6d 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -1,7 +1,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts"; -import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; +import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers"; /** The nav button reads "Heute" or "Heute, N need you". */ function openHeute(page: Page) { @@ -120,8 +120,17 @@ test("Heute shows the coach's brief, habits and goals, checks habits off and set }); const main = await rpc<{ id: string }>(page, "bots/ensureMain", {}); const routines = await rpc(page, "routines/list", { botId: main.id }); - expect(routines.map((routine) => routine.name).sort()).toEqual([ - "Morning brief", - "Weekly review", - ]); + expect(routines.map(({ name, coachKey }) => ({ name, coachKey }))).toEqual( + expect.arrayContaining([ + { name: "Morning brief", coachKey: "morning" }, + { name: "Weekly review", coachKey: "weekly" }, + ]), + ); + expect(routines).toHaveLength(2); + + // The main bot's routine list names the check-ins by their key, in the UI language. + await openMainBotThread(page); + await page.getByTitle("Agent computer").click(); + await expect(page.getByText("Morning brief", { exact: true })).toBeVisible(); + await expect(page.getByText("Weekly review", { exact: true })).toBeVisible(); }); diff --git a/apps/web/e2e/message-hover-actions.spec.ts b/apps/web/e2e/message-hover-actions.spec.ts index d35ade3..c511059 100644 --- a/apps/web/e2e/message-hover-actions.spec.ts +++ b/apps/web/e2e/message-hover-actions.spec.ts @@ -79,7 +79,9 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( timeout: 20_000, }); await page.getByTestId("focus-choice-day").click(); - await expect(page.getByText(/Got it\./)).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({ + timeout: 20_000, + }); const composer = page.getByRole("combobox", { name: /^Message/ }); await sendComposerMessage( page, @@ -398,7 +400,10 @@ test.describe("touch message actions", () => { timeout: 20_000, }); await page.getByTestId("focus-choice-day").click(); - const botText = page.getByText(/Got it\./); + // Onboarding writes no text of its own, so ask for a plain reply to act on. + const composer = page.getByRole("combobox", { name: /^Message/ }); + await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`); + const botText = page.getByText(/i handled: touch-bot-/); await expect(botText).toBeVisible({ timeout: 20_000 }); const row = page .getByTestId("transcript") diff --git a/apps/web/e2e/onboarding-conversation.spec.ts b/apps/web/e2e/onboarding-conversation.spec.ts index e028c66..7e78720 100644 --- a/apps/web/e2e/onboarding-conversation.spec.ts +++ b/apps/web/e2e/onboarding-conversation.spec.ts @@ -36,9 +36,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({ ).toBeVisible(); expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief"); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); - await expect( - page.getByText("Got it. Slack, Gmail, and Google Calendar are a good place to start."), - ).toBeVisible(); + // The app cards speak for themselves; onboarding adds no text around them. + await expect(page.getByText(/^Got it/)).toHaveCount(0); await expect(page.getByText("Slack", { exact: true })).toBeVisible(); await expect(page.getByText("Gmail", { exact: true })).toBeVisible(); const connectionCards = page.getByRole("group", { name: / connection$/ }); diff --git a/apps/web/e2e/onboarding-intake.spec.ts b/apps/web/e2e/onboarding-intake.spec.ts index 3b7903a..fdcac51 100644 --- a/apps/web/e2e/onboarding-intake.spec.ts +++ b/apps/web/e2e/onboarding-intake.spec.ts @@ -49,7 +49,10 @@ test("the main bot asks focus, goal, check-ins and quiet hours, then says what i await quiet.getByTestId("quiet_hours-choice-22-07").click(); // Only a browser that can still turn push on is asked about notifications. - const closing = page.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/); + // The web words the closing note itself, from the time of the first check-in. + const closing = page + .getByTestId("transcript") + .getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/); const notifications = card(page, "notifications"); await expect(notifications.or(closing).first()).toBeVisible(); if (await notifications.isVisible()) { diff --git a/apps/web/src/lib/onboarding-copy.test.ts b/apps/web/src/lib/onboarding-copy.test.ts new file mode 100644 index 0000000..eb8e1c2 --- /dev/null +++ b/apps/web/src/lib/onboarding-copy.test.ts @@ -0,0 +1,140 @@ +import type { MessageBlock, ThreadMessage } from "@rakazo/contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "./i18n.js"; +import { intakeNoteText, localizedChoice, withIntakeNoteText } from "./onboarding-copy.js"; + +// The macros compile at build time; here they keep the English source. +vi.mock("@lingui/core/macro", () => { + const text = (parts: TemplateStringsArray, ...values: unknown[]) => + parts.reduce( + (out, part, index) => out + part + (index < values.length ? values[index] : ""), + "", + ); + return { + t: text, + msg: (parts: TemplateStringsArray, ...values: unknown[]) => { + const message = text(parts, ...values); + return { id: message, message }; + }, + }; +}); + +type Choice = Extract; +type Note = Extract; + +// Intl may separate the time from AM/PM with a narrow no-break space. +const plain = (text: string) => text.replace(/\s/g, " "); + +beforeEach(() => { + i18n.load("en", {}); + i18n.activate("en"); +}); + +describe("localizedChoice", () => { + it("says a known card from its step and option ids, whatever the server wrote", () => { + const card: Choice = { + kind: "choice", + step: "rhythm", + question: "server question", + options: [ + { id: "morning", letter: "A", label: "server morning" }, + { id: "fortnightly", letter: "B", label: "Every other week" }, + { id: "none", letter: "", label: "server none" }, + ], + }; + expect(localizedChoice(card)).toEqual({ + ...card, + question: "When should I check in with you?", + options: [ + { id: "morning", letter: "A", label: "Every morning" }, + // An option the web does not know keeps the server text. + { id: "fortnightly", letter: "B", label: "Every other week" }, + { id: "none", letter: "", label: "Not now" }, + ], + }); + }); + + it("words the goal placeholder and keeps the server's quiet-hour ranges", () => { + const goal = localizedChoice({ + kind: "choice", + step: "goal", + question: "?", + textInput: { placeholder: "server placeholder" }, + options: [{ id: "skip", letter: "", label: "server skip" }], + }); + expect(goal.textInput).toEqual({ placeholder: "Your goal" }); + expect(goal.options[0]?.label).toBe("Skip"); + const quiet = localizedChoice({ + kind: "choice", + step: "quiet_hours", + question: "?", + options: [{ id: "22-07", letter: "A", label: "22:00 – 7:00" }], + }); + expect(quiet.options[0]?.label).toBe("22:00 – 7:00"); + }); + + it("recognizes another bot's focus card and leaves other cards alone", () => { + const focus: Choice = { + kind: "choice", + question: "server question", + options: [ + { id: "day", letter: "A", label: "x" }, + { id: "crew", letter: "D", label: "y" }, + ], + }; + expect(localizedChoice(focus).question).toBe("What do you want me on first?"); + expect(localizedChoice(focus).options.map((option) => option.label)).toEqual([ + "Day-to-day work", + "Specialist crew", + ]); + const other: Choice = { + kind: "choice", + question: "Pick one", + options: [{ id: "a", letter: "A", label: "Apples" }], + }; + expect(localizedChoice(other)).toBe(other); + }); +}); + +describe("intakeNoteText", () => { + const now = new Date(2026, 9, 7, 10, 0); + const note = (nextCheckInAt: Date | null): Note => ({ + kind: "intake_note", + note: "done", + nextCheckInAt: nextCheckInAt?.toISOString() ?? null, + text: "server text", + }); + + it("names the first check-in relative to today, in the user's clock", () => { + expect(plain(intakeNoteText(note(new Date(2026, 9, 7, 18, 0)), now))).toBe( + "All set. I’ll check in today at 6:00 PM.", + ); + expect(plain(intakeNoteText(note(new Date(2026, 9, 8, 8, 0)), now))).toBe( + "All set. I’ll check in tomorrow at 8:00 AM.", + ); + expect(plain(intakeNoteText(note(new Date(2026, 9, 11, 18, 0)), now))).toBe( + "All set. I’ll check in on Sunday at 6:00 PM.", + ); + }); + + it("has a line for no check-in and falls back to the server text when unreadable", () => { + expect(intakeNoteText(note(null), now)).toBe("All set. I’m here whenever you need me."); + expect(intakeNoteText({ ...note(null), nextCheckInAt: "soon" }, now)).toBe("server text"); + }); +}); + +describe("withIntakeNoteText", () => { + const message = (blocks: MessageBlock[]) => + ({ id: "m", role: "bot", blocks, createdAt: "2026-10-07T08:00:00.000Z" }) as ThreadMessage; + + it("turns notes into text and leaves other messages untouched", () => { + const plainMessage = message([{ kind: "text", text: "hi" }]); + expect(withIntakeNoteText(plainMessage)).toBe(plainMessage); + const withNote = message([ + { kind: "intake_note", note: "done", nextCheckInAt: null, text: "server text" }, + ]); + expect(withIntakeNoteText(withNote).blocks).toEqual([ + { kind: "text", text: "All set. I’m here whenever you need me." }, + ]); + }); +}); diff --git a/apps/web/src/lib/onboarding-copy.ts b/apps/web/src/lib/onboarding-copy.ts new file mode 100644 index 0000000..1eb077d --- /dev/null +++ b/apps/web/src/lib/onboarding-copy.ts @@ -0,0 +1,107 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg, t } from "@lingui/core/macro"; +import type { MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts"; +import { i18n } from "./i18n"; + +/** + * The onboarding cards in the user's language. The server stores each card with its step and + * option ids plus English text; the web says what it knows from the ids and keeps the server + * text for anything new (mobile and agent history always use the server text). + */ + +type ChoiceBlock = Extract; +type IntakeNote = Extract; + +const QUESTIONS: Record = { + focus: msg`What do you want me on first?`, + goal: msg`What’s one thing you want to get done in the next few weeks?`, + rhythm: msg`When should I check in with you?`, + quiet_hours: msg`When should I stay quiet?`, + notifications: msg`Can I send you notifications?`, +}; + +const SKIP = msg`Skip`; +const NOT_NOW = msg`Not now`; + +// Quiet-hour ranges are left out: the server already writes them in the user's locale. +const OPTIONS: Record> = { + focus: { + day: msg`Day-to-day work`, + inbox: msg`Inbox & email`, + research: msg`Research & writing`, + crew: msg`Specialist crew`, + skip: SKIP, + }, + goal: { skip: SKIP }, + rhythm: { + morning: msg`Every morning`, + morning_evening: msg`Morning and evening`, + weekly: msg`Once a week`, + none: NOT_NOW, + }, + quiet_hours: { skip: SKIP }, + notifications: { on: msg`Turn on`, later: NOT_NOW }, +}; + +const PLACEHOLDERS: Partial> = { + goal: msg`Your goal`, +}; + +/** Another bot's focus card carries no step; its option ids give it away. */ +function cardStep(card: ChoiceBlock): OnboardingStep | undefined { + if (card.step) return card.step; + const focus = OPTIONS.focus; + return card.options.length > 0 && card.options.every((option) => focus[option.id]) + ? "focus" + : undefined; +} + +/** The card's question, options and placeholder in the user's language. */ +export function localizedChoice(card: ChoiceBlock): ChoiceBlock { + const step = cardStep(card); + if (!step) return card; + const options = OPTIONS[step]; + const placeholder = PLACEHOLDERS[step]; + return { + ...card, + question: i18n._(QUESTIONS[step]), + options: card.options.map((option) => { + const label = options[option.id]; + return label ? { ...option, label: i18n._(label) } : option; + }), + ...(card.textInput && placeholder ? { textInput: { placeholder: i18n._(placeholder) } } : {}), + }; +} + +function sameDay(left: Date, right: Date): boolean { + return left.toDateString() === right.toDateString(); +} + +/** The closing note in the user's language, with the first check-in in their time zone. */ +export function intakeNoteText(note: IntakeNote, now = new Date()): string { + if (note.note !== "done") return note.text; + if (!note.nextCheckInAt) return t`All set. I’m here whenever you need me.`; + const at = new Date(note.nextCheckInAt); + if (!Number.isFinite(at.getTime())) return note.text; + const time = new Intl.DateTimeFormat(i18n.locale || "en", { + hour: "numeric", + minute: "2-digit", + }).format(at); + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); + if (sameDay(at, now)) return t`All set. I’ll check in today at ${time}.`; + if (sameDay(at, tomorrow)) return t`All set. I’ll check in tomorrow at ${time}.`; + const weekday = new Intl.DateTimeFormat(i18n.locale || "en", { weekday: "long" }).format(at); + return t`All set. I’ll check in on ${weekday} at ${time}.`; +} + +/** The message with onboarding notes as plain text, so bubbles, copy and speech read the same. */ +export function withIntakeNoteText(message: ThreadMessage): ThreadMessage { + if (!message.blocks.some((block) => block.kind === "intake_note")) return message; + return { + ...message, + blocks: message.blocks.map((block) => + block.kind === "intake_note" ? { kind: "text", text: intakeNoteText(block) } : block, + ), + }; +} diff --git a/apps/web/src/lib/routine-name.test.ts b/apps/web/src/lib/routine-name.test.ts new file mode 100644 index 0000000..b7a4d1f --- /dev/null +++ b/apps/web/src/lib/routine-name.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { routineName, routineSaveName } from "./routine-name.js"; + +const labels = vi.hoisted(() => ({ locale: "en" })); +vi.mock("@lingui/core/macro", () => ({ + // Stands in for a German catalog when the test switches the locale. + t: (parts: TemplateStringsArray) => + labels.locale === "de" && parts.join("") === "Evening check-in" + ? "Abend-Check-in" + : parts.join(""), +})); + +describe("check-in routine names", () => { + it("shows a check-in under its own label until the user renames it", () => { + labels.locale = "de"; + expect(routineName({ name: "Evening check-in", coachKey: "evening" })).toBe("Abend-Check-in"); + expect(routineName({ name: "Wind down", coachKey: "evening" })).toBe("Wind down"); + expect(routineName({ name: "Evening check-in", coachKey: null })).toBe("Evening check-in"); + }); + + it("saves the stored name when the shown one was left as it is", () => { + labels.locale = "de"; + const routine = { name: "Evening check-in", coachKey: "evening" as const }; + expect(routineSaveName(routine, "Abend-Check-in")).toBe("Evening check-in"); + expect(routineSaveName(routine, "Wind down")).toBe("Wind down"); + expect(routineSaveName(null, "New")).toBe("New"); + }); +}); diff --git a/apps/web/src/lib/routine-name.ts b/apps/web/src/lib/routine-name.ts new file mode 100644 index 0000000..434b1c9 --- /dev/null +++ b/apps/web/src/lib/routine-name.ts @@ -0,0 +1,27 @@ +import { t } from "@lingui/core/macro"; +import type { CoachRoutineKey, Routine } from "@rakazo/contracts"; +import { COACH_ROUTINE_NAMES } from "@rakazo/contracts"; + +const COACH_ROUTINE_LABELS: Record string> = { + morning: () => t`Morning brief`, + evening: () => t`Evening check-in`, + weekly: () => t`Weekly review`, +}; + +/** + * A check-in routine is stored under its English name; the user sees it in their language + * until they rename it. + */ +export function routineName(routine: Pick): string { + return routine.coachKey && routine.name === COACH_ROUTINE_NAMES[routine.coachKey] + ? COACH_ROUTINE_LABELS[routine.coachKey]() + : routine.name; +} + +/** The name to save: a check-in name the user left as shown keeps its stored form. */ +export function routineSaveName( + routine: Pick | null, + draftName: string, +): string { + return routine && draftName === routineName(routine) ? routine.name : draftName; +} diff --git a/apps/web/src/pages/RoutineEditor.tsx b/apps/web/src/pages/RoutineEditor.tsx index 5f55284..b79f2db 100644 --- a/apps/web/src/pages/RoutineEditor.tsx +++ b/apps/web/src/pages/RoutineEditor.tsx @@ -24,6 +24,7 @@ import { } from "@rakazo/ui-web"; import { ChevronLeft, Clock, GitBranch, Globe, MessageSquare, Pause, Plus, X } from "lucide-react"; import { useId } from "react"; +import { routineName } from "../lib/routine-name"; import { RoutineSchedule } from "./RoutineSchedule"; function toDatetimeLocalValue(date: Date): string { @@ -85,7 +86,7 @@ export function emptyRoutineDraft(): RoutineDraftState { export function draftFromRoutine(routine: Routine): RoutineDraftState { return { - name: routine.name, + name: routineName(routine), prompt: routine.prompt, schedules: routine.crons.map(presetFromCron), webhookEnabled: routine.webhookEnabled, @@ -156,7 +157,7 @@ export function RoutineListRow({ - {routine.name} + {routineName(routine)} {routineTriggerSummary(routine)} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 4656244..40e9931 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -207,6 +207,7 @@ import { lazyWithRecovery } from "../lib/lazy-recovery"; import { localTimezone } from "../lib/local-timezone"; import { copyableMessageText } from "../lib/message-text"; import { messageProviderLabel } from "../lib/messaging"; +import { withIntakeNoteText } from "../lib/onboarding-copy"; import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire"; import { ooSound } from "../lib/oo-sound"; import { ooVocals } from "../lib/oo-vocals"; @@ -220,6 +221,7 @@ import { markAfterPaint, markOnce } from "../lib/performance"; import { isEmptyProjectSpace } from "../lib/project-empty-state"; import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order"; import { quoteDraftForSelection } from "../lib/quote-selection"; +import { routineSaveName } from "../lib/routine-name"; import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc"; import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage"; import { sharedInflight } from "../lib/shared-inflight"; @@ -5140,7 +5142,7 @@ export function ShellPage() { } saved = await rpc.routines.update({ routineId: targetRoutine.id, - name: routineDraft.name || t`Routine`, + name: routineSaveName(targetRoutine, routineDraft.name) || t`Routine`, prompt: routineDraft.prompt || t`Check in.`, crons, active: armOneShot ? true : routineDraft.active, @@ -5891,7 +5893,11 @@ const Transcript = memo(function Transcript({ () => new Map(messages.map((message) => [message.id, message])), [messages], ); - const reactionView = useMemo(() => projectMessageReactions(messages), [messages]); + const reactionView = useMemo( + () => projectMessageReactions(messages.map(withIntakeNoteText)), + // Onboarding notes are worded in the UI language. + [messages, i18n.locale], + ); const transcriptSubordinate = encounterMode && voiceEncounterActive; const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined; const workingLabel = diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index 5c12492..8cbda50 100644 --- a/apps/web/src/pages/shell/message-cards.tsx +++ b/apps/web/src/pages/shell/message-cards.tsx @@ -19,6 +19,7 @@ import { BuiCard, SuccessPop } from "../../components/ai/primitives"; import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open"; import { chartViewport } from "../../lib/chart-viewport"; import { connectMcpOauth } from "../../lib/mcp-connect"; +import { localizedChoice } from "../../lib/onboarding-copy"; import { rpc } from "../../lib/rpc"; import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push"; @@ -45,8 +46,10 @@ export function ChoiceCard({ const [locallyDismissed, setLocallyDismissed] = useState(false); const dismissed = locallyDismissed || block.answerId === "_dismissed"; const answered = Boolean(block.answerId); - const answers = block.options.filter((option) => option.letter); - const waysOut = block.options.filter((option) => !option.letter); + // What the card says, in the user's language; answers still go out by step and option id. + const card = localizedChoice(block); + const answers = card.options.filter((option) => option.letter); + const waysOut = card.options.filter((option) => !option.letter); const testId = (option: ChoiceOption) => `${block.step ?? "focus"}-choice-${option.id}`; async function choose(optionId: string, words?: string) { @@ -90,7 +93,7 @@ export function ChoiceCard({ const chosen = block.answerText ? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: block.answerText } - : block.options.find((option) => option.id === block.answerId); + : card.options.find((option) => option.id === block.answerId); return (
    @@ -112,7 +115,7 @@ export function ChoiceCard({ ) : null}
    - {block.question} + {card.question}
    {block.subtitle ? (
    {block.subtitle}
    @@ -150,7 +153,7 @@ export function ChoiceCard({ ))}
    ) : null} - {block.textInput ? ( + {card.textInput ? (
    { @@ -163,8 +166,8 @@ export function ChoiceCard({ setText(event.target.value)} - placeholder={block.textInput.placeholder} - aria-label={block.question} + placeholder={card.textInput.placeholder} + aria-label={card.question} maxLength={200} disabled={pending} dir="auto" diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index 6606092..06ae758 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -1,13 +1,19 @@ import type { JobPublisher } from "@rakazo/adapter-kit"; import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; -import type { CoachRhythm } from "@rakazo/contracts"; -import { COACH_RHYTHM_OFF, clockTimeToMinutes, minutesToClockTime } from "@rakazo/contracts"; +import type { CoachRhythm, CoachRoutineKey } from "@rakazo/contracts"; +import { + COACH_RHYTHM_OFF, + COACH_ROUTINE_KEYS, + COACH_ROUTINE_NAMES, + clockTimeToMinutes, + minutesToClockTime, +} from "@rakazo/contracts"; import { nextCronDate } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db"; -export const COACH_KEYS = ["morning", "evening", "weekly"] as const; -export type CoachKey = (typeof COACH_KEYS)[number]; +export const COACH_KEYS = COACH_ROUTINE_KEYS; +export type CoachKey = CoachRoutineKey; /** * The main bot's check-ins. Names show in the main bot's routine list; prompts are its task when @@ -16,17 +22,17 @@ export type CoachKey = (typeof COACH_KEYS)[number]; */ export const COACH_ROUTINES: Record = { morning: { - name: "Morning brief", + name: COACH_ROUTINE_NAMES.morning, prompt: "Morning check-in. Review the user's active goals and habits, and today's calendar if a calendar app is connected. Write today's brief: two to five short lines on what matters most today, and save it. Then post one short, friendly message with the gist. Leave out what is not relevant today.", }, evening: { - name: "Evening check-in", + name: COACH_ROUTINE_NAMES.evening, prompt: "Evening check-in. Log as check-ins what the user already told you about today. Then ask in one or two sentences how the day went and about habits not yet checked off. Keep it light, and do not ask when everything is already logged.", }, weekly: { - name: "Weekly review", + name: COACH_ROUTINE_NAMES.weekly, prompt: "Weekly review. Look at the past week's check-ins and the progress on the user's active goals. Post a short summary: what moved, what stalled, and one concrete suggestion for next week. Offer to adjust, complete or drop goals, and change them only when the user agrees.", }, diff --git a/packages/contracts/src/coach.ts b/packages/contracts/src/coach.ts index 17f777e..d2d5fdd 100644 --- a/packages/contracts/src/coach.ts +++ b/packages/contracts/src/coach.ts @@ -51,6 +51,18 @@ export type CoachRhythm = z.infer; export const COACH_RHYTHM_OFF: CoachRhythm = { morning: null, evening: null, weekly: null }; +/** The main bot's check-in routines, one per key; a routine carries its key as `coachKey`. */ +export const COACH_ROUTINE_KEYS = ["morning", "evening", "weekly"] as const; +export const CoachRoutineKeySchema = z.enum(COACH_ROUTINE_KEYS); +export type CoachRoutineKey = z.infer; + +/** Stored names of the check-in routines; the web shows them in the user's language. */ +export const COACH_ROUTINE_NAMES: Record = { + morning: "Morning brief", + evening: "Evening check-in", + weekly: "Weekly review", +}; + export const GoalSchema = z.object({ id: Id, title: z.string(), diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 18b4bcc..fe532b8 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -1,4 +1,5 @@ import * as z from "zod"; +import { CoachRoutineKeySchema } from "./coach.js"; import { DelegatedTaskStatusSchema, ThreadMessageSchema } from "./events.js"; import { Id, MemoryScope, RunStatus, SandboxKind } from "./ids.js"; import { McpHeadersSchema, McpRemoteEndpointSchema, McpTransportSchema } from "./mcp.js"; @@ -458,6 +459,8 @@ export const RoutineSchema = z.object({ lastRunAt: z.string().nullable(), nextRunAt: z.string().nullable(), createdAt: z.string(), + /** Set on the main bot's check-in routines. */ + coachKey: CoachRoutineKeySchema.nullable(), }); export type Routine = z.infer; diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 98babd6..9b27c13 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -159,6 +159,17 @@ export const MessageBlock = z.discriminatedUnion("kind", [ /** The user's words when they answered through the text input. */ answerText: z.string().optional(), }), + z.object({ + /** + * A line onboarding writes. Clients that know `note` say it in the user's language and + * write the time where they are; everything else (mobile, agent history) uses `text`. + */ + kind: z.literal("intake_note"), + /** "done": onboarding is complete; the first check-in follows at `nextCheckInAt`. */ + note: z.enum(["done"]), + nextCheckInAt: z.string().nullable(), + text: z.string(), + }), z.object({ /** Inline app authorization card (Composio-backed): logo, name, one-line description, and an Authorize button that flips to connected. */ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 960b36f..289eea4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,9 +2,9 @@ import { eventIterator, oc } from "@orpc/contract"; import * as z from "zod"; import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js"; -import { COACH_TITLE_MAX_LENGTH } from "./coach.js"; import { CheckInSchema, + COACH_TITLE_MAX_LENGTH, CoachRhythmSchema, CoachTodaySchema, CreateGoalInput, diff --git a/packages/core/src/attachments.test.ts b/packages/core/src/attachments.test.ts index 45653a0..cd17d8f 100644 --- a/packages/core/src/attachments.test.ts +++ b/packages/core/src/attachments.test.ts @@ -132,4 +132,17 @@ describe("choice card history", () => { "When should I check in with you?", ); }); + + it("reads the onboarding closing note as its English line", () => { + expect( + blocksToAgentHistoryText([ + { + kind: "intake_note", + note: "done", + nextCheckInAt: "2026-10-08T06:00:00.000Z", + text: "All set. I’ll check in tomorrow at 8:00 AM.", + }, + ]), + ).toBe("All set. I’ll check in tomorrow at 8:00 AM."); + }); }); diff --git a/packages/testkit/src/onboarding-intake.postgres.test.ts b/packages/testkit/src/onboarding-intake.postgres.test.ts index 57aceea..b65f6d2 100644 --- a/packages/testkit/src/onboarding-intake.postgres.test.ts +++ b/packages/testkit/src/onboarding-intake.postgres.test.ts @@ -104,6 +104,8 @@ describeWithDatabase("onboarding questions in the main conversation", () => { ]); const afterFocus = await messages(ada.threadId); expect(afterFocus.filter((block) => block.kind === "app_connect").length).toBeGreaterThan(0); + // The cards speak for themselves: onboarding writes no text of its own. + expect(afterFocus.filter((block) => block.kind === "text")).toEqual([]); // The app suggestions come before the next question. expect(afterFocus.at(-1)).toMatchObject({ kind: "choice", step: "goal" }); @@ -152,10 +154,22 @@ describeWithDatabase("onboarding questions in the main conversation", () => { completedAt: expect.any(Date), }); expect(await pending(ada.threadId)).toEqual([]); + // The closing note carries the first check-in for the web and an English line for others. const closing = (await messages(ada.threadId)).at(-1); - expect(closing?.kind === "text" && closing.text.replace(/\s/g, " ")).toMatch( + expect(closing).toMatchObject({ kind: "intake_note", note: "done" }); + if (closing?.kind !== "intake_note") throw new Error("no closing note"); + expect(closing.text.replace(/\s/g, " ")).toMatch( /^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/, ); + const firstCheckIn = new Date(closing.nextCheckInAt ?? ""); + expect(firstCheckIn.getTime()).toBeGreaterThan(Date.now()); + expect( + new Intl.DateTimeFormat("en-GB", { + timeZone: "Europe/Vienna", + hour: "2-digit", + minute: "2-digit", + }).format(firstCheckIn), + ).toBe("08:00"); expect( (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) .onboardingOpen, @@ -193,7 +207,9 @@ describeWithDatabase("onboarding questions in the main conversation", () => { }); expect((await getNotificationSettings(handles.prisma, bo.userId)).quietHours).toBeNull(); expect((await messages(bo.threadId)).at(-1)).toEqual({ - kind: "text", + kind: "intake_note", + note: "done", + nextCheckInAt: null, text: "All set. I’m here whenever you need me.", }); From 27f02c05afa198179ba3951a7787d13661693f05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:53:03 +0000 Subject: [PATCH 24/38] style: use top-level type imports in the coach changes AGENTS.md asks for top-level `import type` instead of inline `type` specifiers; this applies it to the import statements the coach work added or changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/onboarding-intake.ts | 9 ++------- apps/api/src/onboarding.ts | 8 +++----- apps/web/e2e/notification-settings.spec.ts | 3 ++- apps/web/e2e/onboarding-discovery.spec.ts | 3 ++- apps/web/e2e/onboarding-intake.spec.ts | 3 ++- packages/adapters/src/executor.ts | 5 +---- packages/adapters/src/notify-user.postgres.test.ts | 7 ++----- packages/adapters/src/push-notifications.test.ts | 8 ++------ packages/adapters/src/web-push.ts | 3 ++- 9 files changed, 18 insertions(+), 31 deletions(-) diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts index d67381a..1ed62a6 100644 --- a/apps/api/src/onboarding-intake.ts +++ b/apps/api/src/onboarding-intake.ts @@ -3,13 +3,8 @@ import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters"; import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts"; import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { knownTimeZone, nextCronDateAcross } from "@rakazo/core"; -import { - createGoal, - type Prisma, - type PrismaClient, - updateNotificationSettings, - updateUserProfile, -} from "@rakazo/db"; +import type { Prisma, PrismaClient } from "@rakazo/db"; +import { createGoal, updateNotificationSettings, updateUserProfile } from "@rakazo/db"; /** * The questions the main bot asks a new user in the main conversation, one card at a time. diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index 868acb4..123ae0f 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -1,6 +1,7 @@ import { ORPCError } from "@orpc/server"; import type { JobPublisher } from "@rakazo/adapter-kit"; -import { type ConnectorRegistry, getCoachRhythm } from "@rakazo/adapters"; +import type { ConnectorRegistry } from "@rakazo/adapters"; +import { getCoachRhythm } from "@rakazo/adapters"; import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts"; import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { featuredConnectorProvidersMatch } from "@rakazo/core"; @@ -13,14 +14,11 @@ import { type PrismaClient, type ThreadEvents, } from "@rakazo/db"; +import type { ChoiceBlock, FocusOption, IntakeAnswer, IntakeContext } from "./onboarding-intake.js"; import { - type ChoiceBlock, closingNote, FOCUS_OPTIONS, - type FocusOption, focusCard, - type IntakeAnswer, - type IntakeContext, intakeContext, intakeStep, nextIntakeStep, diff --git a/apps/web/e2e/notification-settings.spec.ts b/apps/web/e2e/notification-settings.spec.ts index 27e1571..b21a36c 100644 --- a/apps/web/e2e/notification-settings.spec.ts +++ b/apps/web/e2e/notification-settings.spec.ts @@ -3,7 +3,8 @@ * Chromium has no push service), sets quiet hours and switches nudges off; all of it survives a * reload. */ -import { expect, type Page, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; const ENDPOINT = "https://push.example.test/e2e-device"; diff --git a/apps/web/e2e/onboarding-discovery.spec.ts b/apps/web/e2e/onboarding-discovery.spec.ts index 0f639bc..d953085 100644 --- a/apps/web/e2e/onboarding-discovery.spec.ts +++ b/apps/web/e2e/onboarding-discovery.spec.ts @@ -2,7 +2,8 @@ * E2E: the deployment owner's onboarding asks before scanning the fixture home folder the * harness configures, then adds only the projects and facts the user picks. */ -import { expect, type Page, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts"; import { captureScreenshot, rpc, signup } from "./helpers"; diff --git a/apps/web/e2e/onboarding-intake.spec.ts b/apps/web/e2e/onboarding-intake.spec.ts index fdcac51..ae8967a 100644 --- a/apps/web/e2e/onboarding-intake.spec.ts +++ b/apps/web/e2e/onboarding-intake.spec.ts @@ -2,7 +2,8 @@ * E2E: after the wizard the main bot gets to know the user in the main conversation, one card * after each answer, without ever blocking the composer. */ -import { expect, type Page, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; function card(page: Page, step: string) { diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 0dadc70..ebc5905 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -85,6 +85,7 @@ import { stableJsonValue, toolEffectIdempotencyKey, } from "@rakazo/core/node/approval-effect-key"; +import type { McpServer, Prisma, PrismaClient, ThreadEvents } from "@rakazo/db"; import { appendEventInTransaction, createSpaceForMember, @@ -99,12 +100,8 @@ import { loadCoachSummary, loadRunHistoryMessages, loadUserContext, - type McpServer, - type Prisma, - type PrismaClient, parseComputerMode, SpaceLimitError, - type ThreadEvents, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { diff --git a/packages/adapters/src/notify-user.postgres.test.ts b/packages/adapters/src/notify-user.postgres.test.ts index e5e63d2..632fc8d 100644 --- a/packages/adapters/src/notify-user.postgres.test.ts +++ b/packages/adapters/src/notify-user.postgres.test.ts @@ -9,11 +9,8 @@ import { } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { notifyUserFromTool } from "./notify-user-tool.js"; -import { - PushNotificationProvider, - type PushPayload, - type PushSender, -} from "./push-notifications.js"; +import type { PushPayload, PushSender } from "./push-notifications.js"; +import { PushNotificationProvider } from "./push-notifications.js"; const databaseUrl = process.env.DATABASE_URL; const describePostgres = diff --git a/packages/adapters/src/push-notifications.test.ts b/packages/adapters/src/push-notifications.test.ts index e457023..b368799 100644 --- a/packages/adapters/src/push-notifications.test.ts +++ b/packages/adapters/src/push-notifications.test.ts @@ -1,12 +1,8 @@ import type { NotificationMessage } from "@rakazo/adapter-kit"; import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; -import { - PushNotificationProvider, - type PushOutcome, - type PushPayload, - type PushSender, -} from "./push-notifications.js"; +import type { PushOutcome, PushPayload, PushSender } from "./push-notifications.js"; +import { PushNotificationProvider } from "./push-notifications.js"; const context = { operationId: "notify", diff --git a/packages/adapters/src/web-push.ts b/packages/adapters/src/web-push.ts index e851d9e..9381523 100644 --- a/packages/adapters/src/web-push.ts +++ b/packages/adapters/src/web-push.ts @@ -4,7 +4,8 @@ import { getLogger } from "@rakazo/logging"; import { Agent } from "undici"; import webPush from "web-push"; import { combineSignals } from "./connector-safety.js"; -import { createAddressCheckedLookup, type ResolveHostname } from "./network-address.js"; +import type { ResolveHostname } from "./network-address.js"; +import { createAddressCheckedLookup } from "./network-address.js"; import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js"; import type { EncryptedSecretStore } from "./secrets.js"; import { dispatcherFetch } from "./undici-fetch.js"; From 7a32100b535bcd306c580ad598e7f35c6061e31b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:55:01 +0000 Subject: [PATCH 25/38] chore(web): extract catalogs and translate the coach strings to German Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/src/locales/de/messages.po | 361 +++++++++++++------------ apps/web/src/locales/en/messages.po | 361 +++++++++++++------------ apps/web/src/locales/es/messages.po | 357 ++++++++++++------------ apps/web/src/locales/hi/messages.po | 357 ++++++++++++------------ apps/web/src/locales/ko/messages.po | 357 ++++++++++++------------ apps/web/src/locales/pt-BR/messages.po | 357 ++++++++++++------------ apps/web/src/locales/ru/messages.po | 357 ++++++++++++------------ apps/web/src/locales/tr/messages.po | 357 ++++++++++++------------ apps/web/src/locales/zh-CN/messages.po | 357 ++++++++++++------------ 9 files changed, 1723 insertions(+), 1498 deletions(-) diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index a697199..c5c998d 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -21,6 +21,11 @@ msgstr " (ungelesen)" msgid "(optional)" msgstr "(optional)" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "{0, plural, one {# Tag} other {# Tage}}" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# Modell} other {# Modelle}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "{0, plural, one {# weiteres wartet} other {# weitere warten}}" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "{0, plural, one {# Woche} other {# Wochen}}" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -46,11 +56,6 @@ msgstr "{0} (über 10 MiB)" msgid "{0} {unitLabel}" msgstr "{0} {unitLabel}" -#. placeholder {0}: subtitle.count -#: src/components/TradingModeIndicator.tsx -msgid "{0} adapters" -msgstr "{0} Adapter" - #. placeholder {0}: block.name #: src/pages/shell/message-cards.tsx msgid "{0} connection" @@ -273,22 +278,6 @@ msgstr "{workingBotName} arbeitet" msgid "/Users/you/projects/acme" msgstr "/Users/you/projects/acme" -#: src/pages/Shell.tsx -msgid "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" -msgstr "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" - -#: src/pages/Shell.tsx -msgid "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" -msgstr "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" - -#: src/pages/Shell.tsx -msgid "📈 Scanne den Markt nach aktuellen Alpha-Signalen" -msgstr "📈 Scanne den Markt nach aktuellen Alpha-Signalen" - -#: src/components/TradingModeIndicator.tsx -msgid "1 adapter" -msgstr "1 Adapter" - #: src/components/stage/OoStage.tsx msgid "1:1 conversation" msgstr "1:1-Unterhaltung" @@ -297,10 +286,6 @@ msgstr "1:1-Unterhaltung" msgid "1:1 conversation with {title}" msgstr "1:1-Unterhaltung mit {title}" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "Ein paar kurze Vorlieben, damit dein Oo besser zu dir passt." - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "Über Gruppen" @@ -366,6 +351,7 @@ msgstr "Aktives Modell" msgid "Activity" msgstr "Aktivität" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -403,6 +389,10 @@ msgstr "Gib eine HTTPS-Server-URL ein." msgid "Add another" msgstr "Weiteres hinzufügen" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "Als Projekte hinzufügen" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Executor hinzufügen" @@ -444,6 +434,10 @@ msgstr "Server hinzufügen" msgid "Add server URL" msgstr "Server-URL hinzufügen" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "Zu „Über dich“ hinzufügen" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Zur Routine hinzufügen" @@ -506,6 +500,26 @@ msgstr "Alle Daten lokal gespeichert — keine externen Server" msgid "All quiet" msgstr "Alles ruhig" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "Alles klar. Ich melde mich am {weekday} um {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "Alles klar. Ich melde mich heute um {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "Alles klar. Ich melde mich morgen um {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "Alles klar. Ich bin da, wann immer du mich brauchst." + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "Alle Spaces" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -541,10 +555,6 @@ msgstr "Schreiben erlauben" msgid "Allowed once" msgstr "Einmal erlaubt" -#: src/pages/Shell.tsx -msgid "Alpha-Signale" -msgstr "Alpha-Signale" - #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "Du hast bereits ein Konto?" @@ -616,10 +626,6 @@ msgstr "Genehmigen" msgid "Approve this server to let your agent use its tools." msgstr "Genehmige diesen Server, damit dein Agent seine Tools verwenden kann." -#: src/pages/Shell.tsx -msgid "Architektur-Audit" -msgstr "Architektur-Audit" - #: src/pages/BotContextMenu.tsx msgid "Archive" msgstr "Archivieren" @@ -720,10 +726,6 @@ msgstr "Autorisieren" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Autorisiere diesen Server, damit Agents seine Tools nutzen können. Ein Pop-up öffnet sich." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "Automatisch erkannt oder manuell eingeben" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "Automatische Absender" @@ -759,10 +761,6 @@ msgstr "Zurück zur Anmeldung" msgid "Back to timeline" msgstr "Zurück zur Zeitleiste" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "Ausgewogen" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Basis-URL" @@ -772,6 +770,10 @@ msgstr "Basis-URL" msgid "Bearer token" msgstr "Bearer-Token" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "In deinen Browser-Einstellungen blockiert" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Live-Desktop wird gestartet…" @@ -840,6 +842,10 @@ msgstr "Ruhige Hinweistöne für erledigte Arbeit, Sprachphasen und Übergaben. msgid "Calm helper" msgstr "Ruhiger Helfer" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "Darf ich dir Benachrichtigungen schicken?" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "Kann sprechen" @@ -852,6 +858,7 @@ msgstr "Server nicht erreichbar." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -886,9 +893,9 @@ msgstr "Abgebrochen" msgid "Cancelled" msgstr "Abgebrochen" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" -msgstr "Locker & freundlich" +#: src/components/UserFactEditor.tsx +msgid "Category" +msgstr "Kategorie" #: src/pages/AccountSettingsOverlay.tsx msgid "Change password" @@ -944,6 +951,10 @@ msgstr "Melde dich." msgid "Check your email" msgstr "Prüfe deine E-Mails" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "Check-ins" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "Prüfe Abhängigkeiten…" @@ -1151,7 +1162,6 @@ msgid "Command" msgstr "Befehl" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "Kommunikationsstil" @@ -1312,8 +1322,8 @@ msgstr "Kontextlimit" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Weiter" @@ -1539,6 +1549,10 @@ msgstr "Update-Status konnte nicht geladen werden" msgid "Could not load voice settings" msgstr "Stimmeinstellungen konnten nicht geladen werden" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "In diesem Ordner konnte nicht gesucht werden." + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "Lokale Einstellungen konnten nicht geöffnet werden. Starte den lokalen Server, erstelle sein Besitzerkonto und versuche es dann erneut." @@ -1691,6 +1705,7 @@ msgstr "Standardbereich für Erinnerungen konnte nicht aktualisiert werden" msgid "Could not update voice" msgstr "Stimme konnte nicht aktualisiert werden" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "Konnte nicht geladen werden" @@ -1699,6 +1714,7 @@ msgstr "Konnte nicht geladen werden" msgid "Couldn't load messaging settings" msgstr "Messaging-Einstellungen konnten nicht geladen werden" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "Konnte nicht gespeichert werden" @@ -1840,6 +1856,10 @@ msgstr "Datenbank" msgid "day" msgstr "Tag" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "Tagesgeschäft" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "Tage" @@ -1848,10 +1868,6 @@ msgstr "Tage" msgid "Decline" msgstr "Ablehnen" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "Fokussiert in die Tiefe" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Standard" @@ -1952,18 +1968,10 @@ msgstr "Desktop-App" msgid "Desktop update" msgstr "Desktop-Update" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "Ausführlich & erklärend" - #: src/pages/Shell.tsx msgid "Dev" msgstr "Dev" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "Direkt & knapp" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "Direktes MCP" @@ -2070,10 +2078,6 @@ msgstr "Entwerfe die Spezifikation…" msgid "Drag, drop, or paste an image" msgstr "Bild ziehen, ablegen oder einfügen" -#: src/components/TradingModeIndicator.tsx -msgid "Dry run" -msgstr "Probelauf" - #: src/pages/BotContextMenu.tsx msgid "Duplicate" msgstr "Duplizieren" @@ -2086,9 +2090,9 @@ msgstr "z. B. Hivenet Agent, Presales, Zeiterfassungs-Bot" msgid "Earlier message" msgstr "Frühere Nachricht" -#: src/pages/Shell.tsx -msgid "Echtzeit-Risiko & Drawdown-Analyse" -msgstr "Echtzeit-Risiko & Drawdown-Analyse" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "Bearbeiten" #: src/components/AskCard.tsx msgid "Edit first" @@ -2139,6 +2143,14 @@ msgstr "Gib einen absoluten Ordnerpfad auf dem Server ein. Git-Remotes werden au msgid "Enter this code at <0>{0}" msgstr "Gib diesen Code unter <0>{0} ein" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "Abends" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "Abend-Check-in" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2167,6 +2179,10 @@ msgstr "Jeden Montag" msgid "Every month" msgstr "Jeden Monat" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "Jeden Morgen" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2207,6 +2223,10 @@ msgstr "Externe Unterhaltung" msgid "Extra high" msgstr "Sehr hoch" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "Fakt" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "fehlgeschlagen" @@ -2276,10 +2296,6 @@ msgstr "MCP-Verbindung wird abgeschlossen…" msgid "Flag unexpected actions" msgstr "Unerwartete Aktionen markieren" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "Flexibel & spontan" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "Schriften lokal geladen" @@ -2300,10 +2316,6 @@ msgstr "Alles über dich vergessen?" msgid "Forgot password?" msgstr "Passwort vergessen?" -#: src/pages/Shell.tsx -msgid "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." -msgstr "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." - #: src/pages/ModelSettingsOverlay.tsx msgid "Free" msgstr "Kostenlos" @@ -2344,6 +2356,7 @@ msgstr "Git-Ereignis" msgid "Goal" msgstr "Ziel" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2380,10 +2393,7 @@ msgstr "Updates bündeln" msgid "Groups" msgstr "Gruppen" -#: src/pages/Shell.tsx -msgid "Guten Tag." -msgstr "Guten Tag." - +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "Gewohnheiten" @@ -2458,10 +2468,6 @@ msgstr "Passwort verbergen" msgid "High" msgstr "Hoch" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "Hobbys & Interessen" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "Hobby" @@ -2475,10 +2481,6 @@ msgstr "Stunde" msgid "hours" msgstr "Stunden" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "Wie arbeitest du am liebsten?" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Wie oft" @@ -2487,10 +2489,6 @@ msgstr "Wie oft" msgid "How should your Oo sound when reading replies aloud?" msgstr "Wie soll dein Oo klingen, wenn es Antworten vorliest?" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "Wie soll dein Oo mit dir sprechen?" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "So wird geprüft" @@ -2531,6 +2529,10 @@ msgstr "In meinem Fachgebiet — ich gehe gern ins Detail oder setze einfach um, msgid "In progress" msgstr "In Arbeit" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "Posteingang & E-Mail" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Standard übernehmen" @@ -2582,10 +2584,6 @@ msgstr "Intervalleinheit" msgid "Isolated" msgstr "Isoliert" -#: src/pages/Shell.tsx -msgid "ist bereit." -msgstr "ist bereit." - #: src/pages/shell/dialogs.tsx msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." msgstr "Seine Unterhaltung, Dateien und Routinen werden dauerhaft gelöscht. Von ihm erstellte Bots bleiben in deiner Liste." @@ -2681,10 +2679,6 @@ msgstr "Ich höre zu — was ist kaputt oder langsam?" msgid "Listening — what's the story?" msgstr "Ich höre zu — was ist die Geschichte?" -#: src/components/TradingModeIndicator.tsx -msgid "Live Trading" -msgstr "Live-Trading" - #: src/pages/Shell.tsx msgid "Load earlier messages" msgstr "Frühere Nachrichten laden" @@ -2761,6 +2755,10 @@ msgstr "Einstellungen des lokalen Servers" msgid "Log out" msgstr "Abmelden" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "Suchen" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2771,10 +2769,18 @@ msgstr "Look {0} von {1}" msgid "Look families" msgstr "Look-Familien" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "Hier nach Projekten suchen?" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "Look-Varianten" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "Suche läuft" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2935,10 +2941,6 @@ msgstr "Minute" msgid "minutes" msgstr "Minuten" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "Mission Control" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2995,6 +2997,18 @@ msgstr "Weitere Computeraktionen" msgid "More conversations ({hiddenSessionCount})" msgstr "Mehr Unterhaltungen ({hiddenSessionCount})" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "Morgens" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "Morgens und abends" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "Morgenbriefing" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Verschiebe sie in deine geteilten Erinnerungen." @@ -3006,7 +3020,6 @@ msgstr "In Ordner verschieben" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3185,6 +3198,10 @@ msgstr "Noch keine Nachrichten mit {peerBotName}." msgid "No model catalog is available." msgstr "Kein Modellkatalog verfügbar." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "Keine Projekte gefunden" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "Noch keine Projekte." @@ -3230,7 +3247,6 @@ msgstr "Es werden keine Nutzungsdaten an Dritte gesendet" msgid "None yet" msgstr "Noch keine" -#: src/components/TradingModeIndicator.tsx #: src/pages/PrivacySettingsOverlay.tsx msgid "Not configured" msgstr "Nicht konfiguriert" @@ -3243,6 +3259,7 @@ msgstr "Nicht verbunden" msgid "Not in the plugin catalog" msgstr "Nicht im Plugin-Katalog" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Jetzt nicht" @@ -3255,14 +3272,11 @@ msgstr "Nichts läuft mehr" msgid "Nothing remembered yet" msgstr "Noch nichts gemerkt" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "Nichts Bestimmtes" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "Noch nichts" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Benachrichtigungen" @@ -3276,6 +3290,10 @@ msgstr "Jetzt" msgid "Now using {0}." msgstr "Jetzt wird {0} verwendet." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "Anstöße von deinem Oo" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth-Autorisierung wurde abgebrochen." @@ -3292,10 +3310,6 @@ msgstr "OAuth-Verbindung fehlgeschlagen" msgid "OAuth expired" msgstr "OAuth abgelaufen" -#: src/components/TradingModeIndicator.tsx -msgid "Offline" -msgstr "Offline" - #: src/pages/VoiceSettingsOverlay.tsx msgid "On" msgstr "An" @@ -3313,6 +3327,10 @@ msgstr "am 1. um {0}" msgid "Onboarding" msgstr "Onboarding" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "Einmal pro Woche" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "Noch ein Blick, bevor ich antworte…" @@ -3321,6 +3339,10 @@ msgstr "Noch ein Blick, bevor ich antworte…" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "Nur leere Spaces können gelöscht werden. Lösche zuerst die Bots und Gruppen darin." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "Gelesen werden nur Datei- und Ordnernamen." + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3408,10 +3430,6 @@ msgstr "Andere Stimmanbieter" msgid "PagerDuty incident" msgstr "PagerDuty-Vorfall" -#: src/components/TradingModeIndicator.tsx -msgid "Paper" -msgstr "Paper" - #: src/pages/ScratchpadSection.tsx msgid "Park" msgstr "Zurückstellen" @@ -3492,10 +3510,6 @@ msgstr "Feile an den Details…" msgid "Polling devices…" msgstr "Frage Geräte ab…" -#: src/pages/Shell.tsx -msgid "Portfolio-Check" -msgstr "Portfolio-Check" - #: src/pages/RoutineEditor.tsx msgid "POST to" msgstr "POST an" @@ -3572,6 +3586,10 @@ msgstr "Anbieter" msgid "Pull request" msgstr "Pull Request" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "Push auf diesem Gerät" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3582,14 +3600,22 @@ msgstr "In Warteschlange" msgid "Queued…" msgstr "Wartet…" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "Schnell & pragmatisch" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "Bisher ein ruhiger Tag" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "Ruhezeiten" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "Ende der Ruhezeiten" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "Beginn der Ruhezeiten" + #: src/pages/Shell.tsx msgid "Quote" msgstr "Zitieren" @@ -3809,6 +3835,10 @@ msgstr "Antwort an {replyName}: „{replyQuote}“" msgid "Reporting from home…" msgstr "Melde mich von zu Hause…" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "Recherche & Schreiben" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "Recherche, Schreiben" @@ -3948,6 +3978,7 @@ msgstr "Sandbox" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4005,10 +4036,6 @@ msgstr "Prüfe das Risiko…" msgid "Scene" msgstr "Szene" -#: src/pages/Shell.tsx -msgid "Schlichtheit, Eleganz & Detailtiefe" -msgstr "Schlichtheit, Eleganz & Detailtiefe" - #: src/pages/ModelSettingsOverlay.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/Shell.tsx @@ -4052,15 +4079,12 @@ msgstr "Suche…" msgid "Select {0}" msgstr "{0} auswählen" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "Wähle alles Zutreffende aus" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "Selbst gehostet (better-auth)" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Senden" @@ -4224,7 +4248,9 @@ msgid "Skills" msgstr "Skills" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Überspringen" @@ -4309,6 +4335,10 @@ msgstr "Vorlesen an oder aus" msgid "Speaking with intent…" msgstr "Spreche mit Bedacht…" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "Spezialisten-Team" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "Standard" @@ -4371,10 +4401,6 @@ msgstr "Bleibe in meinem Fachgebiet…" msgid "Steps" msgstr "Schritte" -#: src/pages/Shell.tsx -msgid "Steve Jobs Audit" -msgstr "Steve-Jobs-Audit" - #: src/pages/Shell.tsx msgid "Stop" msgstr "Stoppen" @@ -4409,10 +4435,6 @@ msgstr "Stresstest fürs Portfolio…" msgid "Stronger together — {0} specialists" msgstr "Stärker zusammen — {0} Spezialisten" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "Strukturiert & geplant" - #: src/pages/Shell.tsx msgid "subagent" msgstr "Subagent" @@ -4498,10 +4520,6 @@ msgstr "Teams nicht aktiviert" msgid "Tell me what matters. I'll move." msgstr "Sag mir, was zählt. Ich lege los." -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "Erzähl uns von dir" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Test" @@ -4592,10 +4610,6 @@ msgstr "Uhrzeit" msgid "Time zone" msgstr "Zeitzone" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "Zeitzone" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Titel" @@ -4629,10 +4643,6 @@ msgstr "Tools" msgid "Tracing the path…" msgstr "Verfolge den Pfad…" -#: src/components/TradingModeIndicator.tsx -msgid "Trading" -msgstr "Trading" - #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx msgid "Transcript" @@ -4654,6 +4664,10 @@ msgstr "Triage läuft…" msgid "Try again" msgstr "Erneut versuchen" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "Einschalten" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Gib deine Antwort ein" @@ -4670,10 +4684,6 @@ msgstr "Nicht verfügbar" msgid "Uninstall" msgstr "Deinstallieren" -#: src/components/TradingModeIndicator.tsx -msgid "Unknown" -msgstr "Unbekannt" - #: src/pages/MessagingSettingsOverlay.tsx msgid "Unlink" msgstr "Verknüpfung aufheben" @@ -4814,14 +4824,6 @@ msgstr "Die Stimme ist noch nicht verfügbar. Du kannst sie später in den Einst msgid "Voice set to {0}." msgstr "Stimme auf {0} gesetzt." -#: src/pages/Shell.tsx -msgid "Volatilität & Orderbuch-Liquidität" -msgstr "Volatilität & Orderbuch-Liquidität" - -#: src/pages/Shell.tsx -msgid "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." -msgstr "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." - #: src/pages/ProjectSessionTimeline.tsx msgid "Waiting" msgstr "Wartet" @@ -4859,6 +4861,14 @@ msgstr "Webhook" msgid "Weekdays" msgstr "Wochentage" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "Wöchentlich" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "Wochenrückblick" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "Was soll mit seinen Erinnerungen geschehen?" @@ -4867,6 +4877,10 @@ msgstr "Was soll mit seinen Erinnerungen geschehen?" msgid "What do you think — react or answer in a sentence." msgstr "Was meinst du — reagiere oder antworte in einem Satz." +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "Wobei soll ich dir zuerst helfen?" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "Was passt zu dir?" @@ -4907,6 +4921,10 @@ msgstr "Was zurückgegeben werden soll" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "Was beschäftigt dich? Ich leite es weiter — und sage dir, wenn ich anderer Meinung bin." +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "Was möchtest du in den nächsten Wochen erledigt bekommen?" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "Was ist heute privat dran?" @@ -4919,6 +4937,14 @@ msgstr "Um welche Aufgabe geht es?" msgid "When a webhook fires" msgstr "Wenn ein Webhook auslöst" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "Wann soll ich mich bei dir melden?" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "Wann soll ich still sein?" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Ausführungszeit" @@ -4944,10 +4970,6 @@ msgstr "Arbeit" msgid "Work channel open — go ahead." msgstr "Arbeitskanal offen — leg los." -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "Arbeitsstil" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "Arbeitet" @@ -5004,8 +5026,11 @@ msgstr "Deine Unterhaltungen und Einstellungen verlassen nie deinen Rechner" msgid "Your email address" msgstr "Deine E-Mail-Adresse" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "Dein Ziel" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Dein Name" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index b176cbf..f551b02 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -21,6 +21,11 @@ msgstr " (unread)" msgid "(optional)" msgstr "(optional)" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "{0, plural, one {# day} other {# days}}" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# model} other {# models}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "{0, plural, one {# more waiting} other {# more waiting}}" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "{0, plural, one {# week} other {# weeks}}" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -46,11 +56,6 @@ msgstr "{0} (over 10 MiB)" msgid "{0} {unitLabel}" msgstr "{0} {unitLabel}" -#. placeholder {0}: subtitle.count -#: src/components/TradingModeIndicator.tsx -msgid "{0} adapters" -msgstr "{0} adapters" - #. placeholder {0}: block.name #: src/pages/shell/message-cards.tsx msgid "{0} connection" @@ -273,22 +278,6 @@ msgstr "{workingBotName} is working" msgid "/Users/you/projects/acme" msgstr "/Users/you/projects/acme" -#: src/pages/Shell.tsx -msgid "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" -msgstr "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" - -#: src/pages/Shell.tsx -msgid "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" -msgstr "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" - -#: src/pages/Shell.tsx -msgid "📈 Scanne den Markt nach aktuellen Alpha-Signalen" -msgstr "📈 Scanne den Markt nach aktuellen Alpha-Signalen" - -#: src/components/TradingModeIndicator.tsx -msgid "1 adapter" -msgstr "1 adapter" - #: src/components/stage/OoStage.tsx msgid "1:1 conversation" msgstr "1:1 conversation" @@ -297,10 +286,6 @@ msgstr "1:1 conversation" msgid "1:1 conversation with {title}" msgstr "1:1 conversation with {title}" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "A few quick preferences so your Oo can be better tailored to you." - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "About groups" @@ -366,6 +351,7 @@ msgstr "Active model" msgid "Activity" msgstr "Activity" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -403,6 +389,10 @@ msgstr "Add an HTTPS server URL." msgid "Add another" msgstr "Add another" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "Add as projects" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Add Executor" @@ -444,6 +434,10 @@ msgstr "Add server" msgid "Add server URL" msgstr "Add server URL" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "Add to About you" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Add to routine" @@ -506,6 +500,26 @@ msgstr "All data stored locally — no external servers" msgid "All quiet" msgstr "All quiet" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "All set. I’ll check in on {weekday} at {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "All set. I’ll check in today at {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "All set. I’ll check in tomorrow at {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "All set. I’m here whenever you need me." + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "All spaces" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -541,10 +555,6 @@ msgstr "Allow writing" msgid "Allowed once" msgstr "Allowed once" -#: src/pages/Shell.tsx -msgid "Alpha-Signale" -msgstr "Alpha-Signale" - #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "Already have an account?" @@ -616,10 +626,6 @@ msgstr "Approve" msgid "Approve this server to let your agent use its tools." msgstr "Approve this server to let your agent use its tools." -#: src/pages/Shell.tsx -msgid "Architektur-Audit" -msgstr "Architektur-Audit" - #: src/pages/BotContextMenu.tsx msgid "Archive" msgstr "Archive" @@ -720,10 +726,6 @@ msgstr "Authorize" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Authorize this server so agents can use its tools. A popup opens." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "Auto-detected or enter manually" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "Automated senders" @@ -759,10 +761,6 @@ msgstr "Back to sign in" msgid "Back to timeline" msgstr "Back to timeline" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "Balanced" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Base URL" @@ -772,6 +770,10 @@ msgstr "Base URL" msgid "Bearer token" msgstr "Bearer token" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "Blocked in your browser settings" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Booting live desktop…" @@ -840,6 +842,10 @@ msgstr "Calm audio cues for finished work, voice phases, and handoffs. Respects msgid "Calm helper" msgstr "Calm helper" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "Can I send you notifications?" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "Can speak" @@ -852,6 +858,7 @@ msgstr "Can't reach the server." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -886,9 +893,9 @@ msgstr "Cancelled" msgid "Cancelled" msgstr "Cancelled" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" -msgstr "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" +msgstr "Category" #: src/pages/AccountSettingsOverlay.tsx msgid "Change password" @@ -944,6 +951,10 @@ msgstr "Check in." msgid "Check your email" msgstr "Check your email" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "Check-ins" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "Checking dependencies…" @@ -1151,7 +1162,6 @@ msgid "Command" msgstr "Command" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "Communication style" @@ -1312,8 +1322,8 @@ msgstr "Context limit" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Continue" @@ -1539,6 +1549,10 @@ msgstr "Could not load update status" msgid "Could not load voice settings" msgstr "Could not load voice settings" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "Could not look in this folder." + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "Could not open local settings. Start the local server and create its owner account, then try again." @@ -1691,6 +1705,7 @@ msgstr "Could not update the default memory scope" msgid "Could not update voice" msgstr "Could not update voice" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "Couldn't load" @@ -1699,6 +1714,7 @@ msgstr "Couldn't load" msgid "Couldn't load messaging settings" msgstr "Couldn't load messaging settings" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "Couldn't save" @@ -1840,6 +1856,10 @@ msgstr "Database" msgid "day" msgstr "day" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "Day-to-day work" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "days" @@ -1848,10 +1868,6 @@ msgstr "days" msgid "Decline" msgstr "Decline" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "Deep-dive focused" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Default" @@ -1952,18 +1968,10 @@ msgstr "Desktop app" msgid "Desktop update" msgstr "Desktop update" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "Detailed & explanatory" - #: src/pages/Shell.tsx msgid "Dev" msgstr "Dev" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "Direct & concise" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "Direct MCP" @@ -2070,10 +2078,6 @@ msgstr "Drafting the spec…" msgid "Drag, drop, or paste an image" msgstr "Drag, drop, or paste an image" -#: src/components/TradingModeIndicator.tsx -msgid "Dry run" -msgstr "Dry run" - #: src/pages/BotContextMenu.tsx msgid "Duplicate" msgstr "Duplicate" @@ -2086,9 +2090,9 @@ msgstr "e.g. Hivenet Agent, Presales, Timesheets bot" msgid "Earlier message" msgstr "Earlier message" -#: src/pages/Shell.tsx -msgid "Echtzeit-Risiko & Drawdown-Analyse" -msgstr "Echtzeit-Risiko & Drawdown-Analyse" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "Edit" #: src/components/AskCard.tsx msgid "Edit first" @@ -2139,6 +2143,14 @@ msgstr "Enter an absolute folder path on the server. Git remotes are linked auto msgid "Enter this code at <0>{0}" msgstr "Enter this code at <0>{0}" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "Evening" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "Evening check-in" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2167,6 +2179,10 @@ msgstr "Every Monday" msgid "Every month" msgstr "Every month" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "Every morning" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2207,6 +2223,10 @@ msgstr "External conversation" msgid "Extra high" msgstr "Extra high" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "Fact" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "failed" @@ -2276,10 +2296,6 @@ msgstr "Finishing MCP connection…" msgid "Flag unexpected actions" msgstr "Flag unexpected actions" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "Flexible & spontaneous" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "Fonts loaded locally" @@ -2300,10 +2316,6 @@ msgstr "Forget everything about you?" msgid "Forgot password?" msgstr "Forgot password?" -#: src/pages/Shell.tsx -msgid "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." -msgstr "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." - #: src/pages/ModelSettingsOverlay.tsx msgid "Free" msgstr "Free" @@ -2344,6 +2356,7 @@ msgstr "Git event" msgid "Goal" msgstr "Goal" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2380,10 +2393,7 @@ msgstr "Group updates" msgid "Groups" msgstr "Groups" -#: src/pages/Shell.tsx -msgid "Guten Tag." -msgstr "Guten Tag." - +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "Habits" @@ -2458,10 +2468,6 @@ msgstr "Hide password" msgid "High" msgstr "High" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "Hobbies & interests" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "Hobby" @@ -2475,10 +2481,6 @@ msgstr "hour" msgid "hours" msgstr "hours" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "How do you like to work?" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "How often" @@ -2487,10 +2489,6 @@ msgstr "How often" msgid "How should your Oo sound when reading replies aloud?" msgstr "How should your Oo sound when reading replies aloud?" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "How should your Oo talk to you?" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "How to check" @@ -2531,6 +2529,10 @@ msgstr "In my lane — happy to geek out or just execute, your call." msgid "In progress" msgstr "In progress" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "Inbox & email" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Inherit default" @@ -2582,10 +2584,6 @@ msgstr "Interval unit" msgid "Isolated" msgstr "Isolated" -#: src/pages/Shell.tsx -msgid "ist bereit." -msgstr "ist bereit." - #: src/pages/shell/dialogs.tsx msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." msgstr "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." @@ -2681,10 +2679,6 @@ msgstr "Listening — what's broken or slow?" msgid "Listening — what's the story?" msgstr "Listening — what's the story?" -#: src/components/TradingModeIndicator.tsx -msgid "Live Trading" -msgstr "Live Trading" - #: src/pages/Shell.tsx msgid "Load earlier messages" msgstr "Load earlier messages" @@ -2761,6 +2755,10 @@ msgstr "Local Server Settings" msgid "Log out" msgstr "Log out" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "Look" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2771,10 +2769,18 @@ msgstr "Look {0} of {1}" msgid "Look families" msgstr "Look families" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "Look for projects here?" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "Look variants" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "Looking" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2935,10 +2941,6 @@ msgstr "minute" msgid "minutes" msgstr "minutes" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "Mission Control" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2995,6 +2997,18 @@ msgstr "More computer actions" msgid "More conversations ({hiddenSessionCount})" msgstr "More conversations ({hiddenSessionCount})" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "Morning" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "Morning and evening" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "Morning brief" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Move them to your shared memory." @@ -3006,7 +3020,6 @@ msgstr "Move to folder" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3185,6 +3198,10 @@ msgstr "No messages with {peerBotName} yet." msgid "No model catalog is available." msgstr "No model catalog is available." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "No projects found" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "No projects yet." @@ -3230,7 +3247,6 @@ msgstr "No usage data is sent to third parties" msgid "None yet" msgstr "None yet" -#: src/components/TradingModeIndicator.tsx #: src/pages/PrivacySettingsOverlay.tsx msgid "Not configured" msgstr "Not configured" @@ -3243,6 +3259,7 @@ msgstr "Not connected" msgid "Not in the plugin catalog" msgstr "Not in the plugin catalog" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Not now" @@ -3255,14 +3272,11 @@ msgstr "Nothing is still running" msgid "Nothing remembered yet" msgstr "Nothing remembered yet" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "Nothing specific" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "Nothing yet" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Notifications" @@ -3276,6 +3290,10 @@ msgstr "Now" msgid "Now using {0}." msgstr "Now using {0}." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "Nudges from your Oo" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth authorization was cancelled." @@ -3292,10 +3310,6 @@ msgstr "OAuth connection failed" msgid "OAuth expired" msgstr "OAuth expired" -#: src/components/TradingModeIndicator.tsx -msgid "Offline" -msgstr "Offline" - #: src/pages/VoiceSettingsOverlay.tsx msgid "On" msgstr "On" @@ -3313,6 +3327,10 @@ msgstr "on the 1st at {0}" msgid "Onboarding" msgstr "Onboarding" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "Once a week" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "One more look before I answer…" @@ -3321,6 +3339,10 @@ msgstr "One more look before I answer…" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "Only empty spaces can be deleted. Delete its bots and groups first." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "Only file and folder names are read." + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3408,10 +3430,6 @@ msgstr "Other voice providers" msgid "PagerDuty incident" msgstr "PagerDuty incident" -#: src/components/TradingModeIndicator.tsx -msgid "Paper" -msgstr "Paper" - #: src/pages/ScratchpadSection.tsx msgid "Park" msgstr "Park" @@ -3492,10 +3510,6 @@ msgstr "Polishing the details…" msgid "Polling devices…" msgstr "Polling devices…" -#: src/pages/Shell.tsx -msgid "Portfolio-Check" -msgstr "Portfolio-Check" - #: src/pages/RoutineEditor.tsx msgid "POST to" msgstr "POST to" @@ -3572,6 +3586,10 @@ msgstr "Providers" msgid "Pull request" msgstr "Pull request" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "Push on this device" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3582,14 +3600,22 @@ msgstr "Queued" msgid "Queued…" msgstr "Queued…" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "Quick & pragmatic" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "Quiet day so far" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "Quiet hours" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "Quiet hours end" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "Quiet hours start" + #: src/pages/Shell.tsx msgid "Quote" msgstr "Quote" @@ -3809,6 +3835,10 @@ msgstr "Replying to {replyName}: “{replyQuote}”" msgid "Reporting from home…" msgstr "Reporting from home…" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "Research & writing" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "research, writing" @@ -3948,6 +3978,7 @@ msgstr "Sandbox" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4005,10 +4036,6 @@ msgstr "Scanning exposure…" msgid "Scene" msgstr "Scene" -#: src/pages/Shell.tsx -msgid "Schlichtheit, Eleganz & Detailtiefe" -msgstr "Schlichtheit, Eleganz & Detailtiefe" - #: src/pages/ModelSettingsOverlay.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/Shell.tsx @@ -4052,15 +4079,12 @@ msgstr "Searching…" msgid "Select {0}" msgstr "Select {0}" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "Select all that apply" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "Self-hosted (better-auth)" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Send" @@ -4224,7 +4248,9 @@ msgid "Skills" msgstr "Skills" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Skip" @@ -4309,6 +4335,10 @@ msgstr "Speaking on or off" msgid "Speaking with intent…" msgstr "Speaking with intent…" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "Specialist crew" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "Standard" @@ -4371,10 +4401,6 @@ msgstr "Staying in my specialty…" msgid "Steps" msgstr "Steps" -#: src/pages/Shell.tsx -msgid "Steve Jobs Audit" -msgstr "Steve Jobs Audit" - #: src/pages/Shell.tsx msgid "Stop" msgstr "Stop" @@ -4409,10 +4435,6 @@ msgstr "Stress-testing the book…" msgid "Stronger together — {0} specialists" msgstr "Stronger together — {0} specialists" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "Structured & planned" - #: src/pages/Shell.tsx msgid "subagent" msgstr "subagent" @@ -4498,10 +4520,6 @@ msgstr "Teams not enabled" msgid "Tell me what matters. I'll move." msgstr "Tell me what matters. I'll move." -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "Tell us about yourself" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Test" @@ -4592,10 +4610,6 @@ msgstr "Time of day" msgid "Time zone" msgstr "Time zone" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "Timezone" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Title" @@ -4629,10 +4643,6 @@ msgstr "Tools" msgid "Tracing the path…" msgstr "Tracing the path…" -#: src/components/TradingModeIndicator.tsx -msgid "Trading" -msgstr "Trading" - #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx msgid "Transcript" @@ -4654,6 +4664,10 @@ msgstr "Triage in progress…" msgid "Try again" msgstr "Try again" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "Turn on" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Type your answer" @@ -4670,10 +4684,6 @@ msgstr "Unavailable" msgid "Uninstall" msgstr "Uninstall" -#: src/components/TradingModeIndicator.tsx -msgid "Unknown" -msgstr "Unknown" - #: src/pages/MessagingSettingsOverlay.tsx msgid "Unlink" msgstr "Unlink" @@ -4814,14 +4824,6 @@ msgstr "Voice is not available yet. You can turn it on later in settings." msgid "Voice set to {0}." msgstr "Voice set to {0}." -#: src/pages/Shell.tsx -msgid "Volatilität & Orderbuch-Liquidität" -msgstr "Volatilität & Orderbuch-Liquidität" - -#: src/pages/Shell.tsx -msgid "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." -msgstr "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." - #: src/pages/ProjectSessionTimeline.tsx msgid "Waiting" msgstr "Waiting" @@ -4859,6 +4861,14 @@ msgstr "Webhook" msgid "Weekdays" msgstr "Weekdays" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "Weekly" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "Weekly review" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "What about its memories?" @@ -4867,6 +4877,10 @@ msgstr "What about its memories?" msgid "What do you think — react or answer in a sentence." msgstr "What do you think — react or answer in a sentence." +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "What do you want me on first?" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "What feels like you?" @@ -4907,6 +4921,10 @@ msgstr "What to return" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "What's on your mind? I'll route it — and tell you when I disagree." +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "What’s one thing you want to get done in the next few weeks?" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "What's personal today?" @@ -4919,6 +4937,14 @@ msgstr "What's the work item?" msgid "When a webhook fires" msgstr "When a webhook fires" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "When should I check in with you?" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "When should I stay quiet?" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "When to run" @@ -4944,10 +4970,6 @@ msgstr "Work" msgid "Work channel open — go ahead." msgstr "Work channel open — go ahead." -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "Work style" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "Working" @@ -5004,8 +5026,11 @@ msgstr "Your conversations and settings never leave your machine" msgid "Your email address" msgstr "Your email address" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "Your goal" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Your name" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 7cf4aaa..b94d684 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -21,6 +21,11 @@ msgstr " (sin leer)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -46,11 +56,6 @@ msgstr "{0} (más de 10 MiB)" msgid "{0} {unitLabel}" msgstr "{0} {unitLabel}" -#. placeholder {0}: subtitle.count -#: src/components/TradingModeIndicator.tsx -msgid "{0} adapters" -msgstr "" - #. placeholder {0}: block.name #: src/pages/shell/message-cards.tsx msgid "{0} connection" @@ -273,22 +278,6 @@ msgstr "{workingBotName} está trabajando" msgid "/Users/you/projects/acme" msgstr "" -#: src/pages/Shell.tsx -msgid "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "📈 Scanne den Markt nach aktuellen Alpha-Signalen" -msgstr "" - -#: src/components/TradingModeIndicator.tsx -msgid "1 adapter" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "1:1 conversation" msgstr "" @@ -297,10 +286,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -366,6 +351,7 @@ msgstr "Modelo activo" msgid "Activity" msgstr "Actividad" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -403,6 +389,10 @@ msgstr "Agrega una URL de servidor HTTPS." msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Agregar Executor" @@ -444,6 +434,10 @@ msgstr "Agregar servidor" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Agregar a la rutina" @@ -506,6 +500,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -541,10 +555,6 @@ msgstr "" msgid "Allowed once" msgstr "Permitido una vez" -#: src/pages/Shell.tsx -msgid "Alpha-Signale" -msgstr "" - #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "¿Ya tienes una cuenta?" @@ -616,10 +626,6 @@ msgstr "Aprobar" msgid "Approve this server to let your agent use its tools." msgstr "Aprueba este servidor para que tu agente use sus herramientas." -#: src/pages/Shell.tsx -msgid "Architektur-Audit" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Archive" msgstr "Archivar" @@ -720,10 +726,6 @@ msgstr "Autorizar" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Autoriza este servidor para que tus agentes usen sus herramientas. Se abre una ventana emergente." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -759,10 +761,6 @@ msgstr "Volver a iniciar sesión" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "URL base" @@ -772,6 +770,10 @@ msgstr "URL base" msgid "Bearer token" msgstr "Token Bearer" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Iniciando escritorio en vivo…" @@ -840,6 +842,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -852,6 +858,7 @@ msgstr "No se puede alcanzar el servidor." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -886,8 +893,8 @@ msgstr "" msgid "Cancelled" msgstr "Cancelado" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -944,6 +951,10 @@ msgstr "Regístrate." msgid "Check your email" msgstr "Revisa tu correo" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1151,7 +1162,6 @@ msgid "Command" msgstr "Comando" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1312,8 +1322,8 @@ msgstr "Límite de contexto" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Continuar" @@ -1539,6 +1549,10 @@ msgstr "No se pudo cargar el estado de actualización" msgid "Could not load voice settings" msgstr "No se pudo cargar la configuración de voz" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1691,6 +1705,7 @@ msgstr "No se pudo actualizar el alcance de memoria predeterminado" msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1699,6 +1714,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "No se pudo cargar la configuración de mensajería" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1840,6 +1856,10 @@ msgstr "" msgid "day" msgstr "día" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "días" @@ -1848,10 +1868,6 @@ msgstr "días" msgid "Decline" msgstr "Rechazar" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Predeterminado" @@ -1952,18 +1968,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/pages/Shell.tsx msgid "Dev" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2070,10 +2078,6 @@ msgstr "" msgid "Drag, drop, or paste an image" msgstr "Arrastra, suelta o pega una imagen" -#: src/components/TradingModeIndicator.tsx -msgid "Dry run" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Duplicate" msgstr "Duplicar" @@ -2086,8 +2090,8 @@ msgstr "p. ej. Hivenet Agent, Presales, bot de hojas de horas" msgid "Earlier message" msgstr "Mensaje anterior" -#: src/pages/Shell.tsx -msgid "Echtzeit-Risiko & Drawdown-Analyse" +#: src/components/UserProfileSettings.tsx +msgid "Edit" msgstr "" #: src/components/AskCard.tsx @@ -2139,6 +2143,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "Ingresa este código en <0>{0}" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2167,6 +2179,10 @@ msgstr "Cada lunes" msgid "Every month" msgstr "Cada mes" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2207,6 +2223,10 @@ msgstr "" msgid "Extra high" msgstr "Extra alto" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "fallido" @@ -2276,10 +2296,6 @@ msgstr "Finalizando conexión MCP…" msgid "Flag unexpected actions" msgstr "Marcar acciones inesperadas" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2300,10 +2316,6 @@ msgstr "" msgid "Forgot password?" msgstr "¿Olvidaste la contraseña?" -#: src/pages/Shell.tsx -msgid "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Free" msgstr "Gratis" @@ -2344,6 +2356,7 @@ msgstr "Evento de Git" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2380,10 +2393,7 @@ msgstr "" msgid "Groups" msgstr "" -#: src/pages/Shell.tsx -msgid "Guten Tag." -msgstr "" - +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2458,10 +2468,6 @@ msgstr "Ocultar contraseña" msgid "High" msgstr "Alto" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2475,10 +2481,6 @@ msgstr "hora" msgid "hours" msgstr "horas" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Con qué frecuencia" @@ -2487,10 +2489,6 @@ msgstr "Con qué frecuencia" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "Cómo comprobar" @@ -2531,6 +2529,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Heredar predeterminado" @@ -2582,10 +2584,6 @@ msgstr "Unidad del intervalo" msgid "Isolated" msgstr "Aislado" -#: src/pages/Shell.tsx -msgid "ist bereit." -msgstr "" - #: src/pages/shell/dialogs.tsx msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." msgstr "Su conversación, archivos y rutinas se eliminarán de forma permanente. Los bots que creó permanecerán en tu lista." @@ -2681,10 +2679,6 @@ msgstr "" msgid "Listening — what's the story?" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Live Trading" -msgstr "" - #: src/pages/Shell.tsx msgid "Load earlier messages" msgstr "Cargar mensajes anteriores" @@ -2761,6 +2755,10 @@ msgstr "" msgid "Log out" msgstr "Cerrar sesión" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2771,10 +2769,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2935,10 +2941,6 @@ msgstr "minuto" msgid "minutes" msgstr "minutos" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2995,6 +2997,18 @@ msgstr "Más acciones de computadora" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Muévelas a tu memoria compartida." @@ -3006,7 +3020,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3185,6 +3198,10 @@ msgstr "Aún no hay mensajes con {peerBotName}." msgid "No model catalog is available." msgstr "No hay un catálogo de modelos disponible." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3230,7 +3247,6 @@ msgstr "" msgid "None yet" msgstr "Ninguno aún" -#: src/components/TradingModeIndicator.tsx #: src/pages/PrivacySettingsOverlay.tsx msgid "Not configured" msgstr "" @@ -3243,6 +3259,7 @@ msgstr "No conectado" msgid "Not in the plugin catalog" msgstr "No está en el catálogo de plugins" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Ahora no" @@ -3255,14 +3272,11 @@ msgstr "No queda nada en ejecución" msgid "Nothing remembered yet" msgstr "Aún no hay recuerdos" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Notificaciones" @@ -3276,6 +3290,10 @@ msgstr "Ahora" msgid "Now using {0}." msgstr "Ahora usando {0}." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "Se canceló la autorización OAuth." @@ -3292,10 +3310,6 @@ msgstr "Falló la conexión OAuth" msgid "OAuth expired" msgstr "OAuth expirado" -#: src/components/TradingModeIndicator.tsx -msgid "Offline" -msgstr "" - #: src/pages/VoiceSettingsOverlay.tsx msgid "On" msgstr "" @@ -3313,6 +3327,10 @@ msgstr "el día 1 a las {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3321,6 +3339,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3408,10 +3430,6 @@ msgstr "" msgid "PagerDuty incident" msgstr "Incidente de PagerDuty" -#: src/components/TradingModeIndicator.tsx -msgid "Paper" -msgstr "" - #: src/pages/ScratchpadSection.tsx msgid "Park" msgstr "Aparcar" @@ -3492,10 +3510,6 @@ msgstr "" msgid "Polling devices…" msgstr "" -#: src/pages/Shell.tsx -msgid "Portfolio-Check" -msgstr "" - #: src/pages/RoutineEditor.tsx msgid "POST to" msgstr "POST a" @@ -3572,6 +3586,10 @@ msgstr "Proveedores" msgid "Pull request" msgstr "Solicitud de incorporación" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3582,14 +3600,22 @@ msgstr "En cola" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3809,6 +3835,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3948,6 +3978,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4005,10 +4036,6 @@ msgstr "" msgid "Scene" msgstr "" -#: src/pages/Shell.tsx -msgid "Schlichtheit, Eleganz & Detailtiefe" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/Shell.tsx @@ -4052,15 +4079,12 @@ msgstr "Buscando…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Enviar" @@ -4224,7 +4248,9 @@ msgid "Skills" msgstr "Habilidades" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Omitir" @@ -4309,6 +4335,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4371,10 +4401,6 @@ msgstr "" msgid "Steps" msgstr "Pasos" -#: src/pages/Shell.tsx -msgid "Steve Jobs Audit" -msgstr "" - #: src/pages/Shell.tsx msgid "Stop" msgstr "Detener" @@ -4409,10 +4435,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "subagente" @@ -4498,10 +4520,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Probar" @@ -4592,10 +4610,6 @@ msgstr "Hora del día" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Título" @@ -4629,10 +4643,6 @@ msgstr "" msgid "Tracing the path…" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Trading" -msgstr "" - #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx msgid "Transcript" @@ -4654,6 +4664,10 @@ msgstr "" msgid "Try again" msgstr "Intentarlo de nuevo" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Escribe tu respuesta" @@ -4670,10 +4684,6 @@ msgstr "No disponible" msgid "Uninstall" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Unknown" -msgstr "" - #: src/pages/MessagingSettingsOverlay.tsx msgid "Unlink" msgstr "Desvincular" @@ -4814,14 +4824,6 @@ msgstr "" msgid "Voice set to {0}." msgstr "" -#: src/pages/Shell.tsx -msgid "Volatilität & Orderbuch-Liquidität" -msgstr "" - -#: src/pages/Shell.tsx -msgid "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." -msgstr "" - #: src/pages/ProjectSessionTimeline.tsx msgid "Waiting" msgstr "" @@ -4859,6 +4861,14 @@ msgstr "Webhook" msgid "Weekdays" msgstr "Días laborables" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "¿Qué hay de sus memorias?" @@ -4867,6 +4877,10 @@ msgstr "¿Qué hay de sus memorias?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4907,6 +4921,10 @@ msgstr "Qué devolver" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4919,6 +4937,14 @@ msgstr "" msgid "When a webhook fires" msgstr "Cuando se dispara un webhook" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Cuándo ejecutar" @@ -4944,10 +4970,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -5004,8 +5026,11 @@ msgstr "" msgid "Your email address" msgstr "Tu dirección de correo" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Tu nombre" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index f5f08b0..e96a068 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -21,6 +21,11 @@ msgstr " (अपठित)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# मॉडल} other {# मॉडल}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -46,11 +56,6 @@ msgstr "{0} (10 MiB से अधिक)" msgid "{0} {unitLabel}" msgstr "{0} {unitLabel}" -#. placeholder {0}: subtitle.count -#: src/components/TradingModeIndicator.tsx -msgid "{0} adapters" -msgstr "" - #. placeholder {0}: block.name #: src/pages/shell/message-cards.tsx msgid "{0} connection" @@ -273,22 +278,6 @@ msgstr "{workingBotName} काम कर रहा है" msgid "/Users/you/projects/acme" msgstr "" -#: src/pages/Shell.tsx -msgid "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "📈 Scanne den Markt nach aktuellen Alpha-Signalen" -msgstr "" - -#: src/components/TradingModeIndicator.tsx -msgid "1 adapter" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "1:1 conversation" msgstr "" @@ -297,10 +286,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -366,6 +351,7 @@ msgstr "सक्रिय मॉडल" msgid "Activity" msgstr "गतिविधि" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -403,6 +389,10 @@ msgstr "एक HTTPS सर्वर URL दर्ज करें।" msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Executor जोड़ें" @@ -444,6 +434,10 @@ msgstr "सर्वर जोड़ें" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "रूटीन में जोड़ें" @@ -506,6 +500,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -541,10 +555,6 @@ msgstr "" msgid "Allowed once" msgstr "एक बार अनुमति दी गई" -#: src/pages/Shell.tsx -msgid "Alpha-Signale" -msgstr "" - #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "पहले से खाता है?" @@ -616,10 +626,6 @@ msgstr "अनुमोदित करें" msgid "Approve this server to let your agent use its tools." msgstr "अपने एजेंट को इसके टूल उपयोग करने देने के लिए इस सर्वर को अनुमोदित करें।" -#: src/pages/Shell.tsx -msgid "Architektur-Audit" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Archive" msgstr "आर्काइव करें" @@ -720,10 +726,6 @@ msgstr "ऑथराइज़ करें" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "एजेंटों को इसके टूल का उपयोग करने देने के लिए इस सर्वर को ऑथराइज़ करें। एक पॉपअप खुलता है।" -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -759,10 +761,6 @@ msgstr "साइन इन पर वापस जाएँ" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "बेस URL" @@ -772,6 +770,10 @@ msgstr "बेस URL" msgid "Bearer token" msgstr "बियरर टोकन" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "लाइव डेस्कटॉप शुरू हो रहा है…" @@ -840,6 +842,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -852,6 +858,7 @@ msgstr "सर्वर तक नहीं पहुंचा जा सका #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -886,8 +893,8 @@ msgstr "" msgid "Cancelled" msgstr "रद्द किया गया" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -944,6 +951,10 @@ msgstr "जांच करें।" msgid "Check your email" msgstr "अपना ईमेल देखें" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1151,7 +1162,6 @@ msgid "Command" msgstr "कमांड" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1312,8 +1322,8 @@ msgstr "कॉन्टेक्स्ट सीमा" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "जारी रखें" @@ -1539,6 +1549,10 @@ msgstr "" msgid "Could not load voice settings" msgstr "आवाज़ सेटिंग्स लोड नहीं हो सकीं" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1691,6 +1705,7 @@ msgstr "डिफ़ॉल्ट मेमोरी स्कोप अपडे msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1699,6 +1714,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1840,6 +1856,10 @@ msgstr "" msgid "day" msgstr "दिन" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "दिन" @@ -1848,10 +1868,6 @@ msgstr "दिन" msgid "Decline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "डिफ़ॉल्ट" @@ -1952,18 +1968,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/pages/Shell.tsx msgid "Dev" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2070,10 +2078,6 @@ msgstr "" msgid "Drag, drop, or paste an image" msgstr "छवि खींचें, छोड़ें या पेस्ट करें" -#: src/components/TradingModeIndicator.tsx -msgid "Dry run" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Duplicate" msgstr "डुप्लिकेट" @@ -2086,8 +2090,8 @@ msgstr "उदा. Hivenet Agent, Presales, टाइमशीट बॉट" msgid "Earlier message" msgstr "पुराना संदेश" -#: src/pages/Shell.tsx -msgid "Echtzeit-Risiko & Drawdown-Analyse" +#: src/components/UserProfileSettings.tsx +msgid "Edit" msgstr "" #: src/components/AskCard.tsx @@ -2139,6 +2143,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "यह कोड <0>{0} पर दर्ज करें" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2167,6 +2179,10 @@ msgstr "हर सोमवार" msgid "Every month" msgstr "हर महीने" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2207,6 +2223,10 @@ msgstr "" msgid "Extra high" msgstr "अतिरिक्त उच्च" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "असफल" @@ -2276,10 +2296,6 @@ msgstr "MCP कनेक्शन पूरा हो रहा है…" msgid "Flag unexpected actions" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2300,10 +2316,6 @@ msgstr "" msgid "Forgot password?" msgstr "पासवर्ड भूल गए?" -#: src/pages/Shell.tsx -msgid "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Free" msgstr "" @@ -2344,6 +2356,7 @@ msgstr "" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2380,10 +2393,7 @@ msgstr "" msgid "Groups" msgstr "" -#: src/pages/Shell.tsx -msgid "Guten Tag." -msgstr "" - +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2458,10 +2468,6 @@ msgstr "" msgid "High" msgstr "उच्च" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2475,10 +2481,6 @@ msgstr "घंटा" msgid "hours" msgstr "घंटे" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "कितनी बार" @@ -2487,10 +2489,6 @@ msgstr "कितनी बार" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "जांच कैसे करें" @@ -2531,6 +2529,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "डिफ़ॉल्ट का पालन करें" @@ -2582,10 +2584,6 @@ msgstr "अंतराल इकाई" msgid "Isolated" msgstr "पृथक" -#: src/pages/Shell.tsx -msgid "ist bereit." -msgstr "" - #: src/pages/shell/dialogs.tsx msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." msgstr "इसकी बातचीत, फ़ाइलें और रूटीन स्थायी रूप से डिलीट कर दिए जाएंगे। इसके बनाए बॉट आपकी सूची में बने रहेंगे।" @@ -2681,10 +2679,6 @@ msgstr "" msgid "Listening — what's the story?" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Live Trading" -msgstr "" - #: src/pages/Shell.tsx msgid "Load earlier messages" msgstr "पुराने संदेश लोड करें" @@ -2761,6 +2755,10 @@ msgstr "" msgid "Log out" msgstr "लॉग आउट" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2771,10 +2769,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2935,10 +2941,6 @@ msgstr "मिनट" msgid "minutes" msgstr "मिनट" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2995,6 +2997,18 @@ msgstr "" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "उन्हें अपनी साझा मेमोरी में ले जाएं।" @@ -3006,7 +3020,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3185,6 +3198,10 @@ msgstr "{peerBotName} के साथ अभी कोई संदेश न msgid "No model catalog is available." msgstr "कोई मॉडल कैटलॉग उपलब्ध नहीं है।" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3230,7 +3247,6 @@ msgstr "" msgid "None yet" msgstr "अभी तक कोई नहीं" -#: src/components/TradingModeIndicator.tsx #: src/pages/PrivacySettingsOverlay.tsx msgid "Not configured" msgstr "" @@ -3243,6 +3259,7 @@ msgstr "कनेक्टेड नहीं" msgid "Not in the plugin catalog" msgstr "" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "अभी नहीं" @@ -3255,14 +3272,11 @@ msgstr "कुछ भी नहीं चल रहा" msgid "Nothing remembered yet" msgstr "अभी तक कुछ याद नहीं किया गया" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "सूचनाएं" @@ -3276,6 +3290,10 @@ msgstr "अभी" msgid "Now using {0}." msgstr "अब {0} उपयोग में है।" +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth ऑथराइज़ेशन रद्द कर दिया गया।" @@ -3292,10 +3310,6 @@ msgstr "OAuth कनेक्शन विफल" msgid "OAuth expired" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Offline" -msgstr "" - #: src/pages/VoiceSettingsOverlay.tsx msgid "On" msgstr "" @@ -3313,6 +3327,10 @@ msgstr "1 तारीख को {0} बजे" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3321,6 +3339,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3408,10 +3430,6 @@ msgstr "" msgid "PagerDuty incident" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Paper" -msgstr "" - #: src/pages/ScratchpadSection.tsx msgid "Park" msgstr "पार्क करें" @@ -3492,10 +3510,6 @@ msgstr "" msgid "Polling devices…" msgstr "" -#: src/pages/Shell.tsx -msgid "Portfolio-Check" -msgstr "" - #: src/pages/RoutineEditor.tsx msgid "POST to" msgstr "" @@ -3572,6 +3586,10 @@ msgstr "प्रोवाइडर्स" msgid "Pull request" msgstr "पुल अनुरोध" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3582,14 +3600,22 @@ msgstr "कतार में" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3809,6 +3835,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3948,6 +3978,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4005,10 +4036,6 @@ msgstr "" msgid "Scene" msgstr "" -#: src/pages/Shell.tsx -msgid "Schlichtheit, Eleganz & Detailtiefe" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/Shell.tsx @@ -4052,15 +4079,12 @@ msgstr "खोजा जा रहा है…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "भेजें" @@ -4224,7 +4248,9 @@ msgid "Skills" msgstr "स्किल्स" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "छोड़ें" @@ -4309,6 +4335,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4371,10 +4401,6 @@ msgstr "" msgid "Steps" msgstr "चरण" -#: src/pages/Shell.tsx -msgid "Steve Jobs Audit" -msgstr "" - #: src/pages/Shell.tsx msgid "Stop" msgstr "रोकें" @@ -4409,10 +4435,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "सबएजेंट" @@ -4498,10 +4520,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "टेस्ट करें" @@ -4592,10 +4610,6 @@ msgstr "दिन का समय" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "शीर्षक" @@ -4629,10 +4643,6 @@ msgstr "" msgid "Tracing the path…" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Trading" -msgstr "" - #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx msgid "Transcript" @@ -4654,6 +4664,10 @@ msgstr "" msgid "Try again" msgstr "फिर से कोशिश करें" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "अपना उत्तर लिखें" @@ -4670,10 +4684,6 @@ msgstr "" msgid "Uninstall" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Unknown" -msgstr "" - #: src/pages/MessagingSettingsOverlay.tsx msgid "Unlink" msgstr "" @@ -4814,14 +4824,6 @@ msgstr "" msgid "Voice set to {0}." msgstr "" -#: src/pages/Shell.tsx -msgid "Volatilität & Orderbuch-Liquidität" -msgstr "" - -#: src/pages/Shell.tsx -msgid "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." -msgstr "" - #: src/pages/ProjectSessionTimeline.tsx msgid "Waiting" msgstr "" @@ -4859,6 +4861,14 @@ msgstr "" msgid "Weekdays" msgstr "कार्यदिवस" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "इसकी मेमोरी का क्या करें?" @@ -4867,6 +4877,10 @@ msgstr "इसकी मेमोरी का क्या करें?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4907,6 +4921,10 @@ msgstr "क्या लौटाना है" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4919,6 +4937,14 @@ msgstr "" msgid "When a webhook fires" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "कब चलाएं" @@ -4944,10 +4970,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -5004,8 +5026,11 @@ msgstr "" msgid "Your email address" msgstr "आपका ईमेल पता" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "आपका नाम" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index 4d6295f..67e03bb 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -21,6 +21,11 @@ msgstr " (읽지 않음)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {모델 #개} other {모델 #개}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -46,11 +56,6 @@ msgstr "{0} (10 MiB 초과)" msgid "{0} {unitLabel}" msgstr "{0} {unitLabel}" -#. placeholder {0}: subtitle.count -#: src/components/TradingModeIndicator.tsx -msgid "{0} adapters" -msgstr "" - #. placeholder {0}: block.name #: src/pages/shell/message-cards.tsx msgid "{0} connection" @@ -273,22 +278,6 @@ msgstr "{workingBotName} 작업 중" msgid "/Users/you/projects/acme" msgstr "" -#: src/pages/Shell.tsx -msgid "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "📈 Scanne den Markt nach aktuellen Alpha-Signalen" -msgstr "" - -#: src/components/TradingModeIndicator.tsx -msgid "1 adapter" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "1:1 conversation" msgstr "" @@ -297,10 +286,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -366,6 +351,7 @@ msgstr "현재 모델" msgid "Activity" msgstr "활동" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -403,6 +389,10 @@ msgstr "HTTPS 서버 URL을 입력하세요." msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Executor 추가" @@ -444,6 +434,10 @@ msgstr "서버 추가" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "자동 실행에 추가" @@ -506,6 +500,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -541,10 +555,6 @@ msgstr "" msgid "Allowed once" msgstr "한 번 허용됨" -#: src/pages/Shell.tsx -msgid "Alpha-Signale" -msgstr "" - #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "이미 계정이 있나요?" @@ -616,10 +626,6 @@ msgstr "승인" msgid "Approve this server to let your agent use its tools." msgstr "이 서버를 승인하면 Bot이 제공되는 도구를 사용할 수 있습니다." -#: src/pages/Shell.tsx -msgid "Architektur-Audit" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Archive" msgstr "보관" @@ -720,10 +726,6 @@ msgstr "인증하기" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "에이전트가 도구를 사용할 수 있도록 이 서버에 권한을 부여하세요. 팝업이 열립니다." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -759,10 +761,6 @@ msgstr "로그인으로 돌아가기" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "서버 주소" @@ -772,6 +770,10 @@ msgstr "서버 주소" msgid "Bearer token" msgstr "Bearer 토큰" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "라이브 데스크톱 시작 중…" @@ -840,6 +842,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -852,6 +858,7 @@ msgstr "서버에 연결할 수 없습니다." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -886,8 +893,8 @@ msgstr "" msgid "Cancelled" msgstr "취소됨" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -944,6 +951,10 @@ msgstr "확인해 주세요." msgid "Check your email" msgstr "이메일을 확인하세요" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1151,7 +1162,6 @@ msgid "Command" msgstr "실행 명령" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1312,8 +1322,8 @@ msgstr "컨텍스트 한도" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "계속" @@ -1539,6 +1549,10 @@ msgstr "업데이트 상태를 불러올 수 없습니다" msgid "Could not load voice settings" msgstr "음성 설정을 불러오지 못했습니다." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1691,6 +1705,7 @@ msgstr "기본 기억 범위를 변경하지 못했습니다." msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1699,6 +1714,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "메시징 설정을 불러올 수 없습니다" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1840,6 +1856,10 @@ msgstr "" msgid "day" msgstr "일" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "일" @@ -1848,10 +1868,6 @@ msgstr "일" msgid "Decline" msgstr "거절" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "기본값" @@ -1952,18 +1968,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/pages/Shell.tsx msgid "Dev" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2070,10 +2078,6 @@ msgstr "" msgid "Drag, drop, or paste an image" msgstr "이미지를 끌어다 놓거나 붙여넣으세요" -#: src/components/TradingModeIndicator.tsx -msgid "Dry run" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Duplicate" msgstr "복제" @@ -2086,8 +2090,8 @@ msgstr "예: Hivenet Agent, Presales, 근무표 봇" msgid "Earlier message" msgstr "이전 메시지" -#: src/pages/Shell.tsx -msgid "Echtzeit-Risiko & Drawdown-Analyse" +#: src/components/UserProfileSettings.tsx +msgid "Edit" msgstr "" #: src/components/AskCard.tsx @@ -2139,6 +2143,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "<0>{0}에서 이 코드를 입력하세요" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2167,6 +2179,10 @@ msgstr "매주 월요일" msgid "Every month" msgstr "매월" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2207,6 +2223,10 @@ msgstr "" msgid "Extra high" msgstr "매우 높음" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "실패" @@ -2276,10 +2296,6 @@ msgstr "MCP 연결을 마무리하는 중…" msgid "Flag unexpected actions" msgstr "예상하지 못한 작업 표시" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2300,10 +2316,6 @@ msgstr "" msgid "Forgot password?" msgstr "비밀번호를 잊으셨나요?" -#: src/pages/Shell.tsx -msgid "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Free" msgstr "무료" @@ -2344,6 +2356,7 @@ msgstr "Git 이벤트" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2380,10 +2393,7 @@ msgstr "" msgid "Groups" msgstr "" -#: src/pages/Shell.tsx -msgid "Guten Tag." -msgstr "" - +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2458,10 +2468,6 @@ msgstr "비밀번호 숨기기" msgid "High" msgstr "높음" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2475,10 +2481,6 @@ msgstr "시간" msgid "hours" msgstr "시간" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "실행 주기" @@ -2487,10 +2489,6 @@ msgstr "실행 주기" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "완료 확인 방법" @@ -2531,6 +2529,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "기본 설정 사용" @@ -2582,10 +2584,6 @@ msgstr "간격 단위" msgid "Isolated" msgstr "이 Bot만 사용" -#: src/pages/Shell.tsx -msgid "ist bereit." -msgstr "" - #: src/pages/shell/dialogs.tsx msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." msgstr "대화, 파일, 자동 실행이 영구 삭제됩니다. 이 Bot이 만든 다른 Bot은 목록에 남습니다." @@ -2681,10 +2679,6 @@ msgstr "" msgid "Listening — what's the story?" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Live Trading" -msgstr "" - #: src/pages/Shell.tsx msgid "Load earlier messages" msgstr "이전 메시지 불러오기" @@ -2761,6 +2755,10 @@ msgstr "" msgid "Log out" msgstr "로그아웃" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2771,10 +2769,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2935,10 +2941,6 @@ msgstr "분" msgid "minutes" msgstr "분" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2995,6 +2997,18 @@ msgstr "컴퓨터 작업 더보기" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "공용 기억으로 옮깁니다." @@ -3006,7 +3020,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3185,6 +3198,10 @@ msgstr "{peerBotName}와의 메시지가 아직 없습니다." msgid "No model catalog is available." msgstr "사용 가능한 모델 목록이 없습니다." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3230,7 +3247,6 @@ msgstr "" msgid "None yet" msgstr "아직 없음" -#: src/components/TradingModeIndicator.tsx #: src/pages/PrivacySettingsOverlay.tsx msgid "Not configured" msgstr "" @@ -3243,6 +3259,7 @@ msgstr "연결되지 않음" msgid "Not in the plugin catalog" msgstr "플러그인 카탈로그에 없음" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "나중에" @@ -3255,14 +3272,11 @@ msgstr "실행 중인 작업 없음" msgid "Nothing remembered yet" msgstr "아직 기억한 내용이 없습니다" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "알림" @@ -3276,6 +3290,10 @@ msgstr "지금" msgid "Now using {0}." msgstr "이제 {0} 모델을 사용합니다." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth 인증이 취소되었습니다." @@ -3292,10 +3310,6 @@ msgstr "OAuth 연결 실패" msgid "OAuth expired" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Offline" -msgstr "" - #: src/pages/VoiceSettingsOverlay.tsx msgid "On" msgstr "" @@ -3313,6 +3327,10 @@ msgstr "매월 1일 {0}에" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3321,6 +3339,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3408,10 +3430,6 @@ msgstr "" msgid "PagerDuty incident" msgstr "PagerDuty 인시던트" -#: src/components/TradingModeIndicator.tsx -msgid "Paper" -msgstr "" - #: src/pages/ScratchpadSection.tsx msgid "Park" msgstr "보류" @@ -3492,10 +3510,6 @@ msgstr "" msgid "Polling devices…" msgstr "" -#: src/pages/Shell.tsx -msgid "Portfolio-Check" -msgstr "" - #: src/pages/RoutineEditor.tsx msgid "POST to" msgstr "POST 대상" @@ -3572,6 +3586,10 @@ msgstr "서비스" msgid "Pull request" msgstr "풀 리퀘스트" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3582,14 +3600,22 @@ msgstr "대기 중" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3809,6 +3835,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3948,6 +3978,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4005,10 +4036,6 @@ msgstr "" msgid "Scene" msgstr "" -#: src/pages/Shell.tsx -msgid "Schlichtheit, Eleganz & Detailtiefe" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/Shell.tsx @@ -4052,15 +4079,12 @@ msgstr "검색 중…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "보내기" @@ -4224,7 +4248,9 @@ msgid "Skills" msgstr "스킬" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "건너뛰기" @@ -4309,6 +4335,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4371,10 +4401,6 @@ msgstr "" msgid "Steps" msgstr "실행 단계" -#: src/pages/Shell.tsx -msgid "Steve Jobs Audit" -msgstr "" - #: src/pages/Shell.tsx msgid "Stop" msgstr "중지" @@ -4409,10 +4435,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "보조 에이전트" @@ -4498,10 +4520,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "시험 실행" @@ -4592,10 +4610,6 @@ msgstr "실행 시각" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "역할" @@ -4629,10 +4643,6 @@ msgstr "" msgid "Tracing the path…" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Trading" -msgstr "" - #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx msgid "Transcript" @@ -4654,6 +4664,10 @@ msgstr "" msgid "Try again" msgstr "다시 시도" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "답변을 입력하세요" @@ -4670,10 +4684,6 @@ msgstr "사용할 수 없음" msgid "Uninstall" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Unknown" -msgstr "" - #: src/pages/MessagingSettingsOverlay.tsx msgid "Unlink" msgstr "연결 해제" @@ -4814,14 +4824,6 @@ msgstr "" msgid "Voice set to {0}." msgstr "" -#: src/pages/Shell.tsx -msgid "Volatilität & Orderbuch-Liquidität" -msgstr "" - -#: src/pages/Shell.tsx -msgid "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." -msgstr "" - #: src/pages/ProjectSessionTimeline.tsx msgid "Waiting" msgstr "" @@ -4859,6 +4861,14 @@ msgstr "웹훅" msgid "Weekdays" msgstr "주중" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "이 Bot의 기억은 어떻게 할까요?" @@ -4867,6 +4877,10 @@ msgstr "이 Bot의 기억은 어떻게 할까요?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4907,6 +4921,10 @@ msgstr "완료 후 전달할 결과" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4919,6 +4937,14 @@ msgstr "" msgid "When a webhook fires" msgstr "웹훅이 실행될 때" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "실행 시간" @@ -4944,10 +4970,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -5004,8 +5026,11 @@ msgstr "" msgid "Your email address" msgstr "이메일 주소" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "이름" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index 773358f..c9f1848 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -21,6 +21,11 @@ msgstr " (não lido)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -46,11 +56,6 @@ msgstr "{0} (acima de 10 MiB)" msgid "{0} {unitLabel}" msgstr "{0} {unitLabel}" -#. placeholder {0}: subtitle.count -#: src/components/TradingModeIndicator.tsx -msgid "{0} adapters" -msgstr "" - #. placeholder {0}: block.name #: src/pages/shell/message-cards.tsx msgid "{0} connection" @@ -273,22 +278,6 @@ msgstr "{workingBotName} está trabalhando" msgid "/Users/you/projects/acme" msgstr "" -#: src/pages/Shell.tsx -msgid "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "📈 Scanne den Markt nach aktuellen Alpha-Signalen" -msgstr "" - -#: src/components/TradingModeIndicator.tsx -msgid "1 adapter" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "1:1 conversation" msgstr "" @@ -297,10 +286,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -366,6 +351,7 @@ msgstr "Modelo ativo" msgid "Activity" msgstr "Atividade" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -403,6 +389,10 @@ msgstr "Adicione um URL do servidor HTTPS." msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Adicionar Executor" @@ -444,6 +434,10 @@ msgstr "Adicionar servidor" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Adicionar à rotina" @@ -506,6 +500,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -541,10 +555,6 @@ msgstr "" msgid "Allowed once" msgstr "Permitido uma vez" -#: src/pages/Shell.tsx -msgid "Alpha-Signale" -msgstr "" - #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "Você já tem cadastro?" @@ -616,10 +626,6 @@ msgstr "Aprovar" msgid "Approve this server to let your agent use its tools." msgstr "Aprove este servidor para permitir que seu agente use suas ferramentas." -#: src/pages/Shell.tsx -msgid "Architektur-Audit" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Archive" msgstr "Arquivar" @@ -720,10 +726,6 @@ msgstr "Autorizar" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Autorize este servidor para que os agentes usem suas ferramentas. Uma janela pop-up será aberta." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -759,10 +761,6 @@ msgstr "Voltar para o login" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "URL Base" @@ -772,6 +770,10 @@ msgstr "URL Base" msgid "Bearer token" msgstr "Token portador" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Iniciando a área de trabalho ao vivo..." @@ -840,6 +842,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -852,6 +858,7 @@ msgstr "Não é possível acessar o servidor." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -886,8 +893,8 @@ msgstr "" msgid "Cancelled" msgstr "Cancelado" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -944,6 +951,10 @@ msgstr "Acompanhamento" msgid "Check your email" msgstr "Confira seu e-mail" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1151,7 +1162,6 @@ msgid "Command" msgstr "Comando" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1312,8 +1322,8 @@ msgstr "Limite de contexto" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Continuar" @@ -1539,6 +1549,10 @@ msgstr "" msgid "Could not load voice settings" msgstr "Não foi possível carregar as configurações de voz" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1691,6 +1705,7 @@ msgstr "Não foi possível atualizar o escopo de memória padrão" msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1699,6 +1714,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1840,6 +1856,10 @@ msgstr "" msgid "day" msgstr "dia" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "dias" @@ -1848,10 +1868,6 @@ msgstr "dias" msgid "Decline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Padrão" @@ -1952,18 +1968,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/pages/Shell.tsx msgid "Dev" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2070,10 +2078,6 @@ msgstr "" msgid "Drag, drop, or paste an image" msgstr "Arraste, solte ou cole uma imagem" -#: src/components/TradingModeIndicator.tsx -msgid "Dry run" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Duplicate" msgstr "Duplicar" @@ -2086,8 +2090,8 @@ msgstr "ex.: Hivenet Agent, Presales, bot de planilhas" msgid "Earlier message" msgstr "" -#: src/pages/Shell.tsx -msgid "Echtzeit-Risiko & Drawdown-Analyse" +#: src/components/UserProfileSettings.tsx +msgid "Edit" msgstr "" #: src/components/AskCard.tsx @@ -2139,6 +2143,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "Digite este código em <0>{0}" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2167,6 +2179,10 @@ msgstr "Toda segunda-feira" msgid "Every month" msgstr "A cada mês" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2207,6 +2223,10 @@ msgstr "" msgid "Extra high" msgstr "Extra alta" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "falhou" @@ -2276,10 +2296,6 @@ msgstr "Finalizando conexão MCP…" msgid "Flag unexpected actions" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2300,10 +2316,6 @@ msgstr "" msgid "Forgot password?" msgstr "Esqueceu a senha?" -#: src/pages/Shell.tsx -msgid "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Free" msgstr "" @@ -2344,6 +2356,7 @@ msgstr "" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2380,10 +2393,7 @@ msgstr "" msgid "Groups" msgstr "" -#: src/pages/Shell.tsx -msgid "Guten Tag." -msgstr "" - +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2458,10 +2468,6 @@ msgstr "" msgid "High" msgstr "Alto" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2475,10 +2481,6 @@ msgstr "hora" msgid "hours" msgstr "horas" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Com que frequência" @@ -2487,10 +2489,6 @@ msgstr "Com que frequência" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "Como verificar" @@ -2531,6 +2529,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Herdar (padrão)" @@ -2582,10 +2584,6 @@ msgstr "Unidade do intervalo:" msgid "Isolated" msgstr "Isolado" -#: src/pages/Shell.tsx -msgid "ist bereit." -msgstr "" - #: src/pages/shell/dialogs.tsx msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." msgstr "Suas conversas, arquivos e rotinas serão excluídos permanentemente. Os bots criados ficam na sua lista." @@ -2681,10 +2679,6 @@ msgstr "" msgid "Listening — what's the story?" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Live Trading" -msgstr "" - #: src/pages/Shell.tsx msgid "Load earlier messages" msgstr "Carregar mensagens anteriores..." @@ -2761,6 +2755,10 @@ msgstr "" msgid "Log out" msgstr "Sair" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2771,10 +2769,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2935,10 +2941,6 @@ msgstr "minuto" msgid "minutes" msgstr "minutos" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2995,6 +2997,18 @@ msgstr "" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Mova-os para a sua memória compartilhada." @@ -3006,7 +3020,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3185,6 +3198,10 @@ msgstr "Ainda sem mensagens com {peerBotName}." msgid "No model catalog is available." msgstr "Nenhum catálogo de modelos está disponível." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3230,7 +3247,6 @@ msgstr "" msgid "None yet" msgstr "Nenhuma ainda" -#: src/components/TradingModeIndicator.tsx #: src/pages/PrivacySettingsOverlay.tsx msgid "Not configured" msgstr "" @@ -3243,6 +3259,7 @@ msgstr "Não Conectado" msgid "Not in the plugin catalog" msgstr "" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Agora não" @@ -3255,14 +3272,11 @@ msgstr "Nada ainda está em execução" msgid "Nothing remembered yet" msgstr "Nada memorizado ainda" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Notificações" @@ -3276,6 +3290,10 @@ msgstr "Agora" msgid "Now using {0}." msgstr "Agora usando {0}." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "A autorização OAuth foi cancelada." @@ -3292,10 +3310,6 @@ msgstr "Falha na conexão OAuth" msgid "OAuth expired" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Offline" -msgstr "" - #: src/pages/VoiceSettingsOverlay.tsx msgid "On" msgstr "" @@ -3313,6 +3327,10 @@ msgstr "no dia 1º às {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3321,6 +3339,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3408,10 +3430,6 @@ msgstr "" msgid "PagerDuty incident" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Paper" -msgstr "" - #: src/pages/ScratchpadSection.tsx msgid "Park" msgstr "Suspender" @@ -3492,10 +3510,6 @@ msgstr "" msgid "Polling devices…" msgstr "" -#: src/pages/Shell.tsx -msgid "Portfolio-Check" -msgstr "" - #: src/pages/RoutineEditor.tsx msgid "POST to" msgstr "" @@ -3572,6 +3586,10 @@ msgstr "Provedores" msgid "Pull request" msgstr "Solicitação de pull" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3582,14 +3600,22 @@ msgstr "Na fila" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3809,6 +3835,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3948,6 +3978,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4005,10 +4036,6 @@ msgstr "" msgid "Scene" msgstr "" -#: src/pages/Shell.tsx -msgid "Schlichtheit, Eleganz & Detailtiefe" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/Shell.tsx @@ -4052,15 +4079,12 @@ msgstr "Pesquisando…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Enviar" @@ -4224,7 +4248,9 @@ msgid "Skills" msgstr "Habilidades" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Pular" @@ -4309,6 +4335,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4371,10 +4401,6 @@ msgstr "" msgid "Steps" msgstr "Passos" -#: src/pages/Shell.tsx -msgid "Steve Jobs Audit" -msgstr "" - #: src/pages/Shell.tsx msgid "Stop" msgstr "Parar" @@ -4409,10 +4435,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "subagente" @@ -4498,10 +4520,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Teste" @@ -4592,10 +4610,6 @@ msgstr "Hora do dia" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Título" @@ -4629,10 +4643,6 @@ msgstr "" msgid "Tracing the path…" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Trading" -msgstr "" - #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx msgid "Transcript" @@ -4654,6 +4664,10 @@ msgstr "" msgid "Try again" msgstr "Tentar novamente" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Digite sua resposta" @@ -4670,10 +4684,6 @@ msgstr "" msgid "Uninstall" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Unknown" -msgstr "" - #: src/pages/MessagingSettingsOverlay.tsx msgid "Unlink" msgstr "" @@ -4814,14 +4824,6 @@ msgstr "" msgid "Voice set to {0}." msgstr "" -#: src/pages/Shell.tsx -msgid "Volatilität & Orderbuch-Liquidität" -msgstr "" - -#: src/pages/Shell.tsx -msgid "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." -msgstr "" - #: src/pages/ProjectSessionTimeline.tsx msgid "Waiting" msgstr "" @@ -4859,6 +4861,14 @@ msgstr "" msgid "Weekdays" msgstr "Dias da semana" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "E suas memórias?" @@ -4867,6 +4877,10 @@ msgstr "E suas memórias?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4907,6 +4921,10 @@ msgstr "O que devolver" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4919,6 +4937,14 @@ msgstr "" msgid "When a webhook fires" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Quando executar" @@ -4944,10 +4970,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -5004,8 +5026,11 @@ msgstr "" msgid "Your email address" msgstr "Seu endereço de e-mail" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "O seu nome" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index 77c6aaf..8a741e2 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -21,6 +21,11 @@ msgstr " (непрочитано)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# модель} few {# модели} many {# модел msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -46,11 +56,6 @@ msgstr "{0} (более 10 МБ)" msgid "{0} {unitLabel}" msgstr "{0} {unitLabel}" -#. placeholder {0}: subtitle.count -#: src/components/TradingModeIndicator.tsx -msgid "{0} adapters" -msgstr "" - #. placeholder {0}: block.name #: src/pages/shell/message-cards.tsx msgid "{0} connection" @@ -273,22 +278,6 @@ msgstr "{workingBotName} работает" msgid "/Users/you/projects/acme" msgstr "" -#: src/pages/Shell.tsx -msgid "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "📈 Scanne den Markt nach aktuellen Alpha-Signalen" -msgstr "" - -#: src/components/TradingModeIndicator.tsx -msgid "1 adapter" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "1:1 conversation" msgstr "" @@ -297,10 +286,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "О группах" @@ -366,6 +351,7 @@ msgstr "Активная модель" msgid "Activity" msgstr "Активность" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -403,6 +389,10 @@ msgstr "Добавьте URL-адрес HTTPS-сервера." msgid "Add another" msgstr "Добавить еще" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Добавить Executor" @@ -444,6 +434,10 @@ msgstr "Добавить сервер" msgid "Add server URL" msgstr "Добавьте URL сервера" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Добавить в задачу" @@ -506,6 +500,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -541,10 +555,6 @@ msgstr "" msgid "Allowed once" msgstr "Разрешено один раз" -#: src/pages/Shell.tsx -msgid "Alpha-Signale" -msgstr "" - #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "У вас уже есть аккаунт?" @@ -616,10 +626,6 @@ msgstr "Утвердить" msgid "Approve this server to let your agent use its tools." msgstr "Одобрите этот сервер, чтобы ваш агент мог использовать его инструменты." -#: src/pages/Shell.tsx -msgid "Architektur-Audit" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Archive" msgstr "Архивировать" @@ -720,10 +726,6 @@ msgstr "Авторизовать" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "Автоматические отправители" @@ -759,10 +761,6 @@ msgstr "Вернуться ко входу" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Базовый URL" @@ -772,6 +770,10 @@ msgstr "Базовый URL" msgid "Bearer token" msgstr "Bearer-токен" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Загрузка рабочего стола…" @@ -840,6 +842,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -852,6 +858,7 @@ msgstr "Не удаётся связаться с сервером." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -886,8 +893,8 @@ msgstr "" msgid "Cancelled" msgstr "Отменено" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -944,6 +951,10 @@ msgstr "Проверить." msgid "Check your email" msgstr "Проверьте свою электронную почту" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1151,7 +1162,6 @@ msgid "Command" msgstr "Команда" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1312,8 +1322,8 @@ msgstr "" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Продолжить" @@ -1539,6 +1549,10 @@ msgstr "Не удалось загрузить статус обновления msgid "Could not load voice settings" msgstr "Не удалось загрузить голосовые настройки." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "Не удалось открыть локальные настройки. Запустите локальный сервер и создайте учетную запись владельца, затем повторите попытку." @@ -1691,6 +1705,7 @@ msgstr "Не удалось обновить область памяти по у msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1699,6 +1714,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "Не удалось загрузить настройки обмена сообщениями." +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1840,6 +1856,10 @@ msgstr "" msgid "day" msgstr "день" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "дни" @@ -1848,10 +1868,6 @@ msgstr "дни" msgid "Decline" msgstr "Отклонить" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "" @@ -1952,18 +1968,10 @@ msgstr "Настольное приложение" msgid "Desktop update" msgstr "Обновление настольного приложения" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/pages/Shell.tsx msgid "Dev" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "Прямой MCP" @@ -2070,10 +2078,6 @@ msgstr "" msgid "Drag, drop, or paste an image" msgstr "Перетащите или вставьте изображение" -#: src/components/TradingModeIndicator.tsx -msgid "Dry run" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Duplicate" msgstr "Дублировать" @@ -2086,8 +2090,8 @@ msgstr "напр. Hivenet Agent, Presales, бот табелей" msgid "Earlier message" msgstr "Предыдущее сообщение" -#: src/pages/Shell.tsx -msgid "Echtzeit-Risiko & Drawdown-Analyse" +#: src/components/UserProfileSettings.tsx +msgid "Edit" msgstr "" #: src/components/AskCard.tsx @@ -2139,6 +2143,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "Введите этот код по адресу <0>{0}." +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2167,6 +2179,10 @@ msgstr "Каждый понедельник" msgid "Every month" msgstr "Каждый месяц" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2207,6 +2223,10 @@ msgstr "Внешний диалог" msgid "Extra high" msgstr "Очень высокий" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "неуспешный" @@ -2276,10 +2296,6 @@ msgstr "Завершение подключения MCP…" msgid "Flag unexpected actions" msgstr "Пометить неожиданные действия" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2300,10 +2316,6 @@ msgstr "" msgid "Forgot password?" msgstr "Забыли пароль?" -#: src/pages/Shell.tsx -msgid "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Free" msgstr "Бесплатно" @@ -2344,6 +2356,7 @@ msgstr "Git-событие" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2380,10 +2393,7 @@ msgstr "Обновления группы" msgid "Groups" msgstr "Группы" -#: src/pages/Shell.tsx -msgid "Guten Tag." -msgstr "" - +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2458,10 +2468,6 @@ msgstr "Скрыть пароль" msgid "High" msgstr "Высокий" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2475,10 +2481,6 @@ msgstr "час" msgid "hours" msgstr "часы" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Как часто" @@ -2487,10 +2489,6 @@ msgstr "Как часто" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "Как проверить" @@ -2531,6 +2529,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Наследовать значение по умолчанию" @@ -2582,10 +2584,6 @@ msgstr "Единица интервала" msgid "Isolated" msgstr "изолированный" -#: src/pages/Shell.tsx -msgid "ist bereit." -msgstr "" - #: src/pages/shell/dialogs.tsx msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." msgstr "Его диалог, файлы и задачи будут безвозвратно удалены. Созданные им боты останутся в вашем списке." @@ -2681,10 +2679,6 @@ msgstr "" msgid "Listening — what's the story?" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Live Trading" -msgstr "" - #: src/pages/Shell.tsx msgid "Load earlier messages" msgstr "Загрузить более ранние сообщения" @@ -2761,6 +2755,10 @@ msgstr "Настройки локального сервера" msgid "Log out" msgstr "Выйти" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2771,10 +2769,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2935,10 +2941,6 @@ msgstr "минута" msgid "minutes" msgstr "минуты" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2995,6 +2997,18 @@ msgstr "Больше действий с компьютером" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Переместите их в свою общую память." @@ -3006,7 +3020,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3185,6 +3198,10 @@ msgstr "Сообщений с {peerBotName} пока нет." msgid "No model catalog is available." msgstr "Каталога моделей нет." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3230,7 +3247,6 @@ msgstr "" msgid "None yet" msgstr "Пока нет" -#: src/components/TradingModeIndicator.tsx #: src/pages/PrivacySettingsOverlay.tsx msgid "Not configured" msgstr "" @@ -3243,6 +3259,7 @@ msgstr "Не подключено" msgid "Not in the plugin catalog" msgstr "Нет в каталоге плагинов" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Не сейчас" @@ -3255,14 +3272,11 @@ msgstr "" msgid "Nothing remembered yet" msgstr "Пока ничего не запомнено" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Уведомления" @@ -3276,6 +3290,10 @@ msgstr "Сейчас" msgid "Now using {0}." msgstr "Теперь используется {0}." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "Авторизация OAuth отменена." @@ -3292,10 +3310,6 @@ msgstr "Соединение OAuth не удалось" msgid "OAuth expired" msgstr "Срок действия OAuth истек" -#: src/components/TradingModeIndicator.tsx -msgid "Offline" -msgstr "" - #: src/pages/VoiceSettingsOverlay.tsx msgid "On" msgstr "" @@ -3313,6 +3327,10 @@ msgstr "1-го числа в {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3321,6 +3339,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "Удалить можно только пустые пространства. Сначала удалите их ботов и группы." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3408,10 +3430,6 @@ msgstr "" msgid "PagerDuty incident" msgstr "Инцидент PagerDuty" -#: src/components/TradingModeIndicator.tsx -msgid "Paper" -msgstr "" - #: src/pages/ScratchpadSection.tsx msgid "Park" msgstr "Приостановить" @@ -3492,10 +3510,6 @@ msgstr "" msgid "Polling devices…" msgstr "" -#: src/pages/Shell.tsx -msgid "Portfolio-Check" -msgstr "" - #: src/pages/RoutineEditor.tsx msgid "POST to" msgstr "ОТПРАВИТЬ в" @@ -3572,6 +3586,10 @@ msgstr "Провайдеры" msgid "Pull request" msgstr "Запрос на слияние" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3582,14 +3600,22 @@ msgstr "В очереди" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3809,6 +3835,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3948,6 +3978,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4005,10 +4036,6 @@ msgstr "" msgid "Scene" msgstr "" -#: src/pages/Shell.tsx -msgid "Schlichtheit, Eleganz & Detailtiefe" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/Shell.tsx @@ -4052,15 +4079,12 @@ msgstr "Поиск…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Отправить" @@ -4224,7 +4248,9 @@ msgid "Skills" msgstr "Навыки" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Пропустить" @@ -4309,6 +4335,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4371,10 +4401,6 @@ msgstr "" msgid "Steps" msgstr "Шаги" -#: src/pages/Shell.tsx -msgid "Steve Jobs Audit" -msgstr "" - #: src/pages/Shell.tsx msgid "Stop" msgstr "Остановить" @@ -4409,10 +4435,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "субагент" @@ -4498,10 +4520,6 @@ msgstr "Teams не настроен" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Тест" @@ -4592,10 +4610,6 @@ msgstr "Время суток" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Заголовок" @@ -4629,10 +4643,6 @@ msgstr "Инструменты" msgid "Tracing the path…" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Trading" -msgstr "" - #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx msgid "Transcript" @@ -4654,6 +4664,10 @@ msgstr "" msgid "Try again" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Введите ответ" @@ -4670,10 +4684,6 @@ msgstr "Недоступно" msgid "Uninstall" msgstr "Удалить" -#: src/components/TradingModeIndicator.tsx -msgid "Unknown" -msgstr "" - #: src/pages/MessagingSettingsOverlay.tsx msgid "Unlink" msgstr "Отсоединить" @@ -4814,14 +4824,6 @@ msgstr "" msgid "Voice set to {0}." msgstr "" -#: src/pages/Shell.tsx -msgid "Volatilität & Orderbuch-Liquidität" -msgstr "" - -#: src/pages/Shell.tsx -msgid "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." -msgstr "" - #: src/pages/ProjectSessionTimeline.tsx msgid "Waiting" msgstr "" @@ -4859,6 +4861,14 @@ msgstr "Вебхук" msgid "Weekdays" msgstr "Будни" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "Что делать с его памятью?" @@ -4867,6 +4877,10 @@ msgstr "Что делать с его памятью?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4907,6 +4921,10 @@ msgstr "Что вернуть" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4919,6 +4937,14 @@ msgstr "" msgid "When a webhook fires" msgstr "Когда срабатывает вебхук" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Когда запускать" @@ -4944,10 +4970,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -5004,8 +5026,11 @@ msgstr "" msgid "Your email address" msgstr "Ваш адрес электронной почты" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Ваше имя" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index ba721e1..1274bf5 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -21,6 +21,11 @@ msgstr " (okunmadı)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# model} other {# model}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -46,11 +56,6 @@ msgstr "{0} (10 MiB üzeri)" msgid "{0} {unitLabel}" msgstr "{0} {unitLabel}" -#. placeholder {0}: subtitle.count -#: src/components/TradingModeIndicator.tsx -msgid "{0} adapters" -msgstr "" - #. placeholder {0}: block.name #: src/pages/shell/message-cards.tsx msgid "{0} connection" @@ -273,22 +278,6 @@ msgstr "{workingBotName} çalışıyor" msgid "/Users/you/projects/acme" msgstr "" -#: src/pages/Shell.tsx -msgid "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "📈 Scanne den Markt nach aktuellen Alpha-Signalen" -msgstr "" - -#: src/components/TradingModeIndicator.tsx -msgid "1 adapter" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "1:1 conversation" msgstr "" @@ -297,10 +286,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -366,6 +351,7 @@ msgstr "Etkin model" msgid "Activity" msgstr "Etkinlik" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -403,6 +389,10 @@ msgstr "Bir HTTPS sunucu URL'si gir." msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Executor ekle" @@ -444,6 +434,10 @@ msgstr "Sunucu ekle" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Rutine ekle" @@ -506,6 +500,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -541,10 +555,6 @@ msgstr "" msgid "Allowed once" msgstr "Bir kez izin verildi" -#: src/pages/Shell.tsx -msgid "Alpha-Signale" -msgstr "" - #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "Zaten hesabın var mı?" @@ -616,10 +626,6 @@ msgstr "Onayla" msgid "Approve this server to let your agent use its tools." msgstr "Agent'ının bu sunucunun araçlarını kullanabilmesi için sunucuyu onayla." -#: src/pages/Shell.tsx -msgid "Architektur-Audit" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Archive" msgstr "Arşivle" @@ -720,10 +726,6 @@ msgstr "Yetkilendir" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Agent'ların araçlarını kullanabilmesi için bu sunucuyu yetkilendir. Bir açılır pencere açılır." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -759,10 +761,6 @@ msgstr "Oturum açmaya geri dön" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Temel URL" @@ -772,6 +770,10 @@ msgstr "Temel URL" msgid "Bearer token" msgstr "Bearer token" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Canlı masaüstü başlatılıyor…" @@ -840,6 +842,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -852,6 +858,7 @@ msgstr "Sunucuya ulaşılamıyor." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -886,8 +893,8 @@ msgstr "" msgid "Cancelled" msgstr "İptal edildi" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -944,6 +951,10 @@ msgstr "Durum bildir." msgid "Check your email" msgstr "E-postanızı kontrol edin" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1151,7 +1162,6 @@ msgid "Command" msgstr "Komut" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1312,8 +1322,8 @@ msgstr "Bağlam sınırı" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Devam" @@ -1539,6 +1549,10 @@ msgstr "" msgid "Could not load voice settings" msgstr "Ses ayarları yüklenemedi" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1691,6 +1705,7 @@ msgstr "Varsayılan hafıza kapsamı güncellenemedi" msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1699,6 +1714,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1840,6 +1856,10 @@ msgstr "" msgid "day" msgstr "gün" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "gün" @@ -1848,10 +1868,6 @@ msgstr "gün" msgid "Decline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Varsayılan" @@ -1952,18 +1968,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/pages/Shell.tsx msgid "Dev" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2070,10 +2078,6 @@ msgstr "" msgid "Drag, drop, or paste an image" msgstr "Görüntüyü sürükleyip bırakın veya yapıştırın" -#: src/components/TradingModeIndicator.tsx -msgid "Dry run" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Duplicate" msgstr "Çoğalt" @@ -2086,8 +2090,8 @@ msgstr "örn. Hivenet Agent, Presales, puantaj botu" msgid "Earlier message" msgstr "" -#: src/pages/Shell.tsx -msgid "Echtzeit-Risiko & Drawdown-Analyse" +#: src/components/UserProfileSettings.tsx +msgid "Edit" msgstr "" #: src/components/AskCard.tsx @@ -2139,6 +2143,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "Bu kodu <0>{0} adresinde gir" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2167,6 +2179,10 @@ msgstr "Her pazartesi" msgid "Every month" msgstr "Her ay" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2207,6 +2223,10 @@ msgstr "" msgid "Extra high" msgstr "Çok yüksek" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "başarısız" @@ -2276,10 +2296,6 @@ msgstr "MCP bağlantısı tamamlanıyor…" msgid "Flag unexpected actions" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2300,10 +2316,6 @@ msgstr "" msgid "Forgot password?" msgstr "Parolanızı mı unuttunuz?" -#: src/pages/Shell.tsx -msgid "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Free" msgstr "" @@ -2344,6 +2356,7 @@ msgstr "" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2380,10 +2393,7 @@ msgstr "" msgid "Groups" msgstr "" -#: src/pages/Shell.tsx -msgid "Guten Tag." -msgstr "" - +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2458,10 +2468,6 @@ msgstr "" msgid "High" msgstr "Yüksek" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2475,10 +2481,6 @@ msgstr "saat" msgid "hours" msgstr "saat" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Ne sıklıkla" @@ -2487,10 +2489,6 @@ msgstr "Ne sıklıkla" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "Nasıl kontrol edilir" @@ -2531,6 +2529,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Varsayılanı devral" @@ -2582,10 +2584,6 @@ msgstr "Aralık birimi" msgid "Isolated" msgstr "Yalıtılmış" -#: src/pages/Shell.tsx -msgid "ist bereit." -msgstr "" - #: src/pages/shell/dialogs.tsx msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." msgstr "Sohbeti, dosyaları ve rutinleri kalıcı olarak silinecek. Oluşturduğu botlar listende kalır." @@ -2681,10 +2679,6 @@ msgstr "" msgid "Listening — what's the story?" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Live Trading" -msgstr "" - #: src/pages/Shell.tsx msgid "Load earlier messages" msgstr "Önceki mesajları yükle" @@ -2761,6 +2755,10 @@ msgstr "" msgid "Log out" msgstr "Çıkış yap" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2771,10 +2769,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2935,10 +2941,6 @@ msgstr "dakika" msgid "minutes" msgstr "dakika" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2995,6 +2997,18 @@ msgstr "" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Bunları ortak hafızana taşı." @@ -3006,7 +3020,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3185,6 +3198,10 @@ msgstr "{peerBotName} ile henüz mesaj yok." msgid "No model catalog is available." msgstr "Kullanılabilir model kataloğu yok." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3230,7 +3247,6 @@ msgstr "" msgid "None yet" msgstr "Henüz yok" -#: src/components/TradingModeIndicator.tsx #: src/pages/PrivacySettingsOverlay.tsx msgid "Not configured" msgstr "" @@ -3243,6 +3259,7 @@ msgstr "Bağlı değil" msgid "Not in the plugin catalog" msgstr "" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Şimdi değil" @@ -3255,14 +3272,11 @@ msgstr "Hâlâ çalışan bir şey yok" msgid "Nothing remembered yet" msgstr "Henüz hatırlanan bir şey yok" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Bildirimler" @@ -3276,6 +3290,10 @@ msgstr "Şimdi" msgid "Now using {0}." msgstr "Artık {0} kullanılıyor." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth yetkilendirmesi iptal edildi." @@ -3292,10 +3310,6 @@ msgstr "OAuth bağlantısı başarısız" msgid "OAuth expired" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Offline" -msgstr "" - #: src/pages/VoiceSettingsOverlay.tsx msgid "On" msgstr "" @@ -3313,6 +3327,10 @@ msgstr "her ayın 1'inde saat {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3321,6 +3339,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3408,10 +3430,6 @@ msgstr "" msgid "PagerDuty incident" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Paper" -msgstr "" - #: src/pages/ScratchpadSection.tsx msgid "Park" msgstr "Beklet" @@ -3492,10 +3510,6 @@ msgstr "" msgid "Polling devices…" msgstr "" -#: src/pages/Shell.tsx -msgid "Portfolio-Check" -msgstr "" - #: src/pages/RoutineEditor.tsx msgid "POST to" msgstr "" @@ -3572,6 +3586,10 @@ msgstr "Sağlayıcılar" msgid "Pull request" msgstr "Çekme isteği" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3582,14 +3600,22 @@ msgstr "Kuyrukta" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3809,6 +3835,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3948,6 +3978,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4005,10 +4036,6 @@ msgstr "" msgid "Scene" msgstr "" -#: src/pages/Shell.tsx -msgid "Schlichtheit, Eleganz & Detailtiefe" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/Shell.tsx @@ -4052,15 +4079,12 @@ msgstr "Aranıyor…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Gönder" @@ -4224,7 +4248,9 @@ msgid "Skills" msgstr "Beceriler" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Atla" @@ -4309,6 +4335,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4371,10 +4401,6 @@ msgstr "" msgid "Steps" msgstr "Adımlar" -#: src/pages/Shell.tsx -msgid "Steve Jobs Audit" -msgstr "" - #: src/pages/Shell.tsx msgid "Stop" msgstr "Durdur" @@ -4409,10 +4435,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "alt ajan" @@ -4498,10 +4520,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Dene" @@ -4592,10 +4610,6 @@ msgstr "Günün saati" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Başlık" @@ -4629,10 +4643,6 @@ msgstr "" msgid "Tracing the path…" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Trading" -msgstr "" - #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx msgid "Transcript" @@ -4654,6 +4664,10 @@ msgstr "" msgid "Try again" msgstr "Tekrar dene" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Yanıtını yaz" @@ -4670,10 +4684,6 @@ msgstr "" msgid "Uninstall" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Unknown" -msgstr "" - #: src/pages/MessagingSettingsOverlay.tsx msgid "Unlink" msgstr "" @@ -4814,14 +4824,6 @@ msgstr "" msgid "Voice set to {0}." msgstr "" -#: src/pages/Shell.tsx -msgid "Volatilität & Orderbuch-Liquidität" -msgstr "" - -#: src/pages/Shell.tsx -msgid "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." -msgstr "" - #: src/pages/ProjectSessionTimeline.tsx msgid "Waiting" msgstr "" @@ -4859,6 +4861,14 @@ msgstr "" msgid "Weekdays" msgstr "Hafta içi" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "Hafızası ne olsun?" @@ -4867,6 +4877,10 @@ msgstr "Hafızası ne olsun?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4907,6 +4921,10 @@ msgstr "Ne döndürülecek" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4919,6 +4937,14 @@ msgstr "" msgid "When a webhook fires" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Ne zaman çalışacak" @@ -4944,10 +4970,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -5004,8 +5026,11 @@ msgstr "" msgid "Your email address" msgstr "E-posta adresin" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Adın" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index 38d30d3..1b0b3a0 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -21,6 +21,11 @@ msgstr "(未读)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# 个模型} other {# 个模型}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -46,11 +56,6 @@ msgstr "{0}(超过 10 MiB)" msgid "{0} {unitLabel}" msgstr "{0} {unitLabel}" -#. placeholder {0}: subtitle.count -#: src/components/TradingModeIndicator.tsx -msgid "{0} adapters" -msgstr "" - #. placeholder {0}: block.name #: src/pages/shell/message-cards.tsx msgid "{0} connection" @@ -273,22 +278,6 @@ msgstr "{workingBotName} 正在工作" msgid "/Users/you/projects/acme" msgstr "" -#: src/pages/Shell.tsx -msgid "⚡ Führe einen autonomen Portfolio- und Risiko-Check durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "💎 Führe ein Steve Jobs UX & Architektur-Audit durch" -msgstr "" - -#: src/pages/Shell.tsx -msgid "📈 Scanne den Markt nach aktuellen Alpha-Signalen" -msgstr "" - -#: src/components/TradingModeIndicator.tsx -msgid "1 adapter" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "1:1 conversation" msgstr "" @@ -297,10 +286,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -366,6 +351,7 @@ msgstr "当前模型" msgid "Activity" msgstr "动态" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -403,6 +389,10 @@ msgstr "请填写 HTTPS 服务器 URL。" msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "添加 Executor" @@ -444,6 +434,10 @@ msgstr "添加服务器" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "加入例行任务" @@ -506,6 +500,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -541,10 +555,6 @@ msgstr "" msgid "Allowed once" msgstr "已允许一次" -#: src/pages/Shell.tsx -msgid "Alpha-Signale" -msgstr "" - #: src/pages/Auth.tsx msgid "Already have an account?" msgstr "已有账户?" @@ -616,10 +626,6 @@ msgstr "批准" msgid "Approve this server to let your agent use its tools." msgstr "批准此服务器后,Agent 才能使用它的工具。" -#: src/pages/Shell.tsx -msgid "Architektur-Audit" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Archive" msgstr "归档" @@ -720,10 +726,6 @@ msgstr "授权" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "授权此服务器后,Agent 才能使用其工具。将打开弹窗。" -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -759,10 +761,6 @@ msgstr "返回登录" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Base URL" @@ -772,6 +770,10 @@ msgstr "Base URL" msgid "Bearer token" msgstr "Bearer token" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "正在启动实时桌面…" @@ -840,6 +842,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -852,6 +858,7 @@ msgstr "无法连接服务器。" #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -886,8 +893,8 @@ msgstr "" msgid "Cancelled" msgstr "已取消" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -944,6 +951,10 @@ msgstr "报到。" msgid "Check your email" msgstr "请查看邮箱" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1151,7 +1162,6 @@ msgid "Command" msgstr "命令" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1312,8 +1322,8 @@ msgstr "上下文限制" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "继续" @@ -1539,6 +1549,10 @@ msgstr "无法加载更新状态" msgid "Could not load voice settings" msgstr "无法加载语音设置" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1691,6 +1705,7 @@ msgstr "无法更新默认记忆范围" msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1699,6 +1714,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "无法加载消息设置" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1840,6 +1856,10 @@ msgstr "" msgid "day" msgstr "天" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "天" @@ -1848,10 +1868,6 @@ msgstr "天" msgid "Decline" msgstr "拒绝" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "默认" @@ -1952,18 +1968,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/pages/Shell.tsx msgid "Dev" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2070,10 +2078,6 @@ msgstr "" msgid "Drag, drop, or paste an image" msgstr "拖放或粘贴图片" -#: src/components/TradingModeIndicator.tsx -msgid "Dry run" -msgstr "" - #: src/pages/BotContextMenu.tsx msgid "Duplicate" msgstr "复制" @@ -2086,8 +2090,8 @@ msgstr "例如 Hivenet Agent、Presales、工时表机器人" msgid "Earlier message" msgstr "更早的消息" -#: src/pages/Shell.tsx -msgid "Echtzeit-Risiko & Drawdown-Analyse" +#: src/components/UserProfileSettings.tsx +msgid "Edit" msgstr "" #: src/components/AskCard.tsx @@ -2139,6 +2143,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "在 <0>{0} 输入此代码" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2167,6 +2179,10 @@ msgstr "每周一" msgid "Every month" msgstr "每月" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2207,6 +2223,10 @@ msgstr "" msgid "Extra high" msgstr "极高" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "失败" @@ -2276,10 +2296,6 @@ msgstr "正在完成 MCP 连接…" msgid "Flag unexpected actions" msgstr "标记异常操作" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2300,10 +2316,6 @@ msgstr "" msgid "Forgot password?" msgstr "忘记密码?" -#: src/pages/Shell.tsx -msgid "Frag {activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews." -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Free" msgstr "免费" @@ -2344,6 +2356,7 @@ msgstr "Git 事件" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2380,10 +2393,7 @@ msgstr "" msgid "Groups" msgstr "" -#: src/pages/Shell.tsx -msgid "Guten Tag." -msgstr "" - +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2458,10 +2468,6 @@ msgstr "隐藏密码" msgid "High" msgstr "高" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2475,10 +2481,6 @@ msgstr "小时" msgid "hours" msgstr "小时" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "频率" @@ -2487,10 +2489,6 @@ msgstr "频率" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "如何检查" @@ -2531,6 +2529,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "沿用默认" @@ -2582,10 +2584,6 @@ msgstr "间隔单位" msgid "Isolated" msgstr "隔离" -#: src/pages/Shell.tsx -msgid "ist bereit." -msgstr "" - #: src/pages/shell/dialogs.tsx msgid "Its conversation, files, and routines will be permanently deleted. Bots it created stay in your list." msgstr "它的对话、文件和例行任务将被永久删除。它创建的 Bot 仍留在列表中。" @@ -2681,10 +2679,6 @@ msgstr "" msgid "Listening — what's the story?" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Live Trading" -msgstr "" - #: src/pages/Shell.tsx msgid "Load earlier messages" msgstr "加载更早的消息" @@ -2761,6 +2755,10 @@ msgstr "" msgid "Log out" msgstr "退出登录" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2771,10 +2769,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2935,10 +2941,6 @@ msgstr "分钟" msgid "minutes" msgstr "分钟" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2995,6 +2997,18 @@ msgstr "" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "把它们移到共享记忆。" @@ -3006,7 +3020,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3185,6 +3198,10 @@ msgstr "还没有与 {peerBotName} 的消息。" msgid "No model catalog is available." msgstr "没有可用的模型目录。" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3230,7 +3247,6 @@ msgstr "" msgid "None yet" msgstr "暂无" -#: src/components/TradingModeIndicator.tsx #: src/pages/PrivacySettingsOverlay.tsx msgid "Not configured" msgstr "" @@ -3243,6 +3259,7 @@ msgstr "未连接" msgid "Not in the plugin catalog" msgstr "不在插件目录中" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "暂不" @@ -3255,14 +3272,11 @@ msgstr "没有仍在运行的任务" msgid "Nothing remembered yet" msgstr "暂无记忆" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "通知" @@ -3276,6 +3290,10 @@ msgstr "现在" msgid "Now using {0}." msgstr "正在使用 {0}。" +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth 授权已取消。" @@ -3292,10 +3310,6 @@ msgstr "OAuth 连接失败" msgid "OAuth expired" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Offline" -msgstr "" - #: src/pages/VoiceSettingsOverlay.tsx msgid "On" msgstr "" @@ -3313,6 +3327,10 @@ msgstr "每月 1 日 {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3321,6 +3339,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3408,10 +3430,6 @@ msgstr "" msgid "PagerDuty incident" msgstr "PagerDuty 事件" -#: src/components/TradingModeIndicator.tsx -msgid "Paper" -msgstr "" - #: src/pages/ScratchpadSection.tsx msgid "Park" msgstr "搁置" @@ -3492,10 +3510,6 @@ msgstr "" msgid "Polling devices…" msgstr "" -#: src/pages/Shell.tsx -msgid "Portfolio-Check" -msgstr "" - #: src/pages/RoutineEditor.tsx msgid "POST to" msgstr "POST 到" @@ -3572,6 +3586,10 @@ msgstr "提供方" msgid "Pull request" msgstr "拉取请求" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3582,14 +3600,22 @@ msgstr "排队中" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3809,6 +3835,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3948,6 +3978,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4005,10 +4036,6 @@ msgstr "" msgid "Scene" msgstr "" -#: src/pages/Shell.tsx -msgid "Schlichtheit, Eleganz & Detailtiefe" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/Shell.tsx @@ -4052,15 +4079,12 @@ msgstr "正在搜索…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "发送" @@ -4224,7 +4248,9 @@ msgid "Skills" msgstr "技能" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "跳过" @@ -4309,6 +4335,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4371,10 +4401,6 @@ msgstr "" msgid "Steps" msgstr "步骤" -#: src/pages/Shell.tsx -msgid "Steve Jobs Audit" -msgstr "" - #: src/pages/Shell.tsx msgid "Stop" msgstr "停止" @@ -4409,10 +4435,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "子 Agent" @@ -4498,10 +4520,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "测试" @@ -4592,10 +4610,6 @@ msgstr "时间" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "标题" @@ -4629,10 +4643,6 @@ msgstr "" msgid "Tracing the path…" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Trading" -msgstr "" - #: src/components/voice/VoiceStage.tsx #: src/pages/Shell.tsx msgid "Transcript" @@ -4654,6 +4664,10 @@ msgstr "" msgid "Try again" msgstr "重试" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "输入你的回答" @@ -4670,10 +4684,6 @@ msgstr "不可用" msgid "Uninstall" msgstr "" -#: src/components/TradingModeIndicator.tsx -msgid "Unknown" -msgstr "" - #: src/pages/MessagingSettingsOverlay.tsx msgid "Unlink" msgstr "取消关联" @@ -4814,14 +4824,6 @@ msgstr "" msgid "Voice set to {0}." msgstr "" -#: src/pages/Shell.tsx -msgid "Volatilität & Orderbuch-Liquidität" -msgstr "" - -#: src/pages/Shell.tsx -msgid "Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt." -msgstr "" - #: src/pages/ProjectSessionTimeline.tsx msgid "Waiting" msgstr "" @@ -4859,6 +4861,14 @@ msgstr "Webhook" msgid "Weekdays" msgstr "工作日" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "它的记忆怎么处理?" @@ -4867,6 +4877,10 @@ msgstr "它的记忆怎么处理?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4907,6 +4921,10 @@ msgstr "返回什么" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4919,6 +4937,14 @@ msgstr "" msgid "When a webhook fires" msgstr "当 Webhook 触发时" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "何时运行" @@ -4944,10 +4970,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -5004,8 +5026,11 @@ msgstr "" msgid "Your email address" msgstr "你的邮箱" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "你的名字" From ef15ec61b15cbb3029d94ea5ef3e7337ebd12f83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:52:39 +0000 Subject: [PATCH 26/38] test(web): look for the bot reply in the transcript only The main conversation's sidebar row now previews the latest reply, so the unscoped text lookup matched the sidebar too and failed in strict mode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/message-hover-actions.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/message-hover-actions.spec.ts b/apps/web/e2e/message-hover-actions.spec.ts index c511059..7ebbbf6 100644 --- a/apps/web/e2e/message-hover-actions.spec.ts +++ b/apps/web/e2e/message-hover-actions.spec.ts @@ -89,7 +89,7 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( `hover-bot-${stamp} ${"please look into this ".repeat(7)}`, ); const botText = page.getByText(/i handled: hover-bot-/); - await expect(botText).toBeVisible({ timeout: 20_000 }); + await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 }); const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first(); await expect(botRow).toBeVisible(); await expectRailAtRest(page, botRow); @@ -404,7 +404,9 @@ test.describe("touch message actions", () => { const composer = page.getByRole("combobox", { name: /^Message/ }); await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`); const botText = page.getByText(/i handled: touch-bot-/); - await expect(botText).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId("transcript").getByText(/i handled: touch-bot-/)).toBeVisible({ + timeout: 20_000, + }); const row = page .getByTestId("transcript") .locator("[data-message-id]") From 2273e8f82b0e7a39a740d3f75aef327252949e7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:08:39 +0000 Subject: [PATCH 27/38] test(onboarding): link the foreign folder while its tenant owns the deployment Since #112 only the deployment owner can link a host folder, so the discovery test creates the other tenant's linked space while that tenant holds the deployment, then hands the deployment to the scanning owner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/testkit/src/onboarding-discovery.postgres.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/testkit/src/onboarding-discovery.postgres.test.ts b/packages/testkit/src/onboarding-discovery.postgres.test.ts index 671131b..e1656bc 100644 --- a/packages/testkit/src/onboarding-discovery.postgres.test.ts +++ b/packages/testkit/src/onboarding-discovery.postgres.test.ts @@ -149,13 +149,15 @@ describeWithDatabase("onboarding discovery through the API", () => { it("creates the chosen projects in the owner's own spaces and saves only the chosen facts", async () => { const owner = await account("discovery-creator"); const intruder = await account("discovery-intruder"); - await makeDeploymentOwner(owner.actor); const atlasFolder = path.join(home, "code/atlas"); - // Another tenant already linked a space with the same name to the same folder. + // Another tenant already linked a space with the same name to the same folder, back when + // they held the deployment (only the deployment owner can link host folders). + await makeDeploymentOwner(intruder.actor); const foreign = await rpc<{ id: string }>(app, intruder.cookie, "spaces/create", { name: "atlas", workspacePath: atlasFolder, }); + await makeDeploymentOwner(owner.actor); const scan = await finishedScan(owner.cookie); const choice = { From f071c2257cf67d6f4d0dc1d85351ef668ba7b730 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:45:43 +0000 Subject: [PATCH 28/38] test: look up the home main conversation in the scheduling journey Since every project has its own main conversation, the journey that schedules for a specialist found the user's main conversation with findFirst over all of them. With the Lab project's conversation also matching, it picked either one, so the check failed about one run in three. The lookup now names the home space, where the specialist joined and the routine is created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/testkit/src/journeys.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index f236434..3ab5e55 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -1305,8 +1305,9 @@ describeJourneys("required product journeys", () => { }); await joinMainConversation(prisma, { userId: me.userId, botIds: [writer.id] }); + // Lab has a main conversation of its own; the home one is where Writer joined. const conversation = await prisma.chatGroup.findFirstOrThrow({ - where: { userId: me.userId, kind: "main" }, + where: { userId: me.userId, spaceId: main.spaceId, kind: "main" }, include: { thread: true }, }); const inMain = await scheduleAndWake("Writer", "main-digest"); From 182a9288c216b9fbece71dc08f16405134657272 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:41:43 +0000 Subject: [PATCH 29/38] test: expect the home main conversation's ask in another space's inbox Every project now has a main conversation of its own, so an ask waiting in the home main conversation is not visible from a project and its inbox lists it, answered in the home space. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/testkit/src/inbox.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/testkit/src/inbox.test.ts b/packages/testkit/src/inbox.test.ts index 22dfc2f..8016419 100644 --- a/packages/testkit/src/inbox.test.ts +++ b/packages/testkit/src/inbox.test.ts @@ -207,14 +207,20 @@ describeInbox("inbox.list", () => { // Items of the current space carry no space. expect(items.filter((item) => item.space).map((item) => item.bot.id)).toEqual([remote.id]); - // From the other space, the same items with the sides swapped. + // From the other space, the same items with the sides swapped, plus the ask in the home + // main conversation: Remote's own main conversation does not show it. const fromRemote = await listInbox(app, cookie, remoteSpace.id); expect(fromRemote.map((item) => [item.kind, item.space?.id ?? null])).toEqual([ ["approval", me.spaceId], + ["ask", me.spaceId], ["takeover", me.spaceId], ["ask", null], ["unread", me.spaceId], ]); + expect(fromRemote[1]).toMatchObject({ + bot: expect.objectContaining({ id: scout.id }), + target: { groupId: main.groupId }, + }); }); it("answers an item from another space only in that space and only for its owner", async () => { From 4ba7d843b1b8bf7657b1f9651afa88aea4c9c989 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:00:43 +0200 Subject: [PATCH 30/38] test: fix journey 5c lookup and chart-links warmup (#126) Look up the home main conversation by spaceId in journey 5c, and warm the chart-links E2E so the first Observable Plot import is not a 20s timeout. --- apps/web/e2e/chart-links.spec.ts | 4 ++++ packages/testkit/src/journeys.test.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/e2e/chart-links.spec.ts b/apps/web/e2e/chart-links.spec.ts index c6e1133..81f6950 100644 --- a/apps/web/e2e/chart-links.spec.ts +++ b/apps/web/e2e/chart-links.spec.ts @@ -4,6 +4,10 @@ import { captureScreenshot } from "./helpers"; const fixture = "/e2e/fixtures/chart-links.html"; test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => { + test.setTimeout(120_000); + // ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below. + await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`); + await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 }); for (const inspect of ["0", "1"]) { for (const href of [ 'javascript:void(document.body.dataset.plotExecuted="yes")', diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index f5ae027..d6d8d98 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -1301,8 +1301,9 @@ describeJourneys("required product journeys", () => { }); await joinMainConversation(prisma, { userId: me.userId, botIds: [writer.id] }); + // Lab has a main conversation of its own; the home one is where Writer joined. const conversation = await prisma.chatGroup.findFirstOrThrow({ - where: { userId: me.userId, kind: "main" }, + where: { userId: me.userId, spaceId: main.spaceId, kind: "main" }, include: { thread: true }, }); const inMain = await scheduleAndWake("Writer", "main-digest"); From c4c5ce3c2da028aa1a2aed54dacdef4744625add Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:37:24 +0200 Subject: [PATCH 31/38] fix(ui): polish the light palette, center the stage bloom (#108) * fix(ui): make the light theme a real light theme The web shell hardcoded dark surfaces (panel hex, white/black alphas, white text), so switching to light left the sidebar, chat, composer and side panel dark with ink text on top. Shell surfaces now use the shared tokens: light gets a warm paper frame with white panels, hairline borders and short soft shadows; dark keeps its look via dark: variants. - Light palette: visible borders (border was equal to muted), white primary-foreground, lighter user bubble, readable warning/success. - Selected sidebar row lives in one place (roster-row-style). - Markdown, inline code, code blocks, quotes and scrollbars follow tokens. - cn() now knows the text-ui-* scale is a font size; tailwind-merge treated it as a color and dropped labels' color on resized buttons (sign-up submit rendered ink on ink). - Stage bloom was offset left by a double translateX. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT * refactor(ui-web): leave the cn() fix to #115 #115 fixes the same tailwind-merge bug more completely (text, control, icon and spacing scales, guarded against styles.css) and adds the same two files, so this branch drops its narrower copy to avoid the overlap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT --------- Co-authored-by: Claude --- apps/web/e2e/ui-appearance.spec.ts | 3 ++ apps/web/index.html | 2 +- .../onboarding/OnboardingScanProgress.tsx | 4 +-- apps/web/src/components/stage/StageHero.tsx | 18 +++++----- .../src/components/stage/TextStageCanvas.tsx | 2 +- apps/web/src/pages/shell/message-cards.tsx | 8 ++--- apps/web/src/pages/shell/roster-row-style.ts | 2 +- packages/ui-tokens/src/index.ts | 34 +++++++++---------- packages/ui-tokens/src/tokens.css | 34 +++++++++---------- packages/ui-web/src/bot-avatar.tsx | 4 +-- packages/ui-web/src/evoo-brand.tsx | 2 +- 11 files changed, 58 insertions(+), 55 deletions(-) diff --git a/apps/web/e2e/ui-appearance.spec.ts b/apps/web/e2e/ui-appearance.spec.ts index e72d12e..6bda945 100644 --- a/apps/web/e2e/ui-appearance.spec.ts +++ b/apps/web/e2e/ui-appearance.spec.ts @@ -67,6 +67,9 @@ test("account settings appearance control switches to light mode", async ({ page await settings.getByRole("button", { name: "Close user settings" }).click(); await expect(settings).toBeHidden(); await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + // Light surfaces stay light: no dark panel or white-on-paper text survives the switch. + await expect(page.locator("main").first()).toHaveCSS("background-color", "rgb(255, 255, 255)"); + await expect(page.locator("aside").first()).toHaveCSS("background-color", "rgb(245, 245, 242)"); await captureScreenshot(page, testInfo, "ui-appearance-light-shell"); await captureSidebarSearchSelected(page, testInfo, "sidebar-search-selected-light"); diff --git a/apps/web/index.html b/apps/web/index.html index c626fbd..3d2749a 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -26,7 +26,7 @@ document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; const meta = document.querySelector('meta[name="theme-color"]'); - if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a"); + if (meta) meta.setAttribute("content", theme === "light" ? "#ffffff" : "#07080a"); })();