Merge branch 'claude/phase2-coach' into claude/phase3-media

# Conflicts:
#	apps/mobile/lib/api.test.ts
#	apps/web/src/locales/de/messages.po
#	apps/web/src/locales/en/messages.po
#	apps/web/src/locales/es/messages.po
#	apps/web/src/locales/hi/messages.po
#	apps/web/src/locales/ko/messages.po
#	apps/web/src/locales/pt-BR/messages.po
#	apps/web/src/locales/ru/messages.po
#	apps/web/src/locales/tr/messages.po
#	apps/web/src/locales/zh-CN/messages.po
#	apps/web/src/pages/SettingsOverlay.tsx
#	apps/web/src/pages/shell/message-cards.tsx
#	packages/adapter-kit/src/index.ts
#	packages/adapters/src/builtin-tools.ts
#	packages/adapters/src/executor-tools.ts
#	packages/adapters/src/index.ts
This commit is contained in:
Claude 2026-10-04 08:10:33 +00:00
commit 08cff7b26a
No known key found for this signature in database
177 changed files with 14605 additions and 3481 deletions

View File

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

View File

@ -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 {
@ -38,13 +37,14 @@ import {
destroyBot,
EmailEmulator,
EncryptedSecretStore,
ExpoPushProvider,
ExpoPushSender,
GraphileJobPublisher,
GraphileJobWorkerHost,
InMemoryJobQueue,
InMemoryRealtimeFanout,
InstalledConnectorProvider,
IntegrationProviderSettings,
importLegacyPushTokens,
isComposioEnabled,
isMessagingSurfaceEnabled,
isPipedreamEnabled,
@ -58,9 +58,9 @@ import {
PiOAuthLogins,
PipedreamConnector,
PostgresRealtimeFanout,
PushNotificationProvider,
pipedreamConfigFromEnv,
piSessionsRoot,
pushTokenPath,
reconcileCloudAgents,
reconcileComputerUpdates,
reconcileDelegatedTasks,
@ -69,6 +69,9 @@ import {
SmtpEmailProvider,
SpaceMemoryProviderResolver,
toTeamChatInbound,
VapidKeys,
vapidEnvFromProcess,
WebPushSender,
} from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core";
@ -316,7 +319,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,
@ -372,7 +382,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.
@ -483,6 +492,7 @@ export async function createApp(
secrets,
oauthLogins,
integrationSettings,
vapid,
mcpOAuth,
composio: stack.composio,
connectors: stack.connector,
@ -580,7 +590,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();

96
apps/api/src/coach.ts Normal file
View File

@ -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<T>(work: () => Promise<T>): Promise<T> {
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<CoachToday> => 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<Habit[]> =>
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<CheckIn> =>
mapErrors(() => logCheckIn(prisma, actor.userId, input)),
listCheckIns: (actor: Actor, input: CheckInFilter) =>
mapErrors(() => listCheckIns(prisma, actor.userId, input)),
getRhythm: (actor: Actor): Promise<CoachRhythm> => getCoachRhythm(prisma, actor.userId),
setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise<CoachRhythm> =>
setCoachRhythm(deps, { userId: actor.userId, rhythm }),
};
}

View File

@ -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<string | null> {
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 <App>" 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<void> {
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,
});
}
}

View File

@ -18,9 +18,11 @@ function run(
status: "waiting_input" | "waiting_takeover",
thread: Partial<InboxRunRow["thread"]> = {},
bot = scout,
spaceId = "space-home",
): InboxRunRow {
return {
id,
spaceId,
status,
updatedAt: at(0),
bot,
@ -48,8 +50,10 @@ const approval = {
function unreadThread(
bot: InboxUnreadThreadRow["bot"],
latest?: { id: string; text: string; minute: number },
spaceId = "space-home",
): InboxUnreadThreadRow {
return {
spaceId,
createdAt: at(0),
bot,
messages: latest
@ -227,4 +231,64 @@ describe("buildInboxItems", () => {
});
expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() });
});
it("lists asks from another space's main conversation, but not the open space's own", () => {
const project = { id: "space-launch", name: "Launch" };
const main = (groupId: string) => ({
botId: null,
groupId,
group: { kind: "main", archivedAt: null },
});
const items = buildInboxItems({
runs: [
run("home-main", "waiting_input", main("group-main-home")),
run("project-main", "waiting_input", main("group-main-launch"), writer, project.id),
],
messages: [
message("ask-home", "home-main", [question], 1),
message("ask-project", "project-main", [question], 2),
],
unreadThreads: [],
otherSpaces: new Map([[project.id, project]]),
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
runId: "project-main",
space: project,
target: { groupId: "group-main-launch" },
});
});
it("names the space of items from the user's other spaces", () => {
const side = { id: "space-side", name: "Side project" };
const items = buildInboxItems({
runs: [
run("run-home", "waiting_input"),
run("run-side", "waiting_input", {}, writer, side.id),
run("takeover-side", "waiting_takeover", {}, writer, side.id),
],
messages: [
message("ask-home", "run-home", [question], 1),
message("ask-side", "run-side", [question], 2),
],
unreadThreads: [
unreadThread(
{ id: "bot-quiet", name: "Quiet", color: "#3EC5A8" },
{ id: "quiet-latest", text: "Done", minute: 3 },
side.id,
),
],
otherSpaces: new Map([[side.id, side]]),
});
expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([
["takeover", "Side project"],
["ask", null],
["ask", "Side project"],
["unread", "Side project"],
]);
expect(items[1]).not.toHaveProperty("space");
expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } });
});
});

View File

@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { promptSnippet } from "./runs.js";
/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */
/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */
const INBOX_RUN_LIMIT = 50;
const INBOX_UNREAD_LIMIT = 50;
const INBOX_PREVIEW_LENGTH = 280;
@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280;
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
type InboxBot = { id: string; name: string; color: string };
type InboxSpace = NonNullable<InboxItem["space"]>;
export interface InboxRunRow {
id: string;
spaceId: string;
status: string;
updatedAt: Date;
bot: InboxBot;
@ -33,6 +35,7 @@ export interface InboxMessageRow {
}
export interface InboxUnreadThreadRow {
spaceId: string;
createdAt: Date;
bot: InboxBot | null;
/** The newest bot message, if any. */
@ -73,12 +76,14 @@ function previewText(blocks: unknown): string {
}
/**
* Where an ask can be answered from. The main conversation already shows its own asks, and an
* archived group or an external conversation has no thread target to answer through.
* Where an ask can be answered from. The open space's main conversation already shows its own
* asks (a project's main conversation does not), and an archived group or an external
* conversation has no thread target to answer through.
*/
function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null {
function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null {
if (thread.groupId) {
if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null;
if (!thread.group || thread.group.archivedAt) return null;
if (thread.group.kind === "main" && currentSpace) return null;
return { groupId: thread.groupId };
}
return thread.botId ? { botId: thread.botId } : null;
@ -95,14 +100,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number {
/**
* Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
* everything that waits on the user first, then unread threads not already covered, each
* oldest first.
* oldest first. Items from another space name it.
*/
export function buildInboxItems(input: {
runs: InboxRunRow[];
/** Ask and takeover messages of those runs, newest first. */
messages: InboxMessageRow[];
unreadThreads: InboxUnreadThreadRow[];
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
otherSpaces?: ReadonlyMap<string, InboxSpace>;
}): InboxItem[] {
const spaceOf = (spaceId: string) => {
const space = input.otherSpaces?.get(spaceId);
return space ? { space } : {};
};
const messagesByRun = new Map<string, InboxMessageRow[]>();
for (const message of input.messages) {
if (!message.runId) continue;
@ -115,13 +126,14 @@ export function buildInboxItems(input: {
for (const run of input.runs) {
const messages = messagesByRun.get(run.id) ?? [];
if (run.status === "waiting_input") {
const target = askTarget(run.thread);
const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId));
const pending = target ? newest(messages, pendingAsk) : undefined;
if (!target || !pending) continue;
waiting.push({
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 +147,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 +167,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 +179,34 @@ export function buildInboxItems(input: {
const botSelect = { id: true, name: true, color: true } as const;
/** The current space's inbox: only the actor's own bots, runs and threads. */
/**
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
* being deleted.
*/
async function otherInboxSpaces(
prisma: PrismaClient,
actor: Actor,
): Promise<Map<string, InboxSpace>> {
const memberships = await prisma.spaceMember.findMany({
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
select: { space: { select: { id: true, name: true } } },
});
return new Map(memberships.map(({ space }) => [space.id, space]));
}
/**
* The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are
* answered and opened in their own space, where the thread procedures check membership and
* ownership again.
*/
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
const otherSpaces = await otherInboxSpaces(prisma, actor);
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
const ownBot = { userId: actor.userId, archivedAt: null };
const [runs, unreadThreads] = await Promise.all([
prisma.run.findMany({
where: {
spaceId: actor.spaceId,
spaceId: { in: spaceIds },
userId: actor.userId,
status: { in: ["waiting_input", "waiting_takeover"] },
bot: ownBot,
@ -179,6 +214,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
},
select: {
id: true,
spaceId: true,
status: true,
threadId: true,
updatedAt: true,
@ -197,12 +233,13 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
// Only specialists' own threads; group threads, the main conversation included, stay out.
prisma.thread.findMany({
where: {
spaceId: actor.spaceId,
spaceId: { in: spaceIds },
userId: actor.userId,
unread: true,
bot: { ...ownBot, kind: "specialist" },
},
select: {
spaceId: true,
createdAt: true,
bot: { select: botSelect },
messages: {
@ -232,5 +269,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
orderBy: { seq: "desc" },
select: { id: true, runId: true, blocks: true, createdAt: true },
});
return buildInboxItems({ runs, messages, unreadThreads });
return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
}

View File

@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
closingNote,
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("closingNote", () => {
const vienna = (iso: string, locale = "de-AT") =>
intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso));
const note = (rhythm: Parameters<typeof closingNote>[0], context: ReturnType<typeof vienna>) => {
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(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(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(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", () => {
expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({
locale: "en",
timeZone: "UTC",
});
});
});

View File

@ -0,0 +1,297 @@
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, 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.
* 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<MessageBlock, { kind: "choice" }>;
type ChoiceOption = ChoiceBlock["options"][number];
/** 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;
jobs: JobPublisher;
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<void>;
}
export interface FocusOption {
id: string;
letter: string;
label: 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",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants help with day-to-day work first",
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
apps: ["gmail", "googlecalendar", "slack"],
fact: "Wants help with inbox and email first",
},
{
id: "research",
letter: "C",
label: "Research & writing",
apps: ["hackernews", "notion", "googledocs"],
fact: "Wants help with research and writing first",
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
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<string, CoachRhythm> = {
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<string, QuietHours> = {
"22-07": { start: "22:00", end: "07:00" },
"23-08": { start: "23:00", end: "08:00" },
};
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 ({ prisma, userId }, answer) => {
if (answer.text)
await createGoal(prisma, userId, { title: answer.text, source: "onboarding" });
},
},
{
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 ({ prisma, jobs, userId }, answer) => {
const rhythm = answer.option && RHYTHMS[answer.option.id];
if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, 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 ({ prisma, userId }, answer) => {
const quietHours = answer.option && QUIET_HOURS[answer.option.id];
if (quietHours) await updateNotificationSettings(prisma, userId, { 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);
}
/** 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 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;
}
export type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
/**
* 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 =
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}.`;
}

View File

@ -1,166 +1,192 @@
import { mkdir, mkdtemp, realpath, 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 type * as db from "@rakazo/db";
import { SpaceLimitError } from "@rakazo/db";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
const createSpaceForMember = vi.hoisted(() => vi.fn());
const ensureMainConversation = vi.hoisted(() => vi.fn());
vi.mock("@rakazo/db", async (original) => ({
...(await original<typeof db>()),
createSpaceForMember,
ensureMainConversation,
}));
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", spaceId: "space-owner", isDeploymentOwner: true };
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
const roots: string[] = [];
type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } };
/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */
function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) {
const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = [];
createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => {
if (spaces.length >= spaceLimit) throw new SpaceLimitError();
const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null };
spaces.push(space);
return { id: space.id, name: space.name };
});
const prisma = {
space: {
findMany: async () => spaces.filter((space) => space.workspacePath),
update: async ({ where, data }: SpaceUpdate) => {
const space = spaces.find((candidate) => candidate.id === where.id);
if (space) space.workspacePath = data.workspacePath;
},
/** 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 unknown as db.PrismaClient;
return { prisma, spaces };
},
) as PrismaClient;
async function tempDir(): Promise<string> {
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
roots.push(dir);
return dir;
}
async function scanHomeWithOneFile(): Promise<string> {
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;
async function files(root: string, entries: string[]): Promise<void> {
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 scanHomeWithRepos(...repos: string[]): Promise<string> {
const home = await scanHomeWithOneFile();
for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true });
return home;
async function project(root: string, relative: string, entries: string[]): Promise<void> {
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 },
);
}
/** The emulated runtime that the shared test setup pins keeps the scan simulated. */
function allowHostScan() {
vi.stubEnv("AGENT_RUNTIME", "");
vi.stubEnv("SANDBOX_PROVIDER", "");
}
afterAll(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
});
describe("onboarding scan", () => {
afterEach(() => {
vi.unstubAllEnvs();
createSpaceForMember.mockReset();
ensureMainConversation.mockReset();
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 { prisma } = fakeDatabase();
const { scanId } = await startOnboardingScan({ actor: member }, prisma);
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 { prisma } = fakeDatabase();
const { scanId } = await startOnboardingScan({ actor: owner }, prisma);
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 } });
});
it("lets only the deployment owner discover the configured scan home", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app");
const { prisma } = fakeDatabase();
const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma);
const memberStatus = await completed(asMember.scanId, member.userId);
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
expect(createSpaceForMember).not.toHaveBeenCalled();
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const ownerStatus = await completed(asOwner.scanId, owner.userId);
expect(ownerStatus.status).toBe("complete");
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
});
it("links each repository of the deployment owner to one project of its own", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim");
const app = await realpath(join(scanHome, "Projects", "app"));
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const firstStatus = await completed(first.scanId, owner.userId);
expect(firstStatus.status).toBe("complete");
expect(firstStatus.result).toMatchObject({
localRepos: [{ name: "app" }],
createdProjects: [{ spaceId: "space-1", name: "app", path: app }],
expect(status).toEqual({
status: "complete",
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
});
expect(createSpaceForMember).toHaveBeenCalledWith(prisma, {
currentSpaceId: owner.spaceId,
userId: owner.userId,
name: "app",
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("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"] };
await expect(
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
).rejects.toMatchObject({ code: "FORBIDDEN" });
// 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.",
});
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
// Each new project starts with its own main agent.
expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1");
// Scanning again finds the linked project instead of creating a second one.
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const againStatus = await completed(again.scanId, owner.userId);
expect(againStatus.result).toMatchObject({ createdProjects: [] });
expect(createSpaceForMember).toHaveBeenCalledTimes(1);
});
it("joins a running scan instead of linking its repositories twice", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app");
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const second = await startOnboardingScan({ actor: owner, scanHome }, prisma);
expect(second.scanId).toBe(first.scanId);
await completed(first.scanId, owner.userId);
expect(spaces).toHaveLength(1);
});
it("stops creating projects at the space limit and still completes", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c");
const { prisma } = fakeDatabase({ spaceLimit: 1 });
const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const status = await completed(scanId, owner.userId);
expect(status.status).toBe("complete");
expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] });
expect(createSpaceForMember).toHaveBeenCalledTimes(2);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -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,16 @@ 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");
expect(posted.flatMap((message) => message.blocks)).not.toContainEqual(
expect.objectContaining({ kind: "app_connect" }),
);
expect(posted.length).toBeGreaterThan(0);
await chooseFocus(deps, actor, "bot", { optionId: "day" });
// 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([
{ 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 +57,8 @@ describe("onboarding connection suggestions", () => {
).toEqual([
expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }),
]);
// The cards speak for themselves.
expect(posted).toHaveLength(1);
});
});

View File

@ -1,67 +1,47 @@
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import type { ConnectorRegistry } from "@rakazo/adapters";
import type { Actor, MessageBlock } from "@rakazo/contracts";
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";
import {
appendEventInTransaction,
createThreadMessageInTransaction,
ensureMainConversation,
IsolationError,
isPersonalMainBot,
type Prisma,
type PrismaClient,
type ThreadEvents,
} from "@rakazo/db";
import type { ChoiceBlock, FocusOption, IntakeAnswer, IntakeContext } from "./onboarding-intake.js";
import {
closingNote,
FOCUS_OPTIONS,
focusCard,
intakeContext,
intakeStep,
nextIntakeStep,
readIntakeAnswer,
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 = {
prisma: PrismaClient;
events: ThreadEvents;
connectors: ConnectorRegistry;
jobs: JobPublisher;
};
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<string, string> = {
slack: "Search, read, and send messages.",
@ -85,74 +65,102 @@ 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 },
/**
* 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<void> {
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`;
}
async function postInTransaction(
tx: Prisma.TransactionClient,
target: Target,
blocks: MessageBlock[],
): Promise<string> {
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<void> {
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<void> {
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, however long ago it came. */
async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) {
const messages = await client.message.findMany({
where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } },
select: { id: true, blocks: true },
orderBy: { seq: "desc" },
take: 20,
});
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 +168,32 @@ export async function promptFocus(
): Promise<void> {
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.
// The intake runs with the personal main agent; a project's main bot starts like any other.
const main = await isPersonalMainBot(deps.prisma, bot);
// 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 +201,128 @@ export async function dismissFocus(
): Promise<void> {
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<void> {
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;
// Fetched before the transaction: the connector catalog can take seconds.
const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus) : [];
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, jobs: deps.jobs, 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(deps, 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<IntakeContext> {
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(
deps: OnboardingDeps,
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)]);
}
// 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, [closingNote(rhythm, context)]);
}
/** 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,
): Promise<MessageBlock[][]> {
const providers = deps.connectors.managedProviders();
const catalog = (
await Promise.all(
@ -290,14 +333,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() ||
@ -316,27 +359,7 @@ export async function chooseFocus(
},
];
});
if (!cards.length) {
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
return;
}
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.`,
},
]);
return cards.length > 0 ? [cards] : [];
}
export async function markAppConnected(
@ -376,7 +399,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;
}

View File

@ -12,6 +12,7 @@ import type {
} from "@rakazo/adapter-kit";
import {
computerControlExpireJobKey,
MemoryDocumentTooLargeError,
messagingDeliverJob,
routineJobKey,
routineWakeupJob,
@ -28,6 +29,7 @@ import type {
MemoryProviderResolver,
PiOAuthLogins,
RemoteConnectorDependencies,
VapidKeys,
} from "@rakazo/adapters";
import {
acquireComputerExecutionLease,
@ -44,7 +46,6 @@ import {
computerSupportsUpdate,
computerUpdateView,
createVoiceProvider,
deletePushToken,
deploymentAutoReviewDefault,
destroyBot,
displayBotWorkspacePath,
@ -53,6 +54,7 @@ import {
filterTeamWorkspaceEntries,
hasActiveComputerControl,
isAutoReviewCheckerConfigured,
isBlockedHostname,
isComputerScreenUnavailable,
isProjectWorkspacePath,
isSandboxGoneError,
@ -75,7 +77,6 @@ import {
resolveAutoReviewChecker,
resolveBotWorkspacePath,
sanitizeComposioError,
savePushToken,
scheduleComputerControlExpiry,
scheduleComputerSleep,
screenLeaseIdForRun,
@ -100,6 +101,7 @@ import type {
} from "@rakazo/contracts";
import {
appContract,
CoachRoutineKeySchema,
IntegrationProviderIdSchema,
OPENAI_COMPATIBLE_PROVIDER_ID,
} from "@rakazo/contracts";
@ -132,6 +134,7 @@ import {
findModelCredential,
findSpaceModelTier,
formatMessagingLinkCode,
getNotificationSettings,
InvalidProjectWorkspacePathError,
InvalidSpaceNameError,
IsolationError,
@ -145,6 +148,8 @@ import {
parseComputerMode,
projectWorkspaceDto,
releaseSpaceDeletionClaim,
removePushSubscription,
removePushSubscriptionsOfKind,
renewSpaceDeletionClaim,
requireMembership,
resolveProjectWorkspaceDirectory,
@ -153,8 +158,10 @@ import {
SpaceLimitError,
SpaceNotEmptyError,
SpaceNotFoundError,
savePushSubscription,
selectSpaceModelPreference,
selectSpaceVoicePreference,
updateNotificationSettings,
wireProjectWorkspace,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
@ -163,11 +170,13 @@ 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,
toComputerStatus,
} from "./computer-status.js";
import { recordConnectedApp } from "./connected-app-facts.js";
import { listInbox } from "./inbox.js";
import { searchIntegrationCatalog } from "./integration-catalog.js";
import {
@ -199,7 +208,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";
@ -305,6 +319,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. */
@ -478,6 +497,8 @@ export interface RouterDeps {
secrets: EncryptedSecretStore;
oauthLogins: PiOAuthLogins;
integrationSettings?: IntegrationProviderSettings;
/** The deployment's web push key pair; without it browsers cannot subscribe. */
vapid?: Pick<VapidKeys, "publicKey">;
composio?: ComposioProvider;
mcpOAuth?: McpOAuthBroker;
connectors: ConnectorRegistry;
@ -545,9 +566,18 @@ 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 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);
@ -560,6 +590,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");
@ -599,12 +630,57 @@ export function createRouter(deps: RouterDeps) {
profile: {
get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)),
update: authed.profile.update.handler(({ context, input }) =>
updateProfile(deps.prisma, context.actor, input),
updateProfile(deps, context.actor, input),
),
forget: authed.profile.forget.handler(({ context, input }) =>
forgetProfile(deps.prisma, context.actor, input),
forgetProfile(deps, 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),
@ -2515,21 +2591,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",
@ -3505,7 +3588,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 }) => {
@ -3522,16 +3605,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 }) => {
@ -3804,6 +3889,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;
@ -4109,6 +4201,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,
@ -4865,13 +4964,53 @@ 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: {
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 }) => ({
@ -5179,6 +5318,7 @@ function spaceGroupDto(group: SpaceGroup): SpaceGroup {
sectionId: group.sectionId,
members: group.members,
preview: group.preview,
...(group.previewBlock ? { previewBlock: group.previewBlock } : {}),
unread: group.unread,
updatedAt: group.updatedAt,
};
@ -5562,6 +5702,7 @@ function mapRoutine(row: {
lastRunAt: Date | null;
nextRunAt: Date | null;
createdAt: Date;
coachKey: string | null;
}) {
return {
id: row.id,
@ -5578,6 +5719,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,
};
}

View File

@ -0,0 +1,143 @@
import type { MessageBlock } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { loadMessagePage } from "./thread-message-pages.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("loadMessagePage (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
let userRunId: string;
let peerRunId: string;
let seq = 0;
beforeAll(async () => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
identity = await provisionMessagingIdentity(
prisma,
{ provider: "sendblue", address: `+1555${Date.now().toString().slice(-7)}` },
{ signupsEnabled: undefined, signupAllowlist: undefined },
);
const { spaceId, botId, threadId, userId } = identity;
const owner = { spaceId, botId, threadId, userId };
const run = async (trigger: string) => {
const task = await prisma.task.create({
data: { ...owner, prompt: "fixture", status: "completed" },
});
const created = await prisma.run.create({
data: { ...owner, taskId: task.id, status: "completed", trigger },
});
return created.id;
};
userRunId = await run("user_message");
peerRunId = await run("bot_message");
seq =
(
await prisma.message.aggregate({
where: { threadId: identity.threadId },
_max: { seq: true },
})
)._max.seq ?? 0;
});
afterAll(async () => {
if (identity) {
await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } });
await prisma.organization.deleteMany({ where: { id: identity.spaceId } });
await prisma.user.deleteMany({ where: { id: identity.userId } });
}
await close?.();
});
async function message(id: string, runId: string | null, blocks: MessageBlock[]) {
seq += 1;
await prisma.message.create({
data: {
id: `${identity.threadId}-${id}`,
threadId: identity.threadId,
seq,
role: "bot",
runId,
blocks,
},
});
return seq;
}
const ids = (page: Awaited<ReturnType<typeof loadMessagePage>>) =>
page.messages.map((row) => row.id.slice(identity.threadId.length + 1));
it("fills a page with visible rows across a long stretch of hidden peer activity", async () => {
await message("older", userRunId, [{ kind: "text", text: "Older answer" }]);
await message("newer", userRunId, [{ kind: "text", text: "Newer answer" }]);
for (let index = 0; index < 30; index += 1) {
await message(`peer-steps-${index}`, peerRunId, [
{ kind: "steps", steps: [{ label: "Working", count: 1 }] },
]);
}
await message("peer-receipt", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
await message("peer-reply", peerRunId, [{ kind: "text", text: "Peer summary" }]);
await message("orphan-run", "run-that-no-longer-exists", [{ kind: "text", text: "Kept" }]);
const first = await loadMessagePage(prisma, identity.threadId, undefined, 3);
expect(ids(first)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const second = await loadMessagePage(prisma, identity.threadId, first.olderCursor!, 2);
expect(ids(second)).toEqual(["older", "newer"]);
const peerView = await loadMessagePage(
prisma,
identity.threadId,
undefined,
3,
undefined,
true,
);
expect(ids(peerView)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const peerOlder = await loadMessagePage(
prisma,
identity.threadId,
peerView.olderCursor!,
1,
undefined,
true,
);
expect(ids(peerOlder)).toEqual(["peer-steps-29"]);
});
it("skips a receipt-only page for web but returns it when receipts are displayed", async () => {
await message("before-receipts", userRunId, [{ kind: "text", text: "Answer" }]);
await message("sent", userRunId, [
{ kind: "bot_message_sent", toBotId: "bot-2", toBotName: "Coder", text: "Check this." },
]);
await message("received", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
const web = await loadMessagePage(prisma, identity.threadId, undefined, 2);
expect(ids(web)).toEqual(["orphan-run", "before-receipts"]);
const mobile = await loadMessagePage(
prisma,
identity.threadId,
undefined,
2,
undefined,
false,
true,
);
expect(ids(mobile)).toEqual(["sent", "received"]);
});
});

View File

@ -43,98 +43,6 @@ describe("thread message pages", () => {
).toBe(false);
});
it("keeps peer receipt rows when filtering peer-run output from pages", async () => {
const findMany = vi.fn(async () => [
{
id: "message-reply",
threadId: "thread-1",
seq: 3,
role: "bot",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:03.000Z"),
},
{
id: "message-received",
threadId: "thread-1",
seq: 2,
role: "user",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Done.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 3);
expect(page.messages.map((message) => message.id)).toEqual([
"message-user",
"message-received",
"message-reply",
]);
});
it("filters peer-run activity when its receipt is outside the loaded page", async () => {
const findMany = vi.fn(async () => [
{
id: "message-peer",
threadId: "thread-1",
seq: 2,
role: "bot",
blocks: [{ kind: "steps", steps: [{ label: "Echoed peer reply", count: 1 }] }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
});
it("keeps peer text around-page targets and omits peer activity", async () => {
const findMany = vi.fn(async () => [
{
@ -281,45 +189,6 @@ describe("thread message pages", () => {
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
});
it("scans past a page containing only hidden peer-run activity", async () => {
const row = (seq: number, runId: string, kind: "text" | "steps" = "text") => ({
id: `message-${seq}`,
threadId: "thread-1",
seq,
role: "bot",
blocks:
kind === "steps"
? [{ kind: "steps", steps: [{ label: String(seq), count: 1 }] }]
: [{ kind: "text", text: String(seq) }],
botId: "bot-1",
replyToMessageId: null,
runId,
createdAt: new Date("2026-08-16T00:00:00.000Z"),
});
const findMany = vi
.fn()
.mockResolvedValueOnce([
row(4, "run-peer", "steps"),
row(3, "run-peer", "steps"),
row(2, "run-peer", "steps"),
])
.mockResolvedValueOnce([row(1, "run-user")]);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-1"]);
expect(findMany).toHaveBeenCalledTimes(2);
});
it("scans past a receipt-only page so web can reach older user-visible rows", async () => {
const receiptRows = [
{
@ -383,21 +252,13 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
];
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
expect(findMany).toHaveBeenCalledTimes(2);
expect(queryRaw).toHaveBeenCalledTimes(2);
});
it("returns a receipt-only page when the client displays peer receipts", async () => {
@ -419,11 +280,8 @@ describe("thread message pages", () => {
runId: "run-peer",
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
});
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
@ -432,10 +290,10 @@ describe("thread message pages", () => {
"message-receipt-3",
]);
expect(page.olderCursor).toBe(2);
expect(findMany).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1);
});
it("queries before the cursor and returns an ascending bounded page", async () => {
it("queries the bot messages view before the cursor as an ascending bounded page", async () => {
const findMany = vi.fn(async () =>
[5, 4, 3].map((seq) => ({
id: `message-${seq}`,
@ -449,7 +307,7 @@ describe("thread message pages", () => {
);
const prisma = { message: { findMany } } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 6, 2);
const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true);
expect(findMany).toHaveBeenCalledWith({
where: { threadId: "thread-1", seq: { lt: 6 } },
@ -461,7 +319,7 @@ describe("thread message pages", () => {
});
it("ends pagination when the database returns no lookahead row", async () => {
const findMany = vi.fn(async () => [
const queryRaw = vi.fn(async () => [
{
id: "message-0",
threadId: "thread-1",
@ -472,7 +330,7 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:00.000Z"),
},
]);
const prisma = { message: { findMany } } as unknown as PrismaClient;
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 1, 2);

View File

@ -1,9 +1,18 @@
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
import { isPeerReceiptBlocks } from "@rakazo/core";
import type { Prisma, PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
type MessageDb = PrismaClient | Prisma.TransactionClient;
/** Peer-run rows that stay in the normal transcript: receipts, ask cards and the bot's own reply. */
const PEER_VISIBLE_BLOCK_KINDS: readonly string[] = [
"bot_message_sent",
"bot_message_received",
"ask",
"text",
];
export async function loadMessagePage(
prisma: MessageDb,
threadId: string,
@ -47,37 +56,69 @@ export async function loadMessagePage(
}
}
let cursor = before;
while (true) {
if (includePeerRuns) {
const rows = await prisma.message.findMany({
where: {
threadId,
...(cursor === undefined ? {} : { seq: { lt: cursor } }),
...(before === undefined ? {} : { seq: { lt: before } }),
},
orderBy: { seq: "desc" },
take: pageSize + 1,
});
const hasOlder = rows.length > pageSize;
const pageRows = rows.slice(0, pageSize).reverse();
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
return toPage(threadId, rows, pageSize);
}
let cursor = before;
while (true) {
const rows = await visibleMessageRows(prisma, threadId, cursor, pageSize + 1);
const page = toPage(threadId, rows, pageSize);
// Web hides receipts client-side, so its receipt-only pages keep scanning.
// Mobile explicitly retains them and must receive each page for pagination.
const hasSubstantive = visibleRows.some(
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]),
);
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) {
return {
threadId,
messages: visibleRows.map(toThreadMessage),
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null,
};
}
// TODO: only rescan when a raw page is entirely peer output. Consider a run relation if
// long peer-only histories make this path hot.
cursor = pageRows[0]?.seq;
const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks));
if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page;
cursor = page.olderCursor;
}
}
type MessageRow = Parameters<typeof toThreadMessage>[0];
function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage {
const pageRows = rows.slice(0, pageSize).reverse();
return {
threadId,
messages: pageRows.map(toThreadMessage),
olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null,
};
}
/**
* Newest-first transcript rows with peer-run activity excluded in the database, so a long
* peer-only stretch costs one query instead of a rescan per hidden page.
*/
function visibleMessageRows(
prisma: MessageDb,
threadId: string,
before: number | undefined,
limit: number,
): Promise<MessageRow[]> {
return prisma.$queryRaw<MessageRow[]>`
SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId",
m."replyQuote", m."runId", m."createdAt"
FROM messages m
LEFT JOIN runs r ON r.id = m."runId"
WHERE m."threadId" = ${threadId}
${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`}
AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join(
PEER_VISIBLE_BLOCK_KINDS.map(
(kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`,
),
" OR ",
)})
ORDER BY m.seq DESC
LIMIT ${limit}
`;
}
export async function loadAllMessages(
prisma: PrismaClient,
threadId: string,
@ -106,15 +147,8 @@ async function withoutPeerRunMessages<T extends { runId: string | null; blocks:
const peerRunIds = new Set(peerRuns.map((run) => run.id));
return rows.filter((row) => {
if (!row.runId || !peerRunIds.has(row.runId)) return true;
// Keep peer receipts (chips), ask cards, and the bot's own text reply.
const blocks = row.blocks as MessageBlock[];
return blocks.some(
(block) =>
block.kind === "bot_message_sent" ||
block.kind === "bot_message_received" ||
block.kind === "ask" ||
block.kind === "text",
);
return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind));
});
}
@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: {
!!block &&
typeof block === "object" &&
"kind" in block &&
(block.kind === "bot_message_received" ||
block.kind === "bot_message_sent" ||
block.kind === "ask" ||
block.kind === "text"),
typeof block.kind === "string" &&
PEER_VISIBLE_BLOCK_KINDS.includes(block.kind),
)
);
}

View File

@ -183,7 +183,7 @@ describe("threadSnapshot", () => {
},
]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
@ -203,7 +203,10 @@ describe("threadSnapshot", () => {
const snapshot = await threadSnapshot({ prisma }, target);
expect(tx.$queryRaw).toHaveBeenCalledOnce();
expect(tx.$queryRaw).toHaveBeenCalledWith(
expect.arrayContaining([expect.stringContaining("FOR SHARE")]),
"thread-1",
);
expect(findManyEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
@ -245,7 +248,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([run]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -330,7 +333,7 @@ describe("threadSnapshot", () => {
prisma: {
$transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
callback({
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -385,7 +388,7 @@ describe("threadSnapshot", () => {
};
const findFirstRun = botRunFindFirst([failed, completed]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -421,7 +424,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -925,9 +928,16 @@ function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] })
);
}
/** The snapshot's thread lock finds the thread; its message page query finds no rows. */
function threadQueries() {
return vi.fn(async (sql: TemplateStringsArray) =>
sql.join("").includes("FOR SHARE") ? [{ id: "thread-1" }] : [],
);
}
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),

View File

@ -1,4 +1,6 @@
import { ORPCError } from "@orpc/server";
import type { CoachRhythmDeps } from "@rakazo/adapters";
import { syncCoachTimeZone } from "@rakazo/adapters";
import type {
Actor,
ForgetUserFacts,
@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProf
}
export async function updateProfile(
prisma: PrismaClient,
deps: CoachRhythmDeps,
actor: Actor,
input: UpdateUserProfile,
): Promise<UserProfileState> {
try {
await updateUserProfile(prisma, actor.userId, input);
await updateUserProfile(deps.prisma, actor.userId, input);
} catch (error) {
throw toRpcError(error);
}
return getUserProfileState(prisma, actor.userId);
if (input.timeZone !== undefined) await syncCoachTimeZone(deps, actor.userId);
return getUserProfileState(deps.prisma, actor.userId);
}
/** Unknown ids are ignored, so a caller cannot probe another user's facts. */
export async function forgetProfile(
prisma: PrismaClient,
deps: CoachRhythmDeps,
actor: Actor,
input: ForgetUserFacts,
): Promise<UserProfileState> {
await forgetUserFacts(
prisma,
actor.userId,
input.factIds
? { factIds: input.factIds }
: input.category
? { category: input.category }
: { all: true },
);
return getUserProfileState(prisma, actor.userId);
const target = input.factIds
? { factIds: input.factIds }
: input.category
? { category: input.category }
: { all: true as const };
await forgetUserFacts(deps.prisma, actor.userId, target);
// Forgetting everything clears the time zone too.
if ("all" in target) await syncCoachTimeZone(deps, actor.userId);
return getUserProfileState(deps.prisma, actor.userId);
}

View File

@ -1903,6 +1903,32 @@ describe("mobile thread event reduction", () => {
]);
});
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`,
);
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", () => {
const initial = snapshot([
mobileMessage("message-1", [{ kind: "text", text: "old" }]),

View File

@ -894,6 +894,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") {

View File

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

View File

@ -255,7 +255,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await sidebar.getByRole("button", { name: "Today", exact: true }).click();
const heute = page.getByTestId("heute-brief");
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
await expect(heute).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });

View File

@ -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);
@ -242,6 +247,7 @@ export async function openUserSettings(
| "memory"
| "profile"
| "voice"
| "notifications"
| "usage"
| "computer"
| "updates",

View File

@ -1,20 +1,136 @@
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, openMainBotThread, rpc, signup } from "./helpers";
/** The nav button reads "Today" or "Today, N need you". */
function openHeute(page: Page) {
return page.getByRole("button", { name: /^Today(,|$)/ }).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: "Today", 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<CoachToday>(page, "coach/today", {});
const goal = await rpc<Goal>(page, "coach/goals/create", {
title: "Ship the beta",
targetDate: shiftDate(date, 30),
});
const habit = await rpc<Habit>(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<CoachRhythm>(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<Routine[]>(page, "routines/list", { botId: main.id });
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();
});

View File

@ -39,6 +39,75 @@ test("answers a specialist's question from the main conversation", async ({ page
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("answers a question from another space in the main conversation", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross");
await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 });
const helper = await createBot(page, "Helper");
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", {
name: "Scout",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpcInSpace(page, side.id, "threads/send", {
botId: scout.id,
text: "ask me which city to use",
});
const runInSide = async () =>
(
await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", {
botId: scout.id,
})
).run ?? null;
await expect
.poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 })
.toBe("waiting_input");
await openMainConversation(page, helper.id);
const inbox = page.getByRole("region", { name: "Needs you" });
const ask = inbox.locator("[data-kind='ask']");
await expect(ask).toContainText("Scout", { timeout: 30_000 });
await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project");
await captureScreenshot(page, testInfo, "inbox-cross-space");
await ask.getByRole("button", { name: "Edit first" }).click();
await ask.getByRole("textbox", { name: "Answer" }).fill("Paris");
await ask.getByRole("button", { name: "Send answer" }).click();
await expect(ask).toHaveCount(0);
await expect.poll(runInSide, { timeout: 60_000 }).toBeNull();
// The reply arrives as unread from the other space and opens there.
const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" });
await expect(reply).toContainText("Paris", { timeout: 45_000 });
await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project");
await reply.getByRole("button").click();
await page.waitForURL(new RegExp(`/app/${scout.id}$`));
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id);
});
async function rpcInSpace<T = unknown>(
page: Page,
spaceId: string,
procedure: string,
body: unknown,
): Promise<T> {
const response = await page.request.post(`/rpc/${procedure}`, {
data: { json: body },
headers: { "x-rakazo-space-id": spaceId },
});
const parsed = (await response.json()) as { json?: T; error?: { message?: string } };
if (!response.ok() || parsed.error) {
throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`);
}
return parsed.json as T;
}
async function createBot(page: Page, name: string) {
return rpc<{ id: string }>(page, "bots/create", {
name,

View File

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

View File

@ -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,14 +73,25 @@ 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.
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
// 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.getByTestId("transcript").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(botText).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,
composer,
`hover-bot-${stamp} ${"please look into this ".repeat(7)}`,
);
const botText = page.getByText(/i handled: hover-bot-/);
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);
@ -121,7 +133,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);
@ -387,12 +398,19 @@ test.describe("touch message actions", () => {
expect(
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
).toBe(false);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
await expect(
page.getByTestId("transcript").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(botText).toBeVisible({ timeout: 20_000 });
// 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(page.getByTestId("transcript").getByText(/i handled: touch-bot-/)).toBeVisible({
timeout: 20_000,
});
const row = page
.getByTestId("transcript")
.locator("[data-message-id]")

View File

@ -42,7 +42,9 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses",
await page.waitForURL(/\/app\/[^/]+$/);
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("Start the conversation — New Bot is on stage above."),
).toBeVisible();
@ -117,20 +119,30 @@ test("later bot waits before showing the focus card; sending cancels it", async
await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay");
await completeOnboarding(page, undefined, { mainConversation: true });
// The main bot shows the focus card immediately in the main conversation.
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible();
await page.clock.install();
await createBotFromPicker(page);
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
await page.clock.fastForward(9_000);
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
await page.clock.fastForward(1_500);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible();
await createBotFromPicker(page, { name: "Later Bot" });
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
const composer = page.getByPlaceholder(/Message/);
await composer.fill("I'll set this up myself");
// Send must finish before the delay is advanced: cancel runs after a successful
@ -144,7 +156,9 @@ test("later bot waits before showing the focus card; sending cancels it", async
page.getByTestId("transcript").getByText("I'll set this up myself").first(),
).toBeVisible();
await page.clock.fastForward(12_000);
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
});
test("plus picker can create a Private computer bot", async ({ page }, testInfo) => {

View File

@ -0,0 +1,130 @@
/**
* 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 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";
/**
* 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";
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: notification,
});
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 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();
});

View File

@ -16,11 +16,16 @@ test("focus choice suggests apps and preserves a completed connection", async ({
await expect(
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
).toHaveCount(0);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("focus-choice-day")).toBeVisible();
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 +38,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();
// 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$/ });
@ -45,10 +52,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" });

View File

@ -0,0 +1,77 @@
/**
* 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 type { Page } from "@playwright/test";
import { expect, 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<SpaceNavigation>(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<UserProfileState>(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 },
});
}
});

View File

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

View File

@ -0,0 +1,91 @@
/**
* 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 type { Page } from "@playwright/test";
import { expect, 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?");
// The sidebar previews the main conversation as the web words it: the pending question.
const mainEntry = page.locator("aside").first().locator("[data-main-conversation]");
await expect(mainEntry).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.
// 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()) {
await notifications.getByTestId("notifications-choice-later").click();
}
await expect(closing).toBeVisible();
await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./);
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",
}),
);
});

View File

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

View File

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

View File

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

View File

@ -0,0 +1,74 @@
/**
* E2E: Settings → About you edits a fact in place: its wording, category and the spaces it
* shows in.
*/
import { expect, test } from "@playwright/test";
import type { UserProfileState } from "@rakazo/contracts";
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester");
await completeOnboarding(page);
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
await rpc(page, "profile/update", {
addFacts: [{ category: "preferences", value: "Prefers tea" }],
});
const settings = await openUserSettings(page, "profile");
const profile = settings.getByTestId("user-profile-settings");
const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" });
await tea.getByRole("button", { name: "Edit" }).click();
const editor = profile.getByTestId("user-fact-editor");
const wording = editor.getByRole("textbox", { name: "Fact" });
await expect(wording).toBeFocused();
await wording.fill("Prefers green tea");
await editor.getByRole("combobox", { name: "Category" }).selectOption("habits");
await editor.getByRole("button", { name: "All spaces" }).click();
await page.getByRole("menuitemcheckbox", { name: "Side project" }).click();
await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked();
await page.keyboard.press("Escape");
await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible();
await captureScreenshot(page, testInfo, "user-profile-edit");
await editor.getByRole("button", { name: "Save", exact: true }).click();
await expect(editor).toHaveCount(0);
const edited = profile
.getByTestId("user-facts-habits")
.getByTestId("user-fact")
.filter({ hasText: "Prefers green tea" });
await expect(edited).toContainText("Side project");
await expect(tea).toHaveCount(0);
const teaFact = async () =>
(await rpc<UserProfileState>(page, "profile/get", null)).facts.find((fact) =>
fact.value.includes("tea"),
);
expect(await teaFact()).toMatchObject({
value: "Prefers green tea",
category: "habits",
allSpaces: false,
spaceIds: [side.id],
});
// Escape leaves the fact as it was; showing it everywhere again clears the limit.
await edited.getByRole("button", { name: "Edit" }).click();
await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded");
await page.keyboard.press("Escape");
await expect(editor).toHaveCount(0);
await expect(edited).toContainText("Prefers green tea");
await expect(settings).toBeVisible();
await edited.getByRole("button", { name: "Edit" }).click();
await editor.getByRole("button", { name: "Side project" }).click();
await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click();
await page.keyboard.press("Escape");
await editor.getByRole("button", { name: "Save", exact: true }).click();
await expect(editor).toHaveCount(0);
await expect(edited).not.toContainText("Side project");
expect(await teaFact()).toMatchObject({
value: "Prefers green tea",
allSpaces: true,
spaceIds: [],
});
});

View File

@ -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<Array<{ instructions: string }>>(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" }),

View File

@ -9,6 +9,7 @@
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Fonts loaded locally via @fontsource-variable (Inter + Geist) — no external CDN -->
<link rel="stylesheet" href="/evoo-brand.css" />
<title>evOo</title>
@ -26,7 +27,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");
})();
</script>
<script>

View File

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

61
apps/web/public/sw.js Normal file
View File

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

View File

@ -79,6 +79,22 @@ const unread: InboxItem = {
text: "Draft ready for review",
createdAt,
};
const side = { id: "space-side", name: "Side project" };
const askInOtherSpace: InboxItem = {
kind: "ask",
bot: writer,
target: { botId: writer.id },
space: side,
runId: "run-side",
messageId: "msg-side",
ask: { kind: "ask", text: "Which title fits?", status: "pending" },
createdAt,
};
const groupAskInOtherSpace: InboxItem = {
...approvalInGroup,
space: side,
messageId: "msg-side-group",
};
const idleBots = [
{ id: scout.id, status: "idle", unread: false, preview: "" },
@ -89,10 +105,18 @@ let container: HTMLDivElement;
let root: Root;
const onOpenBot = vi.fn();
const onOpenGroup = vi.fn();
const onOpenInSpace = vi.fn();
function render(bots = idleBots) {
return act(async () =>
root.render(<MainInbox bots={bots} onOpenBot={onOpenBot} onOpenGroup={onOpenGroup} />),
root.render(
<MainInbox
bots={bots}
onOpenBot={onOpenBot}
onOpenGroup={onOpenGroup}
onOpenInSpace={onOpenInSpace}
/>,
),
);
}
@ -111,6 +135,7 @@ beforeEach(() => {
api.markRead.mockReset().mockResolvedValue({ ok: true });
onOpenBot.mockReset();
onOpenGroup.mockReset();
onOpenInSpace.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
@ -154,12 +179,10 @@ it("answers through the thread the ask came from and drops only that item", asyn
itemOf("approval").querySelector<HTMLButtonElement>("[data-answer]")?.click(),
);
expect(api.answer).toHaveBeenCalledWith({
groupId: "group-ops",
runId: "run-approval",
messageId: "msg-approval",
answer: "Paris",
});
expect(api.answer).toHaveBeenCalledWith(
{ groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris" },
{ context: {} },
);
expect(api.markRead).not.toHaveBeenCalled();
expect(api.list).toHaveBeenCalledTimes(2);
expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1);
@ -173,7 +196,7 @@ it("marks the bot's own thread read before answering, so the question does not r
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id });
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }, { context: {} });
expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan(
api.answer.mock.invocationCallOrder[0]!,
);
@ -187,12 +210,10 @@ it("refreshes after a failed answer so a stale ask disappears", async () => {
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
expect(api.answer).toHaveBeenCalledWith({
botId: scout.id,
runId: "run-ask",
messageId: "msg-ask",
answer: "Paris",
});
expect(api.answer).toHaveBeenCalledWith(
{ botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris" },
{ context: {} },
);
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
});
@ -208,6 +229,36 @@ it("opens the bot's thread for takeovers and unread threads, and the source thre
expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]);
});
it("names another space's items and answers and opens them in that space", async () => {
api.list.mockResolvedValueOnce({ items: [askInOtherSpace, askInOwnThread] });
api.list.mockResolvedValue({ items: [askInOwnThread, groupAskInOtherSpace] });
api.answer.mockResolvedValue({ ok: true });
await render();
const items = container.querySelectorAll<HTMLElement>("[data-testid='main-inbox-item']");
expect(items[0]?.querySelector("[data-testid='main-inbox-space']")?.textContent).toBe(
"· Side project",
);
expect(items[1]?.querySelector("[data-testid='main-inbox-space']")).toBeNull();
await act(async () => items[0]?.querySelector<HTMLButtonElement>("[data-answer]")?.click());
expect(api.markRead).toHaveBeenCalledWith(
{ botId: writer.id },
{ context: { spaceId: side.id } },
);
expect(api.answer).toHaveBeenCalledWith(
{ botId: writer.id, runId: "run-side", messageId: "msg-side", answer: "Paris" },
{ context: { spaceId: side.id } },
);
const [own, group] = container.querySelectorAll<HTMLElement>("[data-testid='main-inbox-item']");
await act(async () => group?.querySelector("button")?.click());
await act(async () => own?.querySelector("button")?.click());
expect(onOpenInSpace.mock.calls).toEqual([[side.id, "/app/g/group-ops"]]);
expect(onOpenBot.mock.calls).toEqual([[scout.id]]);
expect(onOpenGroup).not.toHaveBeenCalled();
});
it("refreshes when a bot's state changes, not on every render", async () => {
api.list.mockResolvedValue({ items: [] });
await render();

View File

@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000;
type PendingAskItem = Extract<InboxItem, { kind: "ask" | "approval" }>;
/** Thread procedures for an item run in the item's own space. */
function itemContext(item: InboxItem) {
return { context: item.space ? { spaceId: item.space.id } : {} };
}
/**
* What the user's bots need from them outside the main conversation, with each bot as the
* sender: asks and approvals are answered in place, takeovers and unread threads open the
* bot's thread.
* What the user's bots in all of their spaces need from them outside the main conversation, with
* each bot as the sender: asks and approvals are answered in place, takeovers and unread threads
* open the bot's thread.
*/
export function MainInbox({
bots,
onOpenBot,
onOpenGroup,
onOpenInSpace,
}: {
/** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */
bots: readonly Pick<Bot, "id" | "status" | "unread" | "preview">[];
onOpenBot: (botId: string) => void;
onOpenGroup: (groupId: string) => void;
/** Opens an app path, such as a thread, in another of the user's spaces. */
onOpenInSpace: (spaceId: string, path: string) => void;
}) {
const { t } = useLingui();
const [items, setItems] = useState<InboxItem[]>([]);
@ -59,11 +67,16 @@ export function MainInbox({
}, [refresh]);
const open = useCallback(
(target: InboxThreadTarget) => {
if ("botId" in target) onOpenBot(target.botId);
({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => {
if (space) {
onOpenInSpace(
space.id,
"botId" in target ? `/app/${target.botId}` : `/app/g/${target.groupId}`,
);
} else if ("botId" in target) onOpenBot(target.botId);
else onOpenGroup(target.groupId);
},
[onOpenBot, onOpenGroup],
[onOpenBot, onOpenGroup, onOpenInSpace],
);
const answer = useCallback(
@ -72,14 +85,12 @@ export function MainInbox({
// Answering reads the ask, so the bot's thread should not come back as unread with the
// same question. Mark it first: the reply to this answer marks it unread again.
if ("botId" in item.target) {
await rpc.threads.markRead(item.target).catch(() => undefined);
await rpc.threads.markRead(item.target, itemContext(item)).catch(() => undefined);
}
await rpc.threads.answer({
...item.target,
runId: item.runId,
messageId: item.messageId,
answer: text,
});
await rpc.threads.answer(
{ ...item.target, runId: item.runId, messageId: item.messageId, answer: text },
itemContext(item),
);
const key = inboxItemKey(item);
setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key));
} finally {
@ -105,10 +116,10 @@ export function MainInbox({
<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={() => open(item.target)}
onClick={() => open(item)}
className="w-fit rounded-md hover:underline"
>
<Sender bot={item.bot} />
<Sender bot={item.bot} space={item.space} />
</button>
<AskCard
block={item.ask}
@ -120,11 +131,11 @@ export function MainInbox({
) : (
<button
type="button"
onClick={() => open(item.target)}
onClick={() => open(item)}
className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent"
>
<span className="min-w-0 flex-1">
<Sender bot={item.bot} />
<Sender bot={item.bot} space={item.space} />
{item.text ? (
<span
className="mt-0.5 line-clamp-2 text-ui-body-sm text-muted-foreground"
@ -154,16 +165,27 @@ export function MainInbox({
);
}
function Sender({ bot }: { bot: InboxItem["bot"] }) {
/** The bot, and the space it works in when that is not the current one. */
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
const color = resolvePersonaColorDef(bot.id, parseBotAvatar(bot.color).color).light;
return (
<span
className="flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
style={{ color }}
dir="auto"
>
<BotAvatar color={bot.color} identity={bot.id} size={22} className="shrink-0" />
<span className="truncate">{bot.name}</span>
{space ? (
<span
data-testid="main-inbox-space"
className="truncate font-normal text-muted-foreground"
dir="auto"
>
<span aria-hidden="true">· </span>
{space.name}
</span>
) : null}
</span>
);
}

View File

@ -0,0 +1,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 (
<li className="py-3 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-4">
<span id={labelId} className="text-ui-body text-foreground">
{label}
</span>
<Switch
data-testid={testId}
aria-labelledby={labelId}
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
{children}
</li>
);
}
/** 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<NotificationSettings | null>(null);
const [push, setPush] = useState<PushState>("loading");
const [pushBusy, setPushBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div data-testid="notification-settings" className="space-y-5">
{error ? (
<p role="alert" className="text-ui-body-sm text-destructive">
{error}
</p>
) : null}
{settings ? (
<section className="rounded-xl border border-border px-4 py-4">
<ul className="divide-y divide-border">
{pushSupported ? (
<SettingRow
label={<Trans>Push on this device</Trans>}
testId="notification-push-switch"
checked={push === "on"}
disabled={push === "loading" || push === "blocked" || pushBusy}
onCheckedChange={(next) => void changePush(next)}
>
{push === "blocked" ? (
<p className="mt-1 text-ui-body-sm text-muted-foreground">
<Trans>Blocked in your browser settings</Trans>
</p>
) : null}
</SettingRow>
) : null}
<SettingRow
label={<Trans>Quiet hours</Trans>}
testId="notification-quiet-hours-switch"
checked={settings.quietHours !== null}
onCheckedChange={(next) => save({ quietHours: next ? quietDraft : null })}
>
{settings.quietHours ? (
<div className="mt-3 flex items-center gap-2">
<Input
type="time"
data-testid="notification-quiet-hours-start"
aria-label={t`Quiet hours start`}
value={quietDraft.start}
onChange={(event) => changeQuietTime("start", event.target.value)}
className="w-36"
/>
<span aria-hidden="true" className="text-muted-foreground">
–
</span>
<Input
type="time"
data-testid="notification-quiet-hours-end"
aria-label={t`Quiet hours end`}
value={quietDraft.end}
onChange={(event) => changeQuietTime("end", event.target.value)}
className="w-36"
/>
</div>
) : null}
</SettingRow>
<SettingRow
label={<Trans>Nudges from your Oo</Trans>}
testId="notification-nudges-switch"
checked={settings.nudges}
onCheckedChange={(next) => save({ nudges: next })}
/>
</ul>
</section>
) : null}
</div>
);
}

View File

@ -0,0 +1,129 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { UserFact, UserFactCategory } from "@rakazo/contracts";
import { USER_FACT_CATEGORIES, USER_FACT_VALUE_MAX_LENGTH } from "@rakazo/contracts";
import {
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
NativeSelect,
NativeSelectOption,
} from "@rakazo/ui-web";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import type { UserFactDraft } from "../lib/user-profile-view";
import { factSpaceNames, toggleFactSpace, userFactDraft } from "../lib/user-profile-view";
/** Edits one fact in place: its wording, category and, with more than one space, where it shows. */
export function UserFactEditor({
fact,
spaces,
categoryLabels,
busy,
onSave,
onCancel,
}: {
fact: UserFact;
/** The user's spaces; the visibility control appears with more than one. */
spaces: ReadonlyArray<{ id: string; name: string }>;
categoryLabels: Record<UserFactCategory, string>;
busy: boolean;
onSave: (draft: UserFactDraft) => void;
onCancel: () => void;
}) {
const { t } = useLingui();
const [draft, setDraft] = useState(() => userFactDraft(fact));
const allSpaces = t`All spaces`;
const showSpaces = spaces.length > 1 || draft.spaceIds !== null;
return (
<li data-testid="user-fact-editor">
<form
className="space-y-2"
onSubmit={(event) => {
event.preventDefault();
if (draft.value.trim()) onSave(draft);
}}
onKeyDown={(event) => {
// The spaces menu renders in a portal; its Escape only closes the menu.
if (event.key === "Escape" && event.currentTarget.contains(event.target as Node)) {
event.stopPropagation();
onCancel();
}
}}
>
<Input
autoFocus
aria-label={t`Fact`}
value={draft.value}
maxLength={USER_FACT_VALUE_MAX_LENGTH}
onChange={(event) => setDraft({ ...draft, value: event.target.value })}
/>
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
className="*:data-[slot=native-select]:h-control-md"
aria-label={t`Category`}
value={draft.category}
onChange={(event) =>
setDraft({ ...draft, category: event.target.value as UserFactCategory })
}
>
{USER_FACT_CATEGORIES.map((category) => (
<NativeSelectOption key={category} value={category}>
{categoryLabels[category]}
</NativeSelectOption>
))}
</NativeSelect>
{showSpaces ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" className="max-w-56" />}
>
<span className="truncate">
{factSpaceNames(draft.spaceIds, spaces).join(", ") || allSpaces}
</span>
<ChevronDownIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto min-w-44">
<DropdownMenuCheckboxItem
checked={draft.spaceIds === null}
onCheckedChange={() => setDraft({ ...draft, spaceIds: null })}
>
{allSpaces}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{spaces.map((space) => (
<DropdownMenuCheckboxItem
key={space.id}
checked={draft.spaceIds?.includes(space.id) ?? false}
onCheckedChange={(checked) =>
setDraft({
...draft,
spaceIds: toggleFactSpace(draft.spaceIds, space.id, checked),
})
}
>
<span className="truncate" dir="auto">
{space.name}
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="ms-auto flex items-center gap-1">
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={busy || !draft.value.trim()}>
<Trans>Save</Trans>
</Button>
</div>
</div>
</form>
</li>
);
}

View File

@ -10,7 +10,7 @@ import {
AlertDialogTitle,
Button,
} from "@rakazo/ui-web";
import { XIcon } from "lucide-react";
import { PencilIcon, XIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useId, useState } from "react";
import { getActiveUiLocale } from "../lib/i18n";
@ -20,9 +20,12 @@ import {
CLEARED_PROFILE_FIELD,
displayLanguageTag,
displayRegionTag,
factSpaceNames,
filledProfileFields,
groupUserFacts,
userFactEdit,
} from "../lib/user-profile-view";
import { UserFactEditor } from "./UserFactEditor";
function Row({
value,
@ -51,14 +54,30 @@ function Row({
);
}
/** Everything the user's bots know about the user, with consent and forget controls. */
/** Everything the user's bots know about the user, with edit, consent and forget controls. */
export function UserProfileSettings() {
const { t } = useLingui();
const [state, setState] = useState<UserProfileState | null>(null);
const [spaces, setSpaces] = useState<Array<{ id: string; name: string }>>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [forgetOpen, setForgetOpen] = useState(false);
useEffect(() => {
let cancelled = false;
// Only names a fact's spaces; without them the profile still works.
rpc.spaces
.list()
.then((navigation) => {
if (!cancelled) setSpaces(navigation.spaces.map(({ id, name }) => ({ id, name })));
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
rpc.profile
@ -74,14 +93,16 @@ export function UserProfileSettings() {
};
}, [t]);
async function apply(change: () => Promise<UserProfileState>) {
if (busy) return;
async function apply(change: () => Promise<UserProfileState>): Promise<boolean> {
if (busy) return false;
setBusy(true);
setError(null);
try {
setState(await change());
return true;
} catch (err) {
setError(err instanceof Error ? err.message : t`Couldn't save`);
return false;
} finally {
setBusy(false);
}
@ -178,36 +199,75 @@ export function UserProfileSettings() {
{categoryLabels[group.category]}
</h3>
<ul className="mt-3 space-y-3">
{group.facts.map((fact) => (
<Row
key={fact.id}
testId="user-fact"
status={fact.status}
value={fact.value}
detail={sourceLabels[fact.source]}
actions={(describedBy) => (
<>
{fact.status === "pending" ? (
{group.facts.map((fact) =>
fact.id === editingId ? (
<UserFactEditor
key={fact.id}
fact={fact}
spaces={spaces}
categoryLabels={categoryLabels}
busy={busy}
onCancel={() => setEditingId(null)}
onSave={(draft) => {
const edit = userFactEdit(fact, draft);
if (!edit) {
setEditingId(null);
return;
}
void apply(() => rpc.profile.update({ editFacts: [edit] })).then((saved) => {
if (saved) setEditingId(null);
});
}}
/>
) : (
<Row
key={fact.id}
testId="user-fact"
status={fact.status}
value={fact.value}
detail={[
sourceLabels[fact.source],
factSpaceNames(fact.allSpaces ? null : fact.spaceIds, spaces).join(", "),
]
.filter(Boolean)
.join(" · ")}
actions={(describedBy) => (
<>
{fact.status === "pending" ? (
<Button
variant="outline"
size="sm"
aria-describedby={describedBy}
disabled={busy}
onClick={() =>
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
}
>
<Trans>Confirm</Trans>
</Button>
) : null}
<Button
variant="outline"
size="sm"
variant="ghost"
size="icon-sm"
aria-label={t`Edit`}
aria-describedby={describedBy}
disabled={busy}
onClick={() =>
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
}
onClick={() => {
setError(null);
setEditingId(fact.id);
}}
>
<Trans>Confirm</Trans>
<PencilIcon />
</Button>
) : null}
{deleteButton(
describedBy,
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
)}
</>
)}
/>
))}
{deleteButton(
describedBy,
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
)}
</>
)}
/>
),
)}
</ul>
</section>
))}

View File

@ -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<CoachRhythm | null>(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 (
<Popover
onOpenChange={(open) => {
if (open) void load();
}}
>
<PopoverTrigger
data-testid="heute-rhythm"
aria-label={t`Check-ins`}
title={t`Check-ins`}
render={<Button variant="ghost" size="icon" />}
>
<CalendarClock strokeWidth={1.7} aria-hidden="true" />
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
data-testid="heute-rhythm-panel"
className="w-[min(340px,calc(100vw-2rem))] gap-3 p-3"
>
{rhythm ? (
<>
<RhythmRow
label={t`Morning`}
testId="heute-rhythm-morning"
on={rhythm.morning !== null}
onToggle={(on) => void save({ ...rhythm, morning: on ? DEFAULTS.morning : null })}
>
{(labelId) =>
rhythm.morning ? (
<TimeInput
labelId={labelId}
value={rhythm.morning}
onChange={(morning) => void save({ ...rhythm, morning })}
/>
) : null
}
</RhythmRow>
<RhythmRow
label={t`Evening`}
testId="heute-rhythm-evening"
on={rhythm.evening !== null}
onToggle={(on) => void save({ ...rhythm, evening: on ? DEFAULTS.evening : null })}
>
{(labelId) =>
rhythm.evening ? (
<TimeInput
labelId={labelId}
value={rhythm.evening}
onChange={(evening) => void save({ ...rhythm, evening })}
/>
) : null
}
</RhythmRow>
<RhythmRow
label={t`Weekly`}
testId="heute-rhythm-weekly"
on={rhythm.weekly !== null}
onToggle={(on) => void save({ ...rhythm, weekly: on ? DEFAULTS.weekly : null })}
>
{(labelId) => {
const weekly = rhythm.weekly;
if (!weekly) return null;
return (
<>
<NativeSelect
size="sm"
aria-labelledby={labelId}
value={String(weekly.day)}
onChange={(event) =>
void save({
...rhythm,
weekly: { ...weekly, day: Number(event.target.value) },
})
}
>
{WEEKDAYS.map((day) => (
<NativeSelectOption key={day} value={day}>
{weekdayName(day)}
</NativeSelectOption>
))}
</NativeSelect>
<TimeInput
labelId={labelId}
value={weekly.time}
onChange={(time) => void save({ ...rhythm, weekly: { ...weekly, time } })}
/>
</>
);
}}
</RhythmRow>
</>
) : (
<div className="flex justify-center py-3">
<Spinner />
</div>
)}
</PopoverContent>
</Popover>
);
}
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 (
<div data-testid={testId} className="flex min-h-control-sm flex-wrap items-center gap-2">
<Switch
size="sm"
checked={on}
aria-labelledby={labelId}
onCheckedChange={(checked) => onToggle(checked)}
/>
<span id={labelId} className="flex-1 text-ui-body text-foreground">
{label}
</span>
<div className="ml-auto flex items-center gap-2">{children(labelId)}</div>
</div>
);
}
function TimeInput({
labelId,
value,
onChange,
}: {
labelId: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Input
type="time"
aria-labelledby={labelId}
value={value}
onChange={(event) => {
if (event.target.value) onChange(event.target.value.slice(0, 5));
}}
className="h-control-sm w-32 px-2 tabular-nums"
/>
);
}

View File

@ -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 ? (
<section data-testid="heute-coach-brief" className="rounded-2xl bg-muted/60 px-4 py-3.5">
<p className="whitespace-pre-line text-ui-body-lg text-foreground">{today.brief.text}</p>
</section>
) : null}
{today.habits.length > 0 ? (
<section data-testid="heute-habits">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Habits</Trans>
</h2>
<ul className="flex flex-col gap-1">
{today.habits.map((habit) => (
<li key={habit.id}>
<HabitRow habit={habit} onDone={(done) => onHabitDone(habit, done)} />
</li>
))}
</ul>
</section>
) : null}
{today.goals.length > 0 ? (
<section data-testid="heute-goals">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Goals</Trans>
</h2>
<ul className="flex flex-col gap-1">
{today.goals.map((goal) => (
<li
key={goal.id}
data-testid={`heute-goal-${goal.id}`}
className="flex items-baseline justify-between gap-3 px-3 py-2"
>
<span className="min-w-0 truncate text-ui-body text-foreground">{goal.title}</span>
{goal.targetDate ? (
<span className="shrink-0 text-ui-label text-muted-foreground tabular-nums">
{formatLocalDate(goal.targetDate, i18n.locale || "en", {
day: "numeric",
month: "short",
})}
</span>
) : null}
</li>
))}
</ul>
</section>
) : null}
</>
);
}
function HabitRow({ habit, onDone }: { habit: HabitToday; onDone: (done: boolean) => void }) {
const id = useId();
return (
<label
htmlFor={id}
data-testid={`heute-habit-${habit.id}`}
className="flex cursor-pointer items-center gap-3 rounded-2xl px-3 py-2.5 transition-colors hover:bg-accent"
>
<Checkbox id={id} checked={habit.doneToday} onCheckedChange={(checked) => onDone(checked)} />
<span
className={cn(
"min-w-0 flex-1 truncate text-ui-body",
habit.doneToday ? "text-muted-foreground" : "text-foreground",
)}
>
{habit.title}
</span>
{habit.streak >= STREAK_MIN ? (
<span
data-testid={`heute-habit-streak-${habit.id}`}
className="flex shrink-0 items-center gap-1 text-ui-label text-muted-foreground tabular-nums"
>
<Flame strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
{habit.cadence === "weekly" ? (
<Plural value={habit.streak} one="# week" other="# weeks" />
) : (
<Plural value={habit.streak} one="# day" other="# days" />
)}
</span>
) : null}
</label>
);
}

View File

@ -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<ScanStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [, setScanId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let pollingInterval: ReturnType<typeof setInterval> | 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 (
<div className="mx-auto max-w-xl text-center">
<p className="text-destructive">{error}</p>
<button type="button" className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
Continue anyway
</button>
</div>
);
}
if (!status) {
return (
<div className="mx-auto max-w-xl text-center">
<div className="mt-6 animate-spin rounded-full border-4 border-border border-t-primary h-8 w-8" />
<p className="mt-4 text-muted-foreground">Preparing your profile...</p>
</div>
);
}
const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status);
const isComplete = status.status === "complete";
return (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground text-center">
{isComplete ? "All set!" : "Getting to know you..."}
</h2>
<p className="mt-1 text-center text-sm text-muted-foreground">
{isComplete
? "Your profile and agent team are ready."
: "We're scanning your computer and services to personalize your experience."}
</p>
<div className="mt-6 space-y-4">
{PHASES.map((phase, idx) => {
const isActive = idx === currentPhaseIndex && !isComplete;
const isDone = idx < currentPhaseIndex || isComplete;
return (
<div
key={phase.key}
className={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all ${
isActive
? "border-primary bg-primary/5"
: isDone
? "border-emerald-500/30 bg-emerald-500/5"
: "border-border bg-card"
}`}
>
<div className="flex-shrink-0">
{isDone ? (
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">
✓
</span>
) : isActive ? (
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">
●
</span>
) : (
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">
○
</span>
)}
</div>
<div className="min-w-0 flex-1">
<p
className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}
>
{phase.label}
</p>
<p className="text-xs text-muted-foreground/60">{phase.sublabel}</p>
</div>
{isActive && (
<div className="flex-shrink-0">
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted/30">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${status.progress}%` }}
/>
</div>
</div>
)}
</div>
);
})}
</div>
{isComplete && (
<div className="mt-8 text-center">
<button
type="button"
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white"
onClick={onComplete}
>
Continue to your workspace →
</button>
</div>
)}
</div>
);
}

View File

@ -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<string | null>(null);
const [looking, setLooking] = useState(false);
const [scanId, setScanId] = useState<string | null>(null);
const [found, setFound] = useState<Found | null>(null);
const [failed, setFailed] = useState(false);
const [projectIds, setProjectIds] = useState<string[]>([]);
const [factIds, setFactIds] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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<typeof setTimeout> | 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 = (
<div className="mt-8 flex justify-end">
<Button onClick={() => done.current()}>
<Trans>Continue</Trans>
</Button>
</div>
);
if (failed) {
return (
<div className="mx-auto max-w-xl">
<p className="text-sm text-destructive">
<Trans>Could not look in this folder.</Trans>
</p>
{continueButton}
</div>
);
}
if (found && found.projects.length === 0) {
return (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>No projects found</Trans>
</h2>
{continueButton}
</div>
);
}
if (found) {
return (
<div className="mx-auto max-w-xl">
<fieldset className="border-0 p-0">
<legend>
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Add as projects</Trans>
</h2>
</legend>
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
{found.projects.map((project) => (
<label
key={project.id}
htmlFor={`${ids}-${project.id}`}
className="flex cursor-pointer items-start gap-3 rounded-lg px-2 py-2 hover:bg-muted"
>
<Checkbox
id={`${ids}-${project.id}`}
checked={projectIds.includes(project.id)}
disabled={saving}
onCheckedChange={(checked) =>
setProjectIds((current) => toggled(current, project.id, checked))
}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{project.name}</span>
{project.path !== project.name ? (
<span className="block truncate font-mono text-xs text-muted-foreground">
{project.path}
</span>
) : null}
</span>
</label>
))}
</div>
</fieldset>
{found.facts.length > 0 ? (
<fieldset className="mt-6 border-0 p-0">
<legend className="text-sm font-medium text-foreground">
<Trans>Add to About you</Trans>
</legend>
<div className="mt-3 flex flex-wrap gap-2">
{found.facts.map((fact) => (
<label
key={fact.id}
htmlFor={`${ids}-${fact.id}`}
className="flex cursor-pointer items-center gap-2 rounded-full border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
<Checkbox
id={`${ids}-${fact.id}`}
checked={factIds.includes(fact.id)}
disabled={saving}
onCheckedChange={(checked) =>
setFactIds((current) => toggled(current, fact.id, checked))
}
/>
{fact.label}
</label>
))}
</div>
</fieldset>
) : null}
{error ? <p className="mt-4 text-sm text-destructive">{error}</p> : null}
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" disabled={saving} onClick={() => done.current()}>
<Trans>Skip</Trans>
</Button>
<Button
disabled={saving || (projectIds.length === 0 && factIds.length === 0)}
onClick={() => void add()}
>
<Trans>Add</Trans>
</Button>
</div>
</div>
);
}
if (looking) {
return (
<div className="mx-auto max-w-xl">
<LoadingState label={t`Looking`} />
</div>
);
}
if (!folder) return null;
return (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Look for projects here?</Trans>
</h2>
<p className="mt-3 break-all rounded-lg bg-muted px-3 py-2 font-mono text-sm text-foreground">
{folder}
</p>
<p className="mt-2 text-sm text-muted-foreground">
<Trans>Only file and folder names are read.</Trans>
</p>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => done.current()}>
<Trans>Skip</Trans>
</Button>
<Button onClick={() => void look()}>
<Trans>Look</Trans>
</Button>
</div>
</div>
);
}

View File

@ -55,22 +55,22 @@ export function StageHero({
) : null}
<div
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-xl`}
style={
reducedMotion
? undefined
: {
boxShadow: `0 16px 48px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.12), 0 0 ${Math.round(18 + energy * 32)}px ${glowRgb}`,
}
}
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_12px_32px_-12px_rgba(24,24,22,0.18)] backdrop-blur-xl dark:shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)]`}
>
{reducedMotion ? null : (
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{ boxShadow: `0 0 ${Math.round(18 + energy * 32)}px ${glowRgb}` }}
/>
)}
<BotAvatar
color={botColor}
identity={botId}
status={botStatus}
pose={pose}
size={avatarSize}
className="drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
className="drop-shadow-[0_6px_16px_rgba(0,0,0,0.12)] dark:drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
/>
</div>

View File

@ -41,7 +41,7 @@ export function TextStageCanvas({
style={{
background: `radial-gradient(circle, ${phaseTint} 0%, ${personaTint} 42%, transparent 72%)`,
opacity: 0.55 + energy * 0.2,
transform: `translateX(-50%) scale(${bloomScale})`,
transform: `scale(${bloomScale})`,
transition: reducedMotion
? undefined
: "transform 140ms ease-out, opacity 140ms ease-out",

View File

@ -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> = {}): 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",
);
});
});

View File

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

View File

@ -0,0 +1,203 @@
import type { MessageBlock, ThreadMessage } from "@rakazo/contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "./i18n.js";
import {
intakeNoteText,
localizedChoice,
localizedPreview,
withIntakeNoteText,
} from "./onboarding-copy.js";
// The macros compile at build time; here the English source is the id, placeholders numbered.
vi.mock("@lingui/core/macro", () => {
const descriptor = (parts: TemplateStringsArray, values: unknown[]) => {
const message = parts.reduce((out, part, index) => `${out}{${index - 1}}${part}`);
return { id: message, message, values: { ...values } };
};
return {
t: (parts: TemplateStringsArray, ...values: unknown[]) => i18n._(descriptor(parts, values)),
msg: (parts: TemplateStringsArray, ...values: unknown[]) => descriptor(parts, values),
};
});
type Choice = Extract<MessageBlock, { kind: "choice" }>;
type Note = Extract<MessageBlock, { kind: "intake_note" }>;
// 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." },
]);
});
});
describe("localizedPreview", () => {
const now = new Date(2026, 9, 7, 10, 0);
const rhythm: Choice = {
kind: "choice",
step: "rhythm",
question: "server question",
options: [
{ id: "morning", letter: "A", label: "server morning" },
{ id: "none", letter: "", label: "server none" },
],
};
const goal: Choice = {
kind: "choice",
step: "goal",
question: "server goal",
textInput: { placeholder: "server placeholder" },
options: [{ id: "skip", letter: "", label: "server skip" }],
};
const done: Note = {
kind: "intake_note",
note: "done",
nextCheckInAt: new Date(2026, 9, 8, 8, 0).toISOString(),
text: "server text",
};
const preview = (previewBlock?: Choice | Note) => localizedPreview({ preview: "", previewBlock });
beforeEach(() => {
vi.useFakeTimers({ now });
return () => vi.useRealTimers();
});
it("keeps the server preview when onboarding did not write the latest message", () => {
expect(localizedPreview({ preview: "See you at 8" })).toBe("See you at 8");
});
it("reads like the conversation: the question, the answer once given, the closing note", () => {
expect(preview(rhythm)).toBe("When should I check in with you?");
expect(preview({ ...rhythm, answerId: "morning" })).toBe("Every morning");
expect(preview({ ...goal, answerId: "_text", answerText: "Run a 10K" })).toBe("Run a 10K");
expect(plain(preview(done))).toBe("All set. I’ll check in tomorrow at 8:00 AM.");
// A dismissed card is gone from the conversation, so the preview is too.
expect(preview({ ...rhythm, answerId: "_dismissed" })).toBe("");
});
it("speaks the user's language", () => {
i18n.load("de", {
"When should I check in with you?": "Wann soll ich mich bei dir melden?",
"Every morning": "Jeden Morgen",
"All set. I’ll check in tomorrow at {0}.": "Alles klar. Ich melde mich morgen um {0}.",
"All set. I’m here whenever you need me.":
"Alles klar. Ich bin da, wann immer du mich brauchst.",
});
i18n.activate("de");
expect(preview(rhythm)).toBe("Wann soll ich mich bei dir melden?");
expect(preview({ ...rhythm, answerId: "morning" })).toBe("Jeden Morgen");
expect(preview(done)).toBe("Alles klar. Ich melde mich morgen um 8:00.");
expect(preview({ ...done, nextCheckInAt: null })).toBe(
"Alles klar. Ich bin da, wann immer du mich brauchst.",
);
});
});

View File

@ -0,0 +1,130 @@
import type { MessageDescriptor } from "@lingui/core";
import { msg, t } from "@lingui/core/macro";
import type { Group, MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } 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<MessageBlock, { kind: "choice" }>;
type ChoiceOption = ChoiceBlock["options"][number];
type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
const QUESTIONS: Record<OnboardingStep, MessageDescriptor> = {
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<OnboardingStep, Record<string, MessageDescriptor>> = {
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<Record<OnboardingStep, MessageDescriptor>> = {
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) } } : {}),
};
}
/** What the user answered: the option they picked or their own words. */
export function chosenOption(card: ChoiceBlock): ChoiceOption | undefined {
return card.answerText
? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: card.answerText }
: card.options.find((option) => option.id === card.answerId);
}
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,
),
};
}
/**
* A conversation's preview in the user's language. An onboarding card reads as its question, or
* as the answer once given; the closing note as the note. A dismissed card shows nothing in the
* conversation, so it keeps the server preview.
*/
export function localizedPreview(chat: Pick<Group, "preview" | "previewBlock">): string {
const block = chat.previewBlock;
if (!block) return chat.preview;
if (block.kind === "intake_note") return intakeNoteText(block);
const card = localizedChoice(block);
if (!card.answerId) return card.question;
return chosenOption(card)?.label ?? chat.preview;
}

View File

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

View File

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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 })) }
: {}),
};
}

View File

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

View File

@ -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<CoachRoutineKey, () => 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<Routine, "name" | "coachKey">): 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<Routine, "name" | "coachKey"> | null,
draftName: string,
): string {
return routine && draftName === routineName(routine) ? routine.name : draftName;
}

View File

@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest";
import {
displayLanguageTag,
displayRegionTag,
factSpaceNames,
filledProfileFields,
groupUserFacts,
toggleFactSpace,
userFactDraft,
userFactEdit,
} from "./user-profile-view";
function fact(overrides: Partial<UserFact> & Pick<UserFact, "id">): UserFact {
@ -74,3 +78,43 @@ describe("user profile view", () => {
expect(displayRegionTag("not a tag", "en")).toBe("not a tag");
});
});
describe("user fact editing", () => {
const tea = fact({ id: "tea", value: "Prefers tea", category: "preferences" });
const limited = fact({ id: "lin", allSpaces: false, spaceIds: ["space-b", "space-a"] });
it("sends only what changed, or nothing", () => {
expect(userFactEdit(tea, userFactDraft(tea))).toBeNull();
expect(userFactEdit(tea, { ...userFactDraft(tea), value: " Prefers tea " })).toBeNull();
expect(
userFactEdit(limited, { ...userFactDraft(limited), spaceIds: ["space-a", "space-b"] }),
).toBeNull();
expect(
userFactEdit(tea, { value: " Prefers green tea ", category: "habits", spaceIds: null }),
).toEqual({ id: "tea", value: "Prefers green tea", category: "habits" });
expect(userFactEdit(tea, { ...userFactDraft(tea), spaceIds: ["space-a"] })).toEqual({
id: "tea",
spaceIds: ["space-a"],
});
expect(userFactEdit(limited, { ...userFactDraft(limited), spaceIds: null })).toEqual({
id: "lin",
spaceIds: null,
});
});
it("limits a fact to checked spaces and shows it everywhere when none is left", () => {
expect(toggleFactSpace(null, "space-a", true)).toEqual(["space-a"]);
expect(toggleFactSpace(["space-a"], "space-b", true)).toEqual(["space-a", "space-b"]);
expect(toggleFactSpace(["space-a", "space-b"], "space-a", false)).toEqual(["space-b"]);
expect(toggleFactSpace(["space-a"], "space-a", false)).toBeNull();
});
it("names the spaces of a limited fact", () => {
const spaces = [
{ id: "space-a", name: "Home" },
{ id: "space-b", name: "Side project" },
];
expect(factSpaceNames(["space-b", "gone"], spaces)).toEqual(["Side project"]);
expect(factSpaceNames(null, spaces)).toEqual([]);
});
});

View File

@ -1,6 +1,15 @@
import type { UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts";
import type { UpdateUserProfile, UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts";
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
/** What the fact editor holds: null spaces means every space. */
export interface UserFactDraft {
value: string;
category: UserFactCategory;
spaceIds: string[] | null;
}
type UserFactEdit = NonNullable<UpdateUserProfile["editFacts"]>[number];
export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary";
const PROFILE_FIELDS: ProfileField[] = [
@ -75,3 +84,49 @@ export function displayRegionTag(tag: string, uiLocale: string): string {
return tag;
}
}
export function userFactDraft(fact: UserFact): UserFactDraft {
return {
value: fact.value,
category: fact.category,
spaceIds: fact.allSpaces ? null : fact.spaceIds,
};
}
/** The `editFacts` entry for what changed in the draft, or null when nothing did. */
export function userFactEdit(fact: UserFact, draft: UserFactDraft): UserFactEdit | null {
const edit: UserFactEdit = { id: fact.id };
const value = draft.value.trim();
if (value !== fact.value) edit.value = value;
if (draft.category !== fact.category) edit.category = draft.category;
const before = userFactDraft(fact).spaceIds;
const sameSpaces =
before === null || draft.spaceIds === null
? before === draft.spaceIds
: before.length === draft.spaceIds.length &&
draft.spaceIds.every((spaceId) => before.includes(spaceId));
if (!sameSpaces) edit.spaceIds = draft.spaceIds;
return Object.keys(edit).length > 1 ? edit : null;
}
/** Checks or unchecks one space; unchecking the last one shows the fact everywhere again. */
export function toggleFactSpace(
spaceIds: string[] | null,
spaceId: string,
checked: boolean,
): string[] | null {
const current = spaceIds ?? [];
const next = checked
? [...current.filter((id) => id !== spaceId), spaceId]
: current.filter((id) => id !== spaceId);
return next.length > 0 ? next : null;
}
/** Names of the spaces a limited fact shows in, in the order of `spaces`; unknown ids are skipped. */
export function factSpaceNames(
spaceIds: string[] | null,
spaces: ReadonlyArray<{ id: string; name: string }>,
): string[] {
if (!spaceIds) return [];
return spaces.filter((space) => spaceIds.includes(space.id)).map((space) => space.name);
}

View File

@ -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<void> {
if (!webPushSupported()) return;
await navigator.serviceWorker
.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE })
.catch(() => undefined);
}
async function currentSubscription(): Promise<PushSubscription | null> {
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<boolean> {
if (!webPushSupported() || Notification.permission !== "granted") return false;
return Boolean(await currentSubscription().catch(() => null));
}
function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> {
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<void> {
if (!webPushSupported()) return;
const subscription = await currentSubscription();
if (!subscription) return;
await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint });
await subscription.unsubscribe().catch(() => undefined);
}

View File

@ -1,17 +1,18 @@
#
msgid ""
msgstr ""
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: de\n"
"Project-Id-Version: rakazo-web\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"X-Generator: @lingui/cli\n"
#: src/pages/Shell.tsx
msgid " (unread)"
@ -21,6 +22,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 +37,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)"
@ -282,10 +293,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"
@ -351,6 +358,7 @@ msgstr "Aktives Modell"
msgid "Activity"
msgstr "Aktivität"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -388,6 +396,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"
@ -429,6 +441,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"
@ -496,6 +512,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"
@ -703,10 +739,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"
@ -743,10 +775,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"
@ -756,6 +784,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…"
@ -824,6 +856,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"
@ -837,6 +873,7 @@ msgstr "Server nicht erreichbar."
#: src/components/ComputerUpdateProgress.tsx
#: src/components/media/MediaCard.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -872,9 +909,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"
@ -930,6 +967,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…"
@ -1139,7 +1180,6 @@ msgid "Command"
msgstr "Befehl"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr "Kommunikationsstil"
@ -1300,8 +1340,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"
@ -1519,6 +1559,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."
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
msgstr "{name} konnte nicht hochgeladen werden. Versuche es erneut."
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr "Konnte nicht geladen werden"
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
msgstr "Messaging-Einstellungen konnten nicht geladen werden"
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr "Konnte nicht gespeichert werden"
@ -1826,6 +1872,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"
@ -1834,10 +1884,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"
@ -1938,18 +1984,10 @@ msgstr "Desktop-App"
msgid "Desktop update"
msgstr "Desktop-Update"
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr "Ausführlich & erklärend"
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr "Entwicklertools"
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr "Direkt & knapp"
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr "Direktes MCP"
@ -2073,6 +2111,10 @@ msgstr "z. B. Hivenet Agent, Presales, Zeiterfassungs-Bot"
msgid "Earlier message"
msgstr "Frühere Nachricht"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr "Bearbeiten"
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Zuerst bearbeiten"
@ -2122,6 +2164,14 @@ msgstr "Gib einen absoluten Ordnerpfad auf dem Server ein. Git-Remotes werden au
msgid "Enter this code at <0>{0}</0>"
msgstr "Gib diesen Code unter <0>{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"
@ -2150,6 +2200,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"
@ -2191,6 +2245,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"
@ -2265,10 +2323,6 @@ msgstr "Erste {shownRows} Zeilen"
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"
@ -2333,6 +2387,7 @@ msgstr "Git-Ereignis"
msgid "Goal"
msgstr "Ziel"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2369,6 +2424,7 @@ msgstr "Updates bündeln"
msgid "Groups"
msgstr "Gruppen"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr "Gewohnheiten"
@ -2443,10 +2499,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"
@ -2460,10 +2512,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"
@ -2472,10 +2520,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"
@ -2524,6 +2568,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"
@ -2747,6 +2795,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
@ -2757,10 +2809,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"
@ -2925,10 +2985,6 @@ msgstr "Minute"
msgid "minutes"
msgstr "Minuten"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr "Mission Control"
#: src/components/MediaSettings.tsx
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
@ -2986,6 +3042,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."
@ -2997,7 +3065,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
@ -3176,6 +3243,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."
@ -3233,6 +3304,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"
@ -3245,14 +3317,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"
@ -3266,6 +3335,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."
@ -3299,6 +3372,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…"
@ -3307,6 +3384,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"
@ -3559,6 +3640,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/components/media/MediaCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -3570,14 +3655,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"
@ -3798,6 +3891,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"
@ -3938,6 +4035,7 @@ msgstr "Sandbox"
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4040,15 +4138,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"
@ -4213,7 +4308,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"
@ -4298,6 +4395,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"
@ -4391,10 +4492,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"
@ -4480,10 +4577,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"
@ -4574,10 +4667,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"
@ -4640,6 +4729,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"
@ -4841,6 +4934,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?"
@ -4849,6 +4950,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?"
@ -4889,6 +4994,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?"
@ -4901,6 +5010,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"
@ -4926,10 +5043,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"
@ -4987,8 +5100,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"

View File

@ -1,17 +1,18 @@
#
msgid ""
msgstr ""
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: rakazo-web\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"X-Generator: @lingui/cli\n"
#: src/pages/Shell.tsx
msgid " (unread)"
@ -21,6 +22,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 +37,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)"
@ -282,10 +293,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"
@ -351,6 +358,7 @@ msgstr "Active model"
msgid "Activity"
msgstr "Activity"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -388,6 +396,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"
@ -429,6 +441,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"
@ -496,6 +512,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"
@ -703,10 +739,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"
@ -743,10 +775,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"
@ -756,6 +784,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…"
@ -824,6 +856,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"
@ -837,6 +873,7 @@ msgstr "Can't reach the server."
#: src/components/ComputerUpdateProgress.tsx
#: src/components/media/MediaCard.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -872,9 +909,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"
@ -930,6 +967,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…"
@ -1139,7 +1180,6 @@ msgid "Command"
msgstr "Command"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr "Communication style"
@ -1300,8 +1340,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"
@ -1519,6 +1559,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."
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
msgstr "Could not upload {name}. Try again."
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr "Couldn't load"
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
msgstr "Couldn't load messaging settings"
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr "Couldn't save"
@ -1826,6 +1872,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"
@ -1834,10 +1884,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"
@ -1938,18 +1984,10 @@ msgstr "Desktop app"
msgid "Desktop update"
msgstr "Desktop update"
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr "Detailed & explanatory"
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr "Developer tools"
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr "Direct & concise"
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr "Direct MCP"
@ -2073,6 +2111,10 @@ msgstr "e.g. Hivenet Agent, Presales, Timesheets bot"
msgid "Earlier message"
msgstr "Earlier message"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr "Edit"
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Edit first"
@ -2122,6 +2164,14 @@ msgstr "Enter an absolute folder path on the server. Git remotes are linked auto
msgid "Enter this code at <0>{0}</0>"
msgstr "Enter this code at <0>{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"
@ -2150,6 +2200,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"
@ -2191,6 +2245,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"
@ -2265,10 +2323,6 @@ msgstr "First {shownRows} rows"
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"
@ -2333,6 +2387,7 @@ msgstr "Git event"
msgid "Goal"
msgstr "Goal"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2369,6 +2424,7 @@ msgstr "Group updates"
msgid "Groups"
msgstr "Groups"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr "Habits"
@ -2443,10 +2499,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"
@ -2460,10 +2512,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"
@ -2472,10 +2520,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"
@ -2524,6 +2568,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"
@ -2747,6 +2795,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
@ -2757,10 +2809,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"
@ -2925,10 +2985,6 @@ msgstr "minute"
msgid "minutes"
msgstr "minutes"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr "Mission Control"
#: src/components/MediaSettings.tsx
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
@ -2986,6 +3042,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."
@ -2997,7 +3065,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
@ -3176,6 +3243,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."
@ -3233,6 +3304,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"
@ -3245,14 +3317,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"
@ -3266,6 +3335,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."
@ -3299,6 +3372,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…"
@ -3307,6 +3384,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"
@ -3559,6 +3640,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/components/media/MediaCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -3570,14 +3655,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"
@ -3798,6 +3891,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"
@ -3938,6 +4035,7 @@ msgstr "Sandbox"
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4040,15 +4138,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"
@ -4213,7 +4308,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"
@ -4298,6 +4395,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"
@ -4391,10 +4492,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"
@ -4480,10 +4577,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"
@ -4574,10 +4667,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"
@ -4640,6 +4729,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"
@ -4841,6 +4934,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?"
@ -4849,6 +4950,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?"
@ -4889,6 +4994,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?"
@ -4901,6 +5010,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"
@ -4926,10 +5043,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"
@ -4987,8 +5100,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"

View File

@ -1,17 +1,18 @@
#
msgid ""
msgstr ""
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: es\n"
"Project-Id-Version: rakazo-web\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: @lingui/cli\n"
#: src/pages/Shell.tsx
msgid " (unread)"
@ -21,6 +22,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 +37,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)"
@ -282,10 +293,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 ""
@ -351,6 +358,7 @@ msgstr "Modelo activo"
msgid "Activity"
msgstr "Actividad"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -388,6 +396,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"
@ -429,6 +441,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"
@ -496,6 +512,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"
@ -703,10 +739,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 ""
@ -743,10 +775,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"
@ -756,6 +784,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…"
@ -824,6 +856,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 ""
@ -837,6 +873,7 @@ msgstr "No se puede alcanzar el servidor."
#: src/components/ComputerUpdateProgress.tsx
#: src/components/media/MediaCard.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -863,7 +900,7 @@ msgstr "cancelado"
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Cancelled"
msgstr ""
msgstr "Cancelado"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/AskCard.tsx
@ -872,8 +909,8 @@ msgstr ""
msgid "Cancelled"
msgstr "Cancelado"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -930,6 +967,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 ""
@ -1139,7 +1180,6 @@ msgid "Command"
msgstr "Comando"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1300,8 +1340,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"
@ -1519,6 +1559,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 ""
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
msgstr "No se pudo cargar la configuración de mensajería"
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1826,6 +1872,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"
@ -1834,10 +1884,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"
@ -1938,18 +1984,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2073,6 +2111,10 @@ msgstr "p. ej. Hivenet Agent, Presales, bot de hojas de horas"
msgid "Earlier message"
msgstr "Mensaje anterior"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Editar primero"
@ -2122,6 +2164,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "Ingresa este código en <0>{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"
@ -2150,6 +2200,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"
@ -2191,6 +2245,10 @@ msgstr ""
msgid "Extra high"
msgstr "Extra alto"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "fallido"
@ -2265,10 +2323,6 @@ msgstr ""
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 ""
@ -2333,6 +2387,7 @@ msgstr "Evento de Git"
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2369,6 +2424,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2443,10 +2499,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 ""
@ -2460,10 +2512,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"
@ -2472,10 +2520,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"
@ -2524,6 +2568,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"
@ -2747,6 +2795,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
@ -2757,10 +2809,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"
@ -2925,10 +2985,6 @@ msgstr "minuto"
msgid "minutes"
msgstr "minutos"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
@ -2986,6 +3042,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."
@ -2997,7 +3065,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
@ -3176,6 +3243,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 ""
@ -3233,6 +3304,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"
@ -3245,14 +3317,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"
@ -3266,6 +3335,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."
@ -3299,6 +3372,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 ""
@ -3307,6 +3384,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"
@ -3559,6 +3640,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/components/media/MediaCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -3570,14 +3655,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 ""
@ -3798,6 +3891,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 ""
@ -3938,6 +4035,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4040,15 +4138,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"
@ -4213,7 +4308,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"
@ -4298,6 +4395,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 ""
@ -4391,10 +4492,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "subagente"
@ -4480,10 +4577,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"
@ -4574,10 +4667,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"
@ -4640,6 +4729,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"
@ -4841,6 +4934,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?"
@ -4849,6 +4950,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 ""
@ -4889,6 +4994,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 ""
@ -4901,6 +5010,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"
@ -4926,10 +5043,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 ""
@ -4945,7 +5058,7 @@ msgstr ""
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Working…"
msgstr ""
msgstr "Trabajando…"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
@ -4987,8 +5100,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"

View File

@ -1,17 +1,18 @@
#
msgid ""
msgstr ""
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: hi\n"
"Project-Id-Version: rakazo-web\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: hi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"X-Generator: @lingui/cli\n"
#: src/pages/Shell.tsx
msgid " (unread)"
@ -21,6 +22,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 +37,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)"
@ -282,10 +293,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 ""
@ -351,6 +358,7 @@ msgstr "सक्रिय मॉडल"
msgid "Activity"
msgstr "गतिविधि"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -388,6 +396,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 जोड़ें"
@ -429,6 +441,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 "रूटीन में जोड़ें"
@ -496,6 +512,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"
@ -703,10 +739,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 ""
@ -743,10 +775,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"
@ -756,6 +784,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 "लाइव डेस्कटॉप शुरू हो रहा है…"
@ -824,6 +856,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 ""
@ -837,6 +873,7 @@ msgstr "सर्वर तक नहीं पहुंचा जा सका
#: src/components/ComputerUpdateProgress.tsx
#: src/components/media/MediaCard.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -863,7 +900,7 @@ msgstr "रद्द"
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Cancelled"
msgstr ""
msgstr "रद्द किया गया"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/AskCard.tsx
@ -872,8 +909,8 @@ msgstr ""
msgid "Cancelled"
msgstr "रद्द किया गया"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -930,6 +967,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 ""
@ -1139,7 +1180,6 @@ msgid "Command"
msgstr "कमांड"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1300,8 +1340,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 "जारी रखें"
@ -1519,6 +1559,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 ""
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1826,6 +1872,10 @@ msgstr ""
msgid "day"
msgstr "दिन"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "दिन"
@ -1834,10 +1884,6 @@ msgstr "दिन"
msgid "Decline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "डिफ़ॉल्ट"
@ -1938,18 +1984,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2073,6 +2111,10 @@ msgstr "उदा. Hivenet Agent, Presales, टाइमशीट बॉट"
msgid "Earlier message"
msgstr "पुराना संदेश"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "पहले संपादित करें"
@ -2122,6 +2164,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "यह कोड <0>{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"
@ -2150,6 +2200,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"
@ -2191,6 +2245,10 @@ msgstr ""
msgid "Extra high"
msgstr "अतिरिक्त उच्च"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "असफल"
@ -2265,10 +2323,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2333,6 +2387,7 @@ msgstr ""
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2369,6 +2424,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2443,10 +2499,6 @@ msgstr ""
msgid "High"
msgstr "उच्च"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2460,10 +2512,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 "कितनी बार"
@ -2472,10 +2520,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 "जांच कैसे करें"
@ -2524,6 +2568,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 "डिफ़ॉल्ट का पालन करें"
@ -2747,6 +2795,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
@ -2757,10 +2809,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"
@ -2925,10 +2985,6 @@ msgstr "मिनट"
msgid "minutes"
msgstr "मिनट"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
@ -2986,6 +3042,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 "उन्हें अपनी साझा मेमोरी में ले जाएं।"
@ -2997,7 +3065,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
@ -3176,6 +3243,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 ""
@ -3233,6 +3304,7 @@ msgstr "कनेक्टेड नहीं"
msgid "Not in the plugin catalog"
msgstr ""
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "अभी नहीं"
@ -3245,14 +3317,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 "सूचनाएं"
@ -3266,6 +3335,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 ऑथराइज़ेशन रद्द कर दिया गया।"
@ -3299,6 +3372,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 ""
@ -3307,6 +3384,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"
@ -3559,6 +3640,10 @@ msgstr "प्रोवाइडर्स"
msgid "Pull request"
msgstr "पुल अनुरोध"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -3570,14 +3655,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 ""
@ -3798,6 +3891,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 ""
@ -3938,6 +4035,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4040,15 +4138,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 "भेजें"
@ -4213,7 +4308,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 "छोड़ें"
@ -4298,6 +4395,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 ""
@ -4391,10 +4492,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "सबएजेंट"
@ -4480,10 +4577,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 "टेस्ट करें"
@ -4574,10 +4667,6 @@ msgstr "दिन का समय"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "शीर्षक"
@ -4640,6 +4729,10 @@ msgstr ""
msgid "Try again"
msgstr "फिर से कोशिश करें"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "अपना उत्तर लिखें"
@ -4841,6 +4934,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 "इसकी मेमोरी का क्या करें?"
@ -4849,6 +4950,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 ""
@ -4889,6 +4994,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 ""
@ -4901,6 +5010,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 "कब चलाएं"
@ -4926,10 +5043,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 ""
@ -4945,7 +5058,7 @@ msgstr ""
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Working…"
msgstr ""
msgstr "काम चल रहा है…"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
@ -4987,8 +5100,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 "आपका नाम"

View File

@ -1,17 +1,18 @@
#
msgid ""
msgstr ""
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: ko\n"
"Project-Id-Version: rakazo-web\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: ko\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"X-Generator: @lingui/cli\n"
#: src/pages/Shell.tsx
msgid " (unread)"
@ -21,6 +22,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 +37,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)"
@ -282,10 +293,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 ""
@ -351,6 +358,7 @@ msgstr "현재 모델"
msgid "Activity"
msgstr "활동"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -388,6 +396,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 추가"
@ -429,6 +441,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 "자동 실행에 추가"
@ -496,6 +512,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"
@ -703,10 +739,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 ""
@ -743,10 +775,6 @@ msgstr "로그인으로 돌아가기"
msgid "Back to timeline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "서버 주소"
@ -756,6 +784,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 "라이브 데스크톱 시작 중…"
@ -824,6 +856,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 ""
@ -837,6 +873,7 @@ msgstr "서버에 연결할 수 없습니다."
#: src/components/ComputerUpdateProgress.tsx
#: src/components/media/MediaCard.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -863,7 +900,7 @@ msgstr "취소됨"
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Cancelled"
msgstr ""
msgstr "취소됨"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/AskCard.tsx
@ -872,8 +909,8 @@ msgstr ""
msgid "Cancelled"
msgstr "취소됨"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -930,6 +967,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 ""
@ -1139,7 +1180,6 @@ msgid "Command"
msgstr "실행 명령"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1300,8 +1340,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 "계속"
@ -1519,6 +1559,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 ""
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
msgstr "메시징 설정을 불러올 수 없습니다"
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1826,6 +1872,10 @@ msgstr ""
msgid "day"
msgstr "일"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "일"
@ -1834,10 +1884,6 @@ msgstr "일"
msgid "Decline"
msgstr "거절"
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "기본값"
@ -1938,18 +1984,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2073,6 +2111,10 @@ msgstr "예: Hivenet Agent, Presales, 근무표 봇"
msgid "Earlier message"
msgstr "이전 메시지"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "수정 후 보내기"
@ -2122,6 +2164,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "<0>{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"
@ -2150,6 +2200,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"
@ -2191,6 +2245,10 @@ msgstr ""
msgid "Extra high"
msgstr "매우 높음"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "실패"
@ -2265,10 +2323,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr "예상하지 못한 작업 표시"
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2333,6 +2387,7 @@ msgstr "Git 이벤트"
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2369,6 +2424,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2443,10 +2499,6 @@ msgstr "비밀번호 숨기기"
msgid "High"
msgstr "높음"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2460,10 +2512,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 "실행 주기"
@ -2472,10 +2520,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 "완료 확인 방법"
@ -2524,6 +2568,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 "기본 설정 사용"
@ -2747,6 +2795,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
@ -2757,10 +2809,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"
@ -2925,10 +2985,6 @@ msgstr "분"
msgid "minutes"
msgstr "분"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
@ -2986,6 +3042,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 "공용 기억으로 옮깁니다."
@ -2997,7 +3065,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
@ -3176,6 +3243,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 ""
@ -3233,6 +3304,7 @@ msgstr "연결되지 않음"
msgid "Not in the plugin catalog"
msgstr "플러그인 카탈로그에 없음"
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "나중에"
@ -3245,14 +3317,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 "알림"
@ -3266,6 +3335,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 인증이 취소되었습니다."
@ -3299,6 +3372,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 ""
@ -3307,6 +3384,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"
@ -3559,6 +3640,10 @@ msgstr "서비스"
msgid "Pull request"
msgstr "풀 리퀘스트"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -3570,14 +3655,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 ""
@ -3798,6 +3891,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 ""
@ -3938,6 +4035,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4040,15 +4138,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 "보내기"
@ -4213,7 +4308,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 "건너뛰기"
@ -4298,6 +4395,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 ""
@ -4391,10 +4492,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "보조 에이전트"
@ -4480,10 +4577,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 "시험 실행"
@ -4574,10 +4667,6 @@ msgstr "실행 시각"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "역할"
@ -4640,6 +4729,10 @@ msgstr ""
msgid "Try again"
msgstr "다시 시도"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "답변을 입력하세요"
@ -4841,6 +4934,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의 기억은 어떻게 할까요?"
@ -4849,6 +4950,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 ""
@ -4889,6 +4994,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 ""
@ -4901,6 +5010,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 "실행 시간"
@ -4926,10 +5043,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 ""
@ -4945,7 +5058,7 @@ msgstr ""
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Working…"
msgstr ""
msgstr "처리 중…"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
@ -4987,8 +5100,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 "이름"

View File

@ -1,17 +1,18 @@
#
msgid ""
msgstr ""
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: pt-BR\n"
"Project-Id-Version: rakazo-web\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: pt-BR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Generator: @lingui/cli\n"
#: src/pages/Shell.tsx
msgid " (unread)"
@ -21,6 +22,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 +37,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)"
@ -282,10 +293,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 ""
@ -351,6 +358,7 @@ msgstr "Modelo ativo"
msgid "Activity"
msgstr "Atividade"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -388,6 +396,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"
@ -429,6 +441,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"
@ -496,6 +512,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"
@ -703,10 +739,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 ""
@ -743,10 +775,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"
@ -756,6 +784,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..."
@ -824,6 +856,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 ""
@ -837,6 +873,7 @@ msgstr "Não é possível acessar o servidor."
#: src/components/ComputerUpdateProgress.tsx
#: src/components/media/MediaCard.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -863,7 +900,7 @@ msgstr "cancelado"
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Cancelled"
msgstr ""
msgstr "Cancelado"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/AskCard.tsx
@ -872,8 +909,8 @@ msgstr ""
msgid "Cancelled"
msgstr "Cancelado"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -930,6 +967,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 ""
@ -1139,7 +1180,6 @@ msgid "Command"
msgstr "Comando"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1300,8 +1340,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"
@ -1519,6 +1559,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 ""
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1826,6 +1872,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"
@ -1834,10 +1884,6 @@ msgstr "dias"
msgid "Decline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "Padrão"
@ -1938,18 +1984,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2073,6 +2111,10 @@ msgstr "ex.: Hivenet Agent, Presales, bot de planilhas"
msgid "Earlier message"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Editar primeiro"
@ -2122,6 +2164,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "Digite este código em <0>{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"
@ -2150,6 +2200,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"
@ -2191,6 +2245,10 @@ msgstr ""
msgid "Extra high"
msgstr "Extra alta"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "falhou"
@ -2265,10 +2323,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2333,6 +2387,7 @@ msgstr ""
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2369,6 +2424,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2443,10 +2499,6 @@ msgstr ""
msgid "High"
msgstr "Alto"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2460,10 +2512,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"
@ -2472,10 +2520,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"
@ -2524,6 +2568,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)"
@ -2747,6 +2795,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
@ -2757,10 +2809,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"
@ -2925,10 +2985,6 @@ msgstr "minuto"
msgid "minutes"
msgstr "minutos"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
@ -2986,6 +3042,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."
@ -2997,7 +3065,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
@ -3176,6 +3243,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 ""
@ -3233,6 +3304,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"
@ -3245,14 +3317,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"
@ -3266,6 +3335,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."
@ -3299,6 +3372,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 ""
@ -3307,6 +3384,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"
@ -3559,6 +3640,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/components/media/MediaCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -3570,14 +3655,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 ""
@ -3798,6 +3891,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 ""
@ -3938,6 +4035,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4040,15 +4138,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"
@ -4213,7 +4308,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"
@ -4298,6 +4395,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 ""
@ -4391,10 +4492,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "subagente"
@ -4480,10 +4577,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"
@ -4574,10 +4667,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"
@ -4640,6 +4729,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"
@ -4841,6 +4934,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?"
@ -4849,6 +4950,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 ""
@ -4889,6 +4994,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 ""
@ -4901,6 +5010,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"
@ -4926,10 +5043,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 ""
@ -4945,7 +5058,7 @@ msgstr ""
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Working…"
msgstr ""
msgstr "Funcionando"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
@ -4987,8 +5100,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"

View File

@ -1,17 +1,18 @@
#
msgid ""
msgstr ""
"POT-Creation-Date: 2026-09-07 14:00+0700\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: ru\n"
"Project-Id-Version: rakazo-web\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-07 14:00+0700\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"X-Generator: @lingui/cli\n"
#: src/pages/Shell.tsx
msgid " (unread)"
@ -21,6 +22,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 +37,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)"
@ -282,10 +293,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 "О группах"
@ -351,6 +358,7 @@ msgstr "Активная модель"
msgid "Activity"
msgstr "Активность"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -388,6 +396,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"
@ -429,6 +441,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 "Добавить в задачу"
@ -496,6 +512,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"
@ -703,10 +739,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 "Автоматические отправители"
@ -743,10 +775,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"
@ -756,6 +784,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 "Загрузка рабочего стола…"
@ -824,6 +856,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 ""
@ -837,6 +873,7 @@ msgstr "Не удаётся связаться с сервером."
#: src/components/ComputerUpdateProgress.tsx
#: src/components/media/MediaCard.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -863,7 +900,7 @@ msgstr "отменен"
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Cancelled"
msgstr ""
msgstr "Отменено"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/AskCard.tsx
@ -872,8 +909,8 @@ msgstr ""
msgid "Cancelled"
msgstr "Отменено"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -930,6 +967,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 ""
@ -1139,7 +1180,6 @@ msgid "Command"
msgstr "Команда"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1300,8 +1340,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 "Продолжить"
@ -1519,6 +1559,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 "Не удалось открыть локальные настройки. Запустите локальный сервер и создайте учетную запись владельца, затем повторите попытку."
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
msgstr "Не удалось загрузить настройки обмена сообщениями."
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1826,6 +1872,10 @@ msgstr ""
msgid "day"
msgstr "день"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "дни"
@ -1834,10 +1884,6 @@ msgstr "дни"
msgid "Decline"
msgstr "Отклонить"
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr ""
@ -1938,18 +1984,10 @@ msgstr "Настольное приложение"
msgid "Desktop update"
msgstr "Обновление настольного приложения"
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr "Прямой MCP"
@ -2073,6 +2111,10 @@ msgstr "напр. Hivenet Agent, Presales, бот табелей"
msgid "Earlier message"
msgstr "Предыдущее сообщение"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Сначала отредактируйте"
@ -2122,6 +2164,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "Введите этот код по адресу <0>{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"
@ -2150,6 +2200,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"
@ -2191,6 +2245,10 @@ msgstr "Внешний диалог"
msgid "Extra high"
msgstr "Очень высокий"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "неуспешный"
@ -2265,10 +2323,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr "Пометить неожиданные действия"
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2333,6 +2387,7 @@ msgstr "Git-событие"
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2369,6 +2424,7 @@ msgstr "Обновления группы"
msgid "Groups"
msgstr "Группы"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2443,10 +2499,6 @@ msgstr "Скрыть пароль"
msgid "High"
msgstr "Высокий"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2460,10 +2512,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 "Как часто"
@ -2472,10 +2520,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 "Как проверить"
@ -2524,6 +2568,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 "Наследовать значение по умолчанию"
@ -2747,6 +2795,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
@ -2757,10 +2809,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"
@ -2925,10 +2985,6 @@ msgstr "минута"
msgid "minutes"
msgstr "минуты"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
@ -2986,6 +3042,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 "Переместите их в свою общую память."
@ -2997,7 +3065,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
@ -3023,7 +3090,7 @@ msgstr ""
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Needs you"
msgstr ""
msgstr "Требуется ваше участие"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/MainInbox.tsx
@ -3176,6 +3243,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 ""
@ -3233,6 +3304,7 @@ msgstr "Не подключено"
msgid "Not in the plugin catalog"
msgstr "Нет в каталоге плагинов"
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "Не сейчас"
@ -3245,14 +3317,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 "Уведомления"
@ -3266,6 +3335,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 отменена."
@ -3299,6 +3372,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 ""
@ -3307,6 +3384,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"
@ -3559,6 +3640,10 @@ msgstr "Провайдеры"
msgid "Pull request"
msgstr "Запрос на слияние"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -3570,14 +3655,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 ""
@ -3798,6 +3891,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 ""
@ -3938,6 +4035,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4040,15 +4138,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 "Отправить"
@ -4213,7 +4308,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 "Пропустить"
@ -4298,6 +4395,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 ""
@ -4391,10 +4492,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "субагент"
@ -4480,10 +4577,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 "Тест"
@ -4574,10 +4667,6 @@ msgstr "Время суток"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "Заголовок"
@ -4640,6 +4729,10 @@ msgstr ""
msgid "Try again"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "Введите ответ"
@ -4841,6 +4934,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 "Что делать с его памятью?"
@ -4849,6 +4950,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 ""
@ -4889,6 +4994,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 ""
@ -4901,6 +5010,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 "Когда запускать"
@ -4926,10 +5043,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 ""
@ -4945,7 +5058,7 @@ msgstr ""
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Working…"
msgstr ""
msgstr "Выполняется…"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
@ -4987,8 +5100,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 "Ваше имя"

View File

@ -1,17 +1,18 @@
#
msgid ""
msgstr ""
"POT-Creation-Date: 2026-08-26 21:16+0300\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: tr\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-26 21:16+0300\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: tr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"X-Generator: @lingui/cli\n"
#: src/pages/Shell.tsx
msgid " (unread)"
@ -21,6 +22,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 +37,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)"
@ -282,10 +293,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 ""
@ -351,6 +358,7 @@ msgstr "Etkin model"
msgid "Activity"
msgstr "Etkinlik"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -388,6 +396,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"
@ -429,6 +441,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"
@ -496,6 +512,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"
@ -703,10 +739,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 ""
@ -743,10 +775,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"
@ -756,6 +784,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…"
@ -824,6 +856,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 ""
@ -837,6 +873,7 @@ msgstr "Sunucuya ulaşılamıyor."
#: src/components/ComputerUpdateProgress.tsx
#: src/components/media/MediaCard.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -863,7 +900,7 @@ msgstr "iptal edildi"
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Cancelled"
msgstr ""
msgstr "İptal edildi"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/AskCard.tsx
@ -872,8 +909,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
@ -930,6 +967,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 ""
@ -1139,7 +1180,6 @@ msgid "Command"
msgstr "Komut"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1300,8 +1340,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"
@ -1519,6 +1559,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 ""
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1826,6 +1872,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"
@ -1834,10 +1884,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"
@ -1938,18 +1984,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2073,6 +2111,10 @@ msgstr "örn. Hivenet Agent, Presales, puantaj botu"
msgid "Earlier message"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Önce düzenle"
@ -2122,6 +2164,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "Bu kodu <0>{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"
@ -2150,6 +2200,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"
@ -2191,6 +2245,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"
@ -2265,10 +2323,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2333,6 +2387,7 @@ msgstr ""
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2369,6 +2424,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2443,10 +2499,6 @@ msgstr ""
msgid "High"
msgstr "Yüksek"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2460,10 +2512,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"
@ -2472,10 +2520,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"
@ -2524,6 +2568,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"
@ -2747,6 +2795,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
@ -2757,10 +2809,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"
@ -2925,10 +2985,6 @@ msgstr "dakika"
msgid "minutes"
msgstr "dakika"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
@ -2986,6 +3042,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şı."
@ -2997,7 +3065,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
@ -3176,6 +3243,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 ""
@ -3233,6 +3304,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"
@ -3245,14 +3317,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"
@ -3266,6 +3335,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."
@ -3299,6 +3372,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 ""
@ -3307,6 +3384,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"
@ -3559,6 +3640,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/components/media/MediaCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -3570,14 +3655,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 ""
@ -3798,6 +3891,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 ""
@ -3938,6 +4035,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4040,15 +4138,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"
@ -4213,7 +4308,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"
@ -4298,6 +4395,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 ""
@ -4391,10 +4492,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"
@ -4480,10 +4577,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"
@ -4574,10 +4667,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"
@ -4640,6 +4729,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"
@ -4841,6 +4934,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?"
@ -4849,6 +4950,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 ""
@ -4889,6 +4994,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 ""
@ -4901,6 +5010,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"
@ -4926,10 +5043,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 ""
@ -4945,7 +5058,7 @@ msgstr ""
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Working…"
msgstr ""
msgstr "Çalışıyor…"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
@ -4987,8 +5100,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"

View File

@ -1,17 +1,18 @@
#
msgid ""
msgstr ""
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: zh-CN\n"
"Project-Id-Version: rakazo-web\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-26 00:00+0000\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: zh-CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"X-Generator: @lingui/cli\n"
#: src/pages/Shell.tsx
msgid " (unread)"
@ -21,6 +22,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 +37,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)"
@ -282,10 +293,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 ""
@ -351,6 +358,7 @@ msgstr "当前模型"
msgid "Activity"
msgstr "动态"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -388,6 +396,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"
@ -429,6 +441,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 "加入例行任务"
@ -496,6 +512,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"
@ -703,10 +739,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 ""
@ -743,10 +775,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"
@ -756,6 +784,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 "正在启动实时桌面…"
@ -824,6 +856,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 ""
@ -837,6 +873,7 @@ msgstr "无法连接服务器。"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/media/MediaCard.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -863,7 +900,7 @@ msgstr "已取消"
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Cancelled"
msgstr ""
msgstr "已取消"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/AskCard.tsx
@ -872,8 +909,8 @@ msgstr ""
msgid "Cancelled"
msgstr "已取消"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -930,6 +967,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 ""
@ -1139,7 +1180,6 @@ msgid "Command"
msgstr "命令"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1300,8 +1340,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 "继续"
@ -1519,6 +1559,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 ""
@ -1676,6 +1720,7 @@ msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1685,6 +1730,7 @@ msgid "Couldn't load messaging settings"
msgstr "无法加载消息设置"
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1826,6 +1872,10 @@ msgstr ""
msgid "day"
msgstr "天"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "天"
@ -1834,10 +1884,6 @@ msgstr "天"
msgid "Decline"
msgstr "拒绝"
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "默认"
@ -1938,18 +1984,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2073,6 +2111,10 @@ msgstr "例如 Hivenet Agent、Presales、工时表机器人"
msgid "Earlier message"
msgstr "更早的消息"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "先编辑"
@ -2122,6 +2164,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "在 <0>{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"
@ -2150,6 +2200,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"
@ -2191,6 +2245,10 @@ msgstr ""
msgid "Extra high"
msgstr "极高"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "失败"
@ -2265,10 +2323,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr "标记异常操作"
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2333,6 +2387,7 @@ msgstr "Git 事件"
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2369,6 +2424,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2443,10 +2499,6 @@ msgstr "隐藏密码"
msgid "High"
msgstr "高"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2460,10 +2512,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 "频率"
@ -2472,10 +2520,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 "如何检查"
@ -2524,6 +2568,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 "沿用默认"
@ -2747,6 +2795,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
@ -2757,10 +2809,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"
@ -2925,10 +2985,6 @@ msgstr "分钟"
msgid "minutes"
msgstr "分钟"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/components/MediaSettings.tsx
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
@ -2986,6 +3042,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 "把它们移到共享记忆。"
@ -2997,7 +3065,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
@ -3176,6 +3243,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 ""
@ -3233,6 +3304,7 @@ msgstr "未连接"
msgid "Not in the plugin catalog"
msgstr "不在插件目录中"
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "暂不"
@ -3245,14 +3317,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 "通知"
@ -3266,6 +3335,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 授权已取消。"
@ -3299,6 +3372,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 ""
@ -3307,6 +3384,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"
@ -3559,6 +3640,10 @@ msgstr "提供方"
msgid "Pull request"
msgstr "拉取请求"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
@ -3570,14 +3655,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 ""
@ -3798,6 +3891,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 ""
@ -3938,6 +4035,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/MediaSettings.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4040,15 +4138,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 "发送"
@ -4213,7 +4308,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 "跳过"
@ -4298,6 +4395,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 ""
@ -4391,10 +4492,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "子 Agent"
@ -4480,10 +4577,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 "测试"
@ -4574,10 +4667,6 @@ msgstr "时间"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "标题"
@ -4640,6 +4729,10 @@ msgstr ""
msgid "Try again"
msgstr "重试"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "输入你的回答"
@ -4841,6 +4934,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 "它的记忆怎么处理?"
@ -4849,6 +4950,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 ""
@ -4889,6 +4994,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 ""
@ -4901,6 +5010,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 "何时运行"
@ -4926,10 +5043,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 ""
@ -4945,7 +5058,7 @@ msgstr ""
#. js-lingui-explicit-id
#: src/lib/agent-activity.ts
msgid "Working…"
msgstr ""
msgstr "处理中…"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/components/media/MediaCard.tsx
@ -4987,8 +5100,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 "你的名字"

View File

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

View File

@ -1,22 +1,23 @@
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 { localizedPreview } from "../lib/onboarding-copy";
import { rpc } from "../lib/rpc";
const MISSION_CONTROL_URL = "http://127.0.0.1:3088/";
type HeuteBriefProps = {
bots: Bot[];
groups: Group[];
@ -26,6 +27,8 @@ type HeuteBriefProps = {
onStartChat: () => void;
};
const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" };
export function HeuteBrief({
bots,
groups,
@ -37,6 +40,9 @@ export function HeuteBrief({
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
const [loading, setLoading] = useState(true);
const [coach, setCoach] = useState<CoachToday | null>(null);
const [coachSettled, setCoachSettled] = useState(false);
const latestCoachRequest = useRef(0);
useEffect(() => {
let cancelled = false;
@ -68,6 +74,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 +131,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 (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
<div className="border-b border-border px-4 py-5 md:px-8 lg:px-10">
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">{dateLabel}</h1>
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
<StatusLine
actionCount={brief.actionCount}
loading={loading}
doingCount={brief.doing.length}
/>
</p>
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-5 md:px-8 lg:px-10">
<div className="min-w-0">
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">
{dateLabel}
</h1>
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
<StatusLine
actionCount={brief.actionCount}
loading={loading}
doingCount={brief.doing.length}
/>
</p>
</div>
<CoachRhythmControl />
</div>
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
{loading && !hasContent ? (
{(loading || !coachSettled) && !hasContent ? (
<p className="text-ui-body text-muted-foreground">
<Trans>Loading…</Trans>
</p>
) : !hasContent ? (
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
) : (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8 pb-4">
{coach ? <HeuteCoach today={coach} onHabitDone={setHabitDone} /> : null}
{brief.needsYou.length > 0 ? (
<section data-testid="heute-needs-you">
<h2 className="rk-shell-section-label mb-2 px-0.5">
@ -172,19 +236,6 @@ export function HeuteBrief({
) : null}
</div>
)}
<footer className="mx-auto mt-10 flex w-full max-w-2xl justify-center pb-4">
<a
href={MISSION_CONTROL_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground transition-colors hover:text-foreground"
data-testid="heute-mission-control-link"
>
<Trans>Mission Control</Trans>
<ExternalLink strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
</a>
</footer>
</div>
</div>
);
@ -286,7 +337,7 @@ function NeedsYouRow({
const { chatKind, chat } = item;
const title = chat.name;
const preview = cleanBriefPreview(chat.preview) || t`Unread reply`;
const preview = cleanBriefPreview(localizedPreview(chat)) || t`Unread reply`;
const label =
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
return (
@ -399,12 +450,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",
});
}

View File

@ -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";
@ -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,
@ -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: <Trans>Nothing specific</Trans> },
];
const WORK_STYLE_OPTIONS = [
{ key: "structured", label: <Trans>Structured & planned</Trans> },
{ key: "flexible", label: <Trans>Flexible & spontaneous</Trans> },
{ key: "deep-dive", label: <Trans>Deep-dive focused</Trans> },
{ key: "quick", label: <Trans>Quick & pragmatic</Trans> },
];
const COMMUNICATION_OPTIONS = [
{ key: "direct", label: <Trans>Direct & concise</Trans> },
{ key: "detailed", label: <Trans>Detailed & explanatory</Trans> },
{ key: "balanced", label: <Trans>Balanced</Trans> },
{ key: "casual", label: <Trans>Casual & friendly</Trans> },
];
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,8 @@ export function OnboardingPage() {
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
const [profilePersisted, setProfilePersisted] = useState(false);
const [crewReady, setCrewReady] = useState(false);
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
const [scanBotId, setScanBotId] = useState<string | null>(null);
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
const [currentLocale, setCurrentLocale] = useState<string>(() => {
try {
return localStorage.getItem("rakazo.uiLocale") ?? "en";
@ -140,15 +90,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();
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) {
@ -178,17 +119,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 +163,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 +184,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 +225,7 @@ export function OnboardingPage() {
<OnboardingDiscoveryShell
items={discoveryItems}
compact={
step === "emoji" ||
step === "preferences" ||
step === "language" ||
step === "voice" ||
step === "integrations"
step === "emoji" || step === "language" || step === "voice" || step === "integrations"
}
>
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
@ -313,140 +234,10 @@ export function OnboardingPage() {
<OnboardingOoDesigner
config={emojiConfig}
onChange={setEmojiConfig}
onContinue={() => setStep("preferences")}
onContinue={() => setStep("language")}
/>
) : null}
{step === "preferences" ? (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Tell us about yourself</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>A few quick preferences so your Oo can be better tailored to you.</Trans>
</p>
<div className="mt-6">
<label
htmlFor={`${fieldId}-name`}
className="block text-sm font-medium text-foreground"
>
<Trans>Name</Trans>
</label>
<Input
id={`${fieldId}-name`}
value={prefs.name}
onChange={(e) => setPrefs({ ...prefs, name: e.target.value })}
placeholder={t`Your name`}
className="mt-2"
/>
</div>
<div className="mt-6">
<label
htmlFor={`${fieldId}-timezone`}
className="block text-sm font-medium text-foreground"
>
<Trans>Timezone</Trans>
</label>
<Input
id={`${fieldId}-timezone`}
value={prefs.timezone}
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
placeholder={t`Auto-detected or enter manually`}
className="mt-2"
/>
</div>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Hobbies & interests</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>Select all that apply</Trans>
</p>
<div className="mt-3 flex flex-wrap gap-2">
{HOBBY_OPTIONS.map((hobby) => (
<button
key={hobby.key}
type="button"
onClick={() => toggleHobby(hobby.key)}
aria-pressed={prefs.hobbies.includes(hobby.key)}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
prefs.hobbies.includes(hobby.key)
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{hobby.label}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Work style</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How do you like to work?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{WORK_STYLE_OPTIONS.map((ws) => (
<button
key={ws.key}
type="button"
onClick={() => setPrefs({ ...prefs, workStyle: ws.key })}
aria-pressed={prefs.workStyle === ws.key}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
prefs.workStyle === ws.key
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{ws.label}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Communication style</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How should your Oo talk to you?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{COMMUNICATION_OPTIONS.map((comm) => (
<button
key={comm.key}
type="button"
onClick={() => setPrefs({ ...prefs, communication: comm.key })}
aria-pressed={prefs.communication === comm.key}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
prefs.communication === comm.key
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{comm.label}
</button>
))}
</div>
</fieldset>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("language")}>
<Trans>Continue</Trans>
</Button>
</div>
</div>
) : null}
{step === "language" ? (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
@ -477,7 +268,7 @@ export function OnboardingPage() {
))}
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("voice")}>
@ -513,12 +304,7 @@ export function OnboardingPage() {
</div>
) : null}
{step === "scan" ? (
<OnboardingScanProgress
onComplete={() => setStep("bot")}
botId={scanBotId ?? undefined}
/>
) : null}
{step === "scan" ? <OnboardingScanStep onDone={() => setStep("bot")} /> : null}
{step === "bot" && error ? (
<div className="text-center">

View File

@ -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({
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14.5px] font-medium text-foreground" dir="auto">
{routine.name}
{routineName(routine)}
</span>
<span className="block truncate text-ui-body-sm text-muted-foreground/80">
{routineTriggerSummary(routine)}

View File

@ -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,
Clapperboard,
CloudDownload,
@ -18,6 +19,7 @@ import {
import { type ComponentType, useEffect, useRef, useState } from "react";
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
import { MediaSettings } from "../components/MediaSettings";
import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel";
import { UserProfileSettings } from "../components/UserProfileSettings";
import {
ComputerSettingsPanel,
@ -39,6 +41,7 @@ export type SettingsSection =
| "profile"
| "voice"
| "media"
| "notifications"
| "usage"
| "computer"
| "updates"
@ -112,6 +115,7 @@ export function SettingsOverlay({
{ id: "profile", label: t`About you`, icon: UserRound },
{ id: "voice", label: t`Voice`, icon: Volume2 },
...(isDeploymentOwner ? [{ id: "media" as const, label: t`Media`, icon: Clapperboard }] : []),
{ 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 },
@ -251,6 +255,7 @@ export function SettingsOverlay({
) : null}
{section === "profile" ? <UserProfileSettings /> : null}
{section === "media" && isDeploymentOwner ? <MediaSettings /> : null}
{section === "notifications" ? <NotificationSettingsPanel /> : null}
{section === "usage" ? (
<UsageSettingsPanel usage={usage} panelRef={usageRef} />
) : null}

View File

@ -219,6 +219,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 { localizedPreview, 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";
@ -232,6 +233,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";
@ -258,6 +260,7 @@ import {
transcriptMovedDown,
} from "../lib/transcript-scroll";
import { speaker } from "../lib/tts";
import { disableWebPush } from "../lib/web-push";
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
import { ActivityList } from "./ActivityList";
import type { ContextMenuPosition } from "./BotContextMenu";
@ -3655,7 +3658,7 @@ export function ShellPage() {
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
subtitle={rosterActivitySubtitle(
mainConversationEntry.bot.status,
cleanRosterPreview(mainConversationEntry.group.preview),
cleanRosterPreview(localizedPreview(mainConversationEntry.group)),
)}
active={inGroup && groupId === mainConversationEntry.groupId}
onOpen={openMainConversation}
@ -4169,7 +4172,7 @@ export function ShellPage() {
item.chat.unread,
)}`}
>
{cleanRosterPreview(item.chat.preview)}
{cleanRosterPreview(localizedPreview(item.chat))}
</div>
</div>
</button>
@ -4317,10 +4320,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("/");
})
}
>
<LogOut className="text-muted-foreground" strokeWidth={1.75} />
@ -4665,7 +4672,12 @@ export function ShellPage() {
</>
)}
{activeGroup?.kind === "main" && !showProjectTimeline ? (
<MainInbox bots={bots} onOpenBot={openBot} onOpenGroup={openGroup} />
<MainInbox
bots={bots}
onOpenBot={openBot}
onOpenGroup={openGroup}
onOpenInSpace={openSpaceChat}
/>
) : null}
{recordingSkill ? (
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
@ -5089,7 +5101,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,
@ -5843,7 +5855,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 =

View File

@ -1,5 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { MessageBlock } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { abortableDelay } from "@rakazo/core";
import {
Button,
@ -8,15 +9,28 @@ import {
DialogClose,
DialogContent,
DialogTitle,
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@rakazo/ui-web";
import { Download, X } from "lucide-react";
import { ArrowUp, Check, Download, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { BuiCard, SuccessPop } from "../../components/ai/primitives";
import { artifactUrl } from "../../lib/artifact-url";
import { chartViewport } from "../../lib/chart-viewport";
import { connectMcpOauth } from "../../lib/mcp-connect";
import { chosenOption, localizedChoice } from "../../lib/onboarding-copy";
import { rpc } from "../../lib/rpc";
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
type ChoiceOption = Extract<MessageBlock, { kind: "choice" }>["options"][number];
/**
* A question card. Lettered options are the answers; an option without a letter ("Skip",
* "Not now") is the quiet way out, and a card without one can be dismissed instead. Onboarding
* cards may also take an answer in the user's own words.
*/
export function ChoiceCard({
botId,
block,
@ -29,14 +43,33 @@ export function ChoiceCard({
const { t } = useLingui();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [text, setText] = useState("");
const [locallyDismissed, setLocallyDismissed] = useState(false);
const dismissed = locallyDismissed || block.answerId === "_dismissed";
const answered = Boolean(block.answerId);
// 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) {
async function choose(optionId: string, words?: string) {
setPending(true);
setError(null);
try {
await rpc.onboarding.choose({ botId, optionId });
// Tapping "Turn on" is the gesture the browser needs before it asks for permission.
const answer =
block.step === "notifications" && optionId === "on" && (await enableWebPush()) !== "enabled"
? "later"
: optionId;
await rpc.onboarding.choose({
botId,
step: block.step,
optionId: answer,
...(words ? { text: words } : {}),
// The server offers the notifications card only to a browser that can still turn push on.
canEnablePush: webPushSupported() && !(await webPushEnabled()),
});
await onBotChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t`Could not save this choice`);
@ -59,10 +92,16 @@ export function ChoiceCard({
if (dismissed) return null;
const chosen = chosenOption(card);
return (
<div className="flex justify-start">
<div className="apple-glass-card relative w-[440px] max-w-full rounded-[24px] px-5 py-4.5 transition-all">
{!block.answerId ? (
<BuiCard
data-testid="choice-card"
data-step={block.step}
className="relative w-[440px] max-w-full px-5 py-4"
>
{!answered && waysOut.length === 0 ? (
<Button
variant="ghost"
size="icon-sm"
@ -75,54 +114,116 @@ export function ChoiceCard({
</Button>
) : null}
<div className="pe-8 text-ui-body font-medium text-foreground tracking-tight">
{block.question}
{card.question}
</div>
{block.subtitle ? (
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
) : null}
<div className="mt-3.5 space-y-2.5">
{block.options
.filter((option) => !block.answerId || option.id === block.answerId)
.map((option) => (
<button
key={option.id}
type="button"
data-testid={`focus-choice-${option.id}`}
aria-label={option.label}
disabled={Boolean(block.answerId) || pending}
onClick={() => void choose(option.id)}
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-white/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
block.answerId === option.id
? "bg-blue-500/20 border-blue-500/40 shadow-sm"
: "bg-white/[0.04] hover:bg-white/[0.08] hover:border-white/10"
}`}
{answered ? (
chosen ? (
<div
data-testid="choice-answer"
className="mt-3 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3 text-ui-body font-medium text-foreground"
>
{chosen.letter ? <ChoiceLetter letter={chosen.letter} selected /> : null}
<span className="flex-1 leading-snug" dir="auto">
{chosen.label}
</span>
<Check aria-hidden strokeWidth={2.2} className="mt-0.5 size-icon-md text-primary" />
</div>
) : null
) : (
<>
{answers.length > 0 ? (
<div className="mt-3 space-y-2">
{answers.map((option) => (
<button
key={option.id}
type="button"
data-testid={testId(option)}
aria-label={option.label}
disabled={pending}
onClick={() => void choose(option.id)}
className="tactile-press flex w-full items-start gap-3 rounded-xl border border-border bg-background p-3 text-start text-ui-body leading-snug text-foreground transition-colors hover:bg-muted disabled:opacity-60"
>
<ChoiceLetter letter={option.letter} />
<span className="flex-1">{option.label}</span>
</button>
))}
</div>
) : null}
{card.textInput ? (
<form
className="mt-3"
onSubmit={(event) => {
event.preventDefault();
const words = text.trim();
if (words) void choose(CHOICE_TEXT_ANSWER_ID, words);
}}
>
<span
className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-[8px] text-ui-body-sm font-semibold ${
block.answerId === option.id
? "bg-blue-500 text-white"
: "bg-white/10 text-foreground/80"
}`}
>
{option.letter}
</span>
<span
className={`flex-1 text-ui-body leading-snug ${block.answerId ? "text-foreground font-medium" : "text-foreground"}`}
>
{option.label}
</span>
{block.answerId === option.id ? (
<span className="mt-0.5 text-blue-400 font-bold">✓</span>
) : null}
</button>
))}
</div>
<InputGroup className="h-control-lg rounded-xl bg-background">
<InputGroupInput
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={card.textInput.placeholder}
aria-label={card.question}
maxLength={200}
disabled={pending}
dir="auto"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="submit"
size="icon-sm"
variant="default"
aria-label={t`Send`}
disabled={pending || !text.trim()}
className="rounded-lg"
>
<ArrowUp aria-hidden />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
) : null}
{waysOut.length > 0 ? (
<div className="mt-2 flex justify-end gap-1">
{waysOut.map((option) => (
<Button
key={option.id}
variant="ghost"
size="sm"
data-testid={testId(option)}
disabled={pending}
onClick={() => void choose(option.id)}
className="text-muted-foreground hover:text-foreground"
>
{option.label}
</Button>
))}
</div>
) : null}
</>
)}
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
</div>
</BuiCard>
</div>
);
}
function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) {
return (
<span
aria-hidden
className={`mt-0.5 grid size-6 shrink-0 place-items-center rounded-lg text-ui-body-sm font-semibold ${
selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/80"
}`}
>
{letter}
</span>
);
}
export function AppConnectCard({
botId,
block,

View File

@ -2,7 +2,7 @@
export function rosterRowClass(selected: boolean): string {
return `flex w-full items-center gap-3 rounded-2xl border px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
selected
? "border-sidebar-border bg-sidebar-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"
? "border-sidebar-border bg-sidebar-accent shadow-xs dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"
: "border-transparent hover:bg-sidebar-accent/50"
}`;
}

View File

@ -21,7 +21,7 @@ import {
createWebProvider,
databaseCapacityBackoffMs,
EncryptedSecretStore,
ExpoPushProvider,
ExpoPushSender,
GraphileJobPublisher,
GraphileJobWorkerHost,
InMemoryJobQueue,
@ -39,6 +39,7 @@ import {
PiAgentRuntime,
PipedreamConnector,
PostgresRealtimeFanout,
PushNotificationProvider,
pipedreamConfigFromEnv,
reconcileCloudAgents,
reconcileComputerUpdates,
@ -48,6 +49,9 @@ import {
resolveSandboxProvider,
ScriptedAgentRuntime,
SpaceMemoryProviderResolver,
VapidKeys,
vapidEnvFromProcess,
WebPushSender,
} from "@rakazo/adapters";
import { resolveEncryptionKey, resolveSupervisorToken } from "@rakazo/core";
import {
@ -205,7 +209,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,

View File

@ -127,6 +127,19 @@ Called from `Onboarding.tsx` after first bot ensure.
Seeded in user `MEMORY.md` (`user-relationship-contract.ts`). Persona copy and prompts follow. Details: `docs/OO-RELATIONSHIP.md`.
## Memory and the user model
| Store | Holds | Written by |
|-------|-------|------------|
| `UserProfile` + `UserFact` (Settings → About you) | The user model: who the user is, across spaces; a fact can be limited to chosen spaces | `update_user_profile`, onboarding, chat consolidation, the user |
| User-scoped `MEMORY.md` (Settings → Memory) | Space notes shared by the bots of one space | The user, onboarding harvest |
| Bot-scoped `MEMORY.md` (bot → Knowledge) | One bot's notes in one space | `remember` appends dated notes; `mode: "replace"` rewrites |
Space notes are not migrated into facts: facts are short, consented statements about the user,
space notes are free-form context for one space. A memory document holds at most 16 KB, so a
bot's notes and the space notes both reach the prompt; when it is full, `remember` asks the bot
to condense it.
## Capability vision — ambient & video (future roadmap)
**This PR does not build surveillance.** Current Gesamterfahrung stays **Stage + sound + calm onboarding** (`VOICE.md`). The following is Ingo's long-term direction — optional layers only.

View File

@ -1,16 +1,16 @@
# Evo auto-deploy (evOo-local)
Idempotent deploy helper for the **source checkout** on the Evo host (`~/Projects/evOo-local`, user `cynar`). Pulls `main` (or a pinned SHA), runs migrations, restarts the existing stack, health-checks API `:3100` and web `:5173`, and rolls back on failure.
Idempotent deploy helper for the **source checkout** on the Evo host, run as `<deploy-user>`. Pulls `main` (or a pinned SHA), installs dependencies, builds the web app, runs migrations, restarts the stack, health-checks API `:3100` and web `:5173`, and rolls back on failure.
This is separate from production Docker deploys under `/srv/rakazo` (see `docs/self-host.md` and `.github/workflows/ci.yml` `deploy-production`).
This is separate from production Docker deploys (see `docs/self-host.md` and `.github/workflows/ci.yml` `deploy-production`).
## Prerequisites on Evo
1. Git checkout at `~/Projects/evOo-local` (or set `EVO_DEPLOY_DIR`).
1. Git checkout at `$HOME/Projects/evOo-local` (or set `EVO_DEPLOY_DIR`).
2. `.env` with a working `DATABASE_URL` (Postgres reachable for `pnpm db:migrate`).
3. A restart path the script can use (pick one):
- **systemd (recommended for unattended deploy):** user unit, e.g. `evoo-local.service`, that starts `pnpm dev` or your stack wrapper. Set repo variable `EVO_SYSTEMD_UNIT`.
- **tmux:** session `evoo` running `pnpm dev` (matches `scripts/start-tmux.sh` on Mac; on Evo set `EVO_TMUX_SESSION` if different).
- **systemd (recommended for unattended deploy):** the user unit from [Stack unit](#stack-unit). Set repo variable `EVO_SYSTEMD_UNIT`.
- **tmux:** session `evoo` running `pnpm dev` (set `EVO_TMUX_SESSION` if different).
- **compose:** `infra/compose/docker-compose.yml` with `api` / `web` / `worker` (dev stack).
- **script:** executable hook at `EVO_RESTART_SCRIPT`.
4. Self-hosted GitHub Actions runner on Evo with labels `[self-hosted, evoo-ci, Linux]` (already used by CI).
@ -20,23 +20,88 @@ No SSH keys or deploy secrets are stored in this repo. The workflow runs **on**
## Enable the workflow
1. Merge this PR (or copy `ops/evo-deploy/` and `.github/workflows/evo-deploy.yml` to `main`).
2. In GitHub → **Settings → Secrets and variables → Actions → Variables** for `cynarAI/evOo-local`:
2. In GitHub → **Settings → Secrets and variables → Actions → Variables** for this repository:
- `EVO_DEPLOY_ENABLED` = `true`
- Optional: `EVO_DEPLOY_DIR` (default `~/Projects/evOo-local` on the runner user)
- Optional: `EVO_DEPLOY_DIR` (default `$HOME/Projects/evOo-local` of the runner user)
- Optional: `EVO_RESTART_MODE` (`auto`, `systemd`, `tmux`, `compose`, `script`, `none`)
- Optional: `EVO_SYSTEMD_UNIT`, `EVO_TMUX_SESSION`, health URLs, timeouts
3. Confirm the runner user can read/write `EVO_DEPLOY_DIR` and restart services (`systemctl --user` or tmux).
Until `EVO_DEPLOY_ENABLED` is `true`, pushes to `main` do not deploy (workflow job is skipped).
## Stack unit
`start-stack.sh` starts api, worker and sandbox supervisor exactly as `pnpm dev` does. The web app runs in one of two modes, chosen by `EVO_WEB_MODE`:
- `dev` (default): the Vite dev server, same as `pnpm dev`.
- `preview`: `vite preview` serves the production build from `apps/web/dist` on the same port. `deploy.sh` rebuilds it on every deploy.
`evoo-local.service` is a systemd user unit template that runs the script. Install it once, as `<deploy-user>` in the checkout:
```bash
systemctl --user cat evoo-local.service # note what the current unit runs; keep a copy if needed
install -D -m 0644 ops/evo-deploy/evoo-local.service ~/.config/systemd/user/evoo-local.service
# Edit WorkingDirectory in the installed copy if the checkout is not at ~/Projects/evOo-local.
systemctl --user daemon-reload
systemctl --user enable evoo-local.service
systemctl --user restart evoo-local.service
loginctl enable-linger "$USER" # keep user services running without a login session
```
The unit starts the script through a login shell, so node and pnpm come from the same `PATH` that `deploy.sh` uses. It stays in dev mode, so nothing visible changes. Keep the repo variables `EVO_RESTART_MODE=systemd` and `EVO_SYSTEMD_UNIT=evoo-local.service`. Logs: `journalctl --user -u evoo-local.service -f`.
### Switch to preview
The production build drops message source text, so a message missing from the Lingui catalogs shows as a hash id instead of English. Switch only once the catalogs are complete (`pnpm --filter @rakazo/web intl:extract` adds no new entries).
1. In the root `.env`, set `RAKAZO_HOST` to the hostname browsers use (see [HTTPS with Caddy](#https-with-caddy)) and `SCREEN_PROXY_SECRET` to a dedicated random value of at least 32 characters (`openssl rand -hex 32`), different from `BETTER_AUTH_SECRET` and `SANDBOX_SUPERVISOR_TOKEN`. `vite preview` runs with `NODE_ENV=production` and refuses development placeholders; the API reads the same secret.
2. Check the configuration without restarting anything:
```bash
EVO_WEB_MODE=preview bash ops/evo-deploy/start-stack.sh --check
```
3. Switch the unit and restart:
```bash
systemctl --user edit evoo-local.service
# add:
# [Service]
# Environment=EVO_WEB_MODE=preview
systemctl --user restart evoo-local.service
```
4. Verify: `curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5173/`.
To switch back, remove the override (`systemctl --user revert evoo-local.service`) and restart.
## HTTPS with Caddy
Service workers and web push need a secure context, so phones and other devices need HTTPS; `http://<host-ip>:5173` is not one. Run Caddy on the host and proxy everything to the web port. Both the dev server and `vite preview` forward `/api`, `/rpc` and the screen proxy to the API, so `:3100` stays private:
```Caddyfile
app.example.com {
reverse_proxy 127.0.0.1:5173
}
```
This is the host proxy from `docs/self-host.md`; `infra/compose/Caddyfile.prod` routes the same paths to Compose containers and is not used here. Caddy obtains the certificate itself when the hostname resolves to the host and ports 80 and 443 are reachable; otherwise configure a DNS challenge.
Root `.env`, then restart the unit:
```dotenv
RAKAZO_HOST=app.example.com
EXTRA_TRUSTED_ORIGINS=https://app.example.com
```
- `RAKAZO_HOST` is the hostname `vite preview` accepts. Caddy forwards the original `Host` header; the dev server accepts any host.
- `EXTRA_TRUSTED_ORIGINS` (comma-separated) lets the HTTPS origin sign in. Keep `BETTER_AUTH_URL` on the plain-http origin and set it explicitly, because it falls back to `WEB_ORIGIN`: an `https://` value makes Better Auth use `__Secure-` cookies, which browsers drop over plain http, so signing in via `http://…:5173` would stop working.
- `WEB_ORIGIN` is the origin of links the API builds, such as the agent computer screen. Set it to the HTTPS origin if that is the address you use; an `http://` screen link is blocked as mixed content on the HTTPS page.
## Manual / dry-run
On Evo (or any machine with the checkout and stack):
```bash
cd ~/Projects/evOo-local
cd <checkout>
# Show what would happen (no git reset, migrate, restart, or health wait)
# Show what would happen (no git reset, install, build, migrate, restart, or health wait)
bash ops/evo-deploy/deploy.sh --dry-run
# Deploy main tip
@ -51,30 +116,28 @@ EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service bash ops/evo-deploy
GitHub: **Actions → evo-deploy → Run workflow** (optional `ref`, `dry_run`, `skip_migrate`).
## Rollback behavior
## Deploy and rollback behavior
### Regression: SHA capture vs. git stdout
`deploy.sh` captures `previousSha` and `targetSha` via process substitution (`read … < <(git_fetch_and_checkout)`). Git commands such as `reset --hard` print progress to **stdout** (`HEAD is now at …`), which previously polluted that capture and produced invalid `.evo-deploy/state.json` (e.g. `deployedSha: "is now at 84bc45e …"`, `previousSha: "HEAD"`). Rollback then became a no-op.
**Fix:** all git invocations inside `git_fetch_and_checkout` (and rollback) redirect stdout to stderr; only the final `printf '%s %s\n'` emits SHAs on stdout. `write_state` and rollback reject values that are not 7–40 hex characters.
1. Before `git fetch`, the script records `previousSha` (current `HEAD`).
2. After pull, migrate, and restart, it waits up to `EVO_HEALTH_TIMEOUT_SEC` (default 120s) for:
1. Before `git fetch`, the script records `previousSha` (current `HEAD`), then resets the checkout to the target.
2. `pnpm install --frozen-lockfile`, then the web build into `.evo-deploy/web-dist`. Its output goes to `.evo-deploy/web-build.log` and is printed only when the build fails.
3. `pnpm db:generate && pnpm db:migrate`.
4. The new build replaces `apps/web/dist`, the stack restarts, and the script waits up to `EVO_HEALTH_TIMEOUT_SEC` (default 120s) for:
- `EVO_API_HEALTH_URL` (default `http://127.0.0.1:3100/health`)
- `EVO_WEB_HEALTH_URL` (default `http://127.0.0.1:5173/`)
3. On failure: `git reset --hard` to `previousSha`, restart services again, exit non-zero.
4. Last outcome is written to `.evo-deploy/state.json` under the checkout.
5. Any failure rolls back: `git reset --hard` to `previousSha`, reinstall, regenerate the client, rebuild the web app if this deploy had already replaced it, restart, health-check, exit non-zero. A failing install, build or migration therefore never restarts the stack on the new commit, and `apps/web/dist` keeps the build that was being served. Migrations only move forward and stay applied.
6. The outcome (`success`, `rolled-back` or `rollback-failed`) is written to `.evo-deploy/state.json` under the checkout.
`deploy.sh` captures `previousSha` and `targetSha` via process substitution, so every git command inside `git_fetch_and_checkout` and rollback redirects stdout to stderr (`reset --hard` prints `HEAD is now at …`). `write_state` and rollback reject values that are not 7–40 hex characters. `packages/testkit/src/evo-deploy.test.ts` runs the script against a fixture checkout and covers this, the build gate and the rollback.
Manual rollback to a known good SHA:
```bash
cd ~/Projects/evOo-local
git reset --hard <good-sha>
EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service bash ops/evo-deploy/deploy.sh --skip-migrate --ref HEAD
cd <checkout>
EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service EVO_DEPLOY_REF=<good-sha> \
bash ops/evo-deploy/deploy.sh --skip-migrate
```
(or restart your unit/tmux session by hand, then verify health URLs).
(or `git reset --hard <good-sha>`, `pnpm install --frozen-lockfile`, `pnpm --filter @rakazo/web build`, restart the unit, then verify the health URLs).
## Autopilot / Grok Bot after merge
@ -83,7 +146,7 @@ After a PR merges to `main`:
1. **Preferred:** rely on the `evo-deploy` workflow (once `EVO_DEPLOY_ENABLED=true`). No SSH step required.
2. **Fallback (manual):** SSH to Evo and run:
```bash
cd ~/Projects/evOo-local && bash ops/evo-deploy/deploy.sh
cd <checkout> && bash ops/evo-deploy/deploy.sh
```
3. **Verify:** API `curl -fsS http://127.0.0.1:3100/health`, web `curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5173/`.
@ -91,6 +154,8 @@ Do not commit host passwords, tokens, or SSH private keys. Configure restart tar
## Environment reference
`deploy.sh`:
| Variable | Default | Purpose |
|----------|---------|---------|
| `EVO_DEPLOY_DIR` | `$HOME/Projects/evOo-local` | Git checkout root |
@ -100,10 +165,17 @@ Do not commit host passwords, tokens, or SSH private keys. Configure restart tar
| `EVO_SYSTEMD_SCOPE` | `user` | `user` or `system` |
| `EVO_TMUX_SESSION` | `evoo` | tmux session name |
| `EVO_TMUX_COMMAND` | `pnpm dev` | Command started in new tmux session |
| `EVO_MIGRATE` | `true` | Run `pnpm db:generate` + `pnpm db:migrate` |
| `EVO_INSTALL` | `true` | Run `pnpm install --frozen-lockfile` after checkout |
| `EVO_MIGRATE` | `true` | Run `pnpm db:generate` + `pnpm db:migrate` (`--skip-migrate`) |
| `EVO_INSTALL` | `true` | Run `pnpm install --frozen-lockfile` after checkout (`--skip-install`) |
| `EVO_BUILD` | `true` | Build the web app before the restart (`--skip-build`) |
| `EVO_API_HEALTH_URL` | `http://127.0.0.1:3100/health` | API health probe |
| `EVO_WEB_HEALTH_URL` | `http://127.0.0.1:5173/` | Web health probe |
| `EVO_HEALTH_TIMEOUT_SEC` | `120` | Per-check timeout (seconds) |
If `EVO_RESTART_MODE=auto` cannot detect systemd, tmux, compose, or a restart script, deploy fails with a clear error — set `EVO_RESTART_MODE` and the matching unit/session explicitly.
`start-stack.sh`:
| Variable | Default | Purpose |
|----------|---------|---------|
| `EVO_WEB_MODE` | `dev` | `dev` (Vite dev server) or `preview` (production build) |
If `EVO_RESTART_MODE=auto` cannot detect systemd, tmux, compose, or a restart script, it falls back to `none` and only health-checks — set `EVO_RESTART_MODE` and the matching unit/session explicitly.

View File

@ -1,40 +0,0 @@
#!/usr/bin/env bash
# Regression: git_fetch_and_checkout must emit only two SHAs on stdout.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
git init -q "$TMP/repo"
git -C "$TMP/repo" config user.email "test@example.com"
git -C "$TMP/repo" config user.name "Test"
echo first >"$TMP/repo/file.txt"
git -C "$TMP/repo" add file.txt
git -C "$TMP/repo" commit -q -m "first"
FIRST_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
echo second >"$TMP/repo/file.txt"
git -C "$TMP/repo" commit -q -am "second"
SECOND_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
# Minimal reimplementation of the capture contract under test.
git_fetch_and_checkout() {
local previous_sha target_sha deploy_dir="$1" ref="$2"
previous_sha="$(git -C "$deploy_dir" rev-parse HEAD)"
git -C "$deploy_dir" fetch origin --prune >&2 2>/dev/null || true
target_sha="$(git -C "$deploy_dir" rev-parse "${ref}^{commit}")"
git -C "$deploy_dir" reset --hard "$target_sha" >&2
printf '%s %s\n' "$previous_sha" "$target_sha"
}
git -C "$TMP/repo" remote add origin "$TMP/repo"
git -C "$TMP/repo" checkout -q "$FIRST_SHA"
capture="$(git_fetch_and_checkout "$TMP/repo" "$SECOND_SHA")"
read -r got_previous got_target <<<"$capture"
[[ "$got_previous" == "$FIRST_SHA" ]] || { echo "expected previous $FIRST_SHA, got $got_previous"; exit 1; }
[[ "$got_target" == "$SECOND_SHA" ]] || { echo "expected target $SECOND_SHA, got $got_target"; exit 1; }
[[ "$capture" != *"HEAD is now at"* ]] || { echo "git reset output leaked into capture: $capture"; exit 1; }
echo "ok: deploy-sha-capture regression passed"

View File

@ -1,10 +1,9 @@
#!/usr/bin/env bash
# evOo-local — idempotent deploy helper for the Evo host checkout.
# Pull (or pin) a revision, migrate, restart services, health-check, rollback on failure.
# Pull (or pin) a revision, install, build the web app, migrate, restart services, health-check,
# roll back on failure.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- defaults (override via env; see ops/evo-deploy/README.md) ---
EVO_DEPLOY_DIR="${EVO_DEPLOY_DIR:-${HOME}/Projects/evOo-local}"
EVO_DEPLOY_REF="${EVO_DEPLOY_REF:-main}"
@ -23,6 +22,7 @@ EVO_WEB_HEALTH_URL="${EVO_WEB_HEALTH_URL:-http://127.0.0.1:5173/}"
EVO_HEALTH_TIMEOUT_SEC="${EVO_HEALTH_TIMEOUT_SEC:-120}"
EVO_MIGRATE="${EVO_MIGRATE:-true}"
EVO_INSTALL="${EVO_INSTALL:-true}"
EVO_BUILD="${EVO_BUILD:-true}"
EVO_DRY_RUN="${EVO_DRY_RUN:-false}"
EVO_STATE_FILE="${EVO_STATE_FILE:-${EVO_DEPLOY_DIR}/.evo-deploy/state.json}"
@ -37,7 +37,7 @@ is_valid_git_sha() {
usage() {
cat <<'EOF'
Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install]
Usage: deploy.sh [--dry-run] [--ref REF] [--skip-migrate] [--skip-install] [--skip-build]
Environment: EVO_DEPLOY_DIR, EVO_DEPLOY_REF, EVO_RESTART_MODE, EVO_SYSTEMD_UNIT,
EVO_TMUX_SESSION, EVO_API_HEALTH_URL, EVO_WEB_HEALTH_URL, EVO_HEALTH_TIMEOUT_SEC, …
@ -66,6 +66,10 @@ parse_args() {
EVO_INSTALL=false
shift
;;
--skip-build)
EVO_BUILD=false
shift
;;
-h | --help)
usage
exit 0
@ -212,8 +216,7 @@ wait_for_url() {
}
health_check() {
wait_for_url "$EVO_API_HEALTH_URL" "API"
wait_for_url "$EVO_WEB_HEALTH_URL" "web"
wait_for_url "$EVO_API_HEALTH_URL" "API" && wait_for_url "$EVO_WEB_HEALTH_URL" "web"
}
write_state() {
@ -267,19 +270,78 @@ run_migrate() {
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate && pnpm db:migrate"
}
# Rollback regenerates the client for the previous schema; applied migrations stay applied.
run_generate() {
[[ "$EVO_MIGRATE" == "true" ]] || return 0
log "running pnpm db:generate"
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm db:generate"
}
run_install() {
[[ "$EVO_INSTALL" == "true" ]] || { log "skipping install (EVO_INSTALL=false)"; return 0; }
log "running pnpm install --frozen-lockfile"
run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm install --frozen-lockfile"
}
# The build goes to a staging directory, so a failing build never touches the one a running
# `vite preview` serves; activate_web_build swaps it in right before the restart.
WEB_STAGING_DIR="$EVO_DEPLOY_DIR/.evo-deploy/web-dist"
WEB_BUILD_LOG="$EVO_DEPLOY_DIR/.evo-deploy/web-build.log"
WEB_DIST_DIR="$EVO_DEPLOY_DIR/apps/web/dist"
WEB_BUILD_REPLACED=false
build_web() {
[[ "$EVO_BUILD" == "true" ]] || { log "skipping web build (EVO_BUILD=false)"; return 0; }
log "building the web app"
if [[ "$DRY_RUN" == true ]]; then
log "[dry-run] pnpm --filter @rakazo/web build --outDir $WEB_STAGING_DIR"
return 0
fi
rm -rf "$WEB_STAGING_DIR" && mkdir -p "$(dirname "$WEB_BUILD_LOG")" || return 1
if run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm --filter @rakazo/web build --outDir $(printf '%q' "$WEB_STAGING_DIR") --emptyOutDir" >"$WEB_BUILD_LOG" 2>&1 &&
[[ -f "$WEB_STAGING_DIR/index.html" ]]; then
return 0
fi
# Stack frames bury the message that names the file and line.
grep -v -E '^[[:space:]]+at ' "$WEB_BUILD_LOG" | tail -n 60 >&2 || true
log "ERROR: web build failed (full log: $WEB_BUILD_LOG)"
return 1
}
activate_web_build() {
[[ "$EVO_BUILD" == "true" ]] || return 0
local outgoing="$WEB_STAGING_DIR.outgoing"
log "activating the web build"
WEB_BUILD_REPLACED=true
run rm -rf "$outgoing" || return 1
if [[ -d "$WEB_DIST_DIR" ]]; then
run mv "$WEB_DIST_DIR" "$outgoing" || return 1
fi
run mv "$WEB_STAGING_DIR" "$WEB_DIST_DIR" || return 1
run rm -rf "$outgoing"
}
# Restores the previous commit with its dependencies, client and web build, restarts and
# health-checks it. Every step is attempted so the service comes back even if one of them fails.
rollback() {
local previous_sha="$1"
local previous_sha="$1" status="rolled-back"
is_valid_git_sha "$previous_sha" || die "rollback aborted: invalid previous_sha: $previous_sha"
log "ROLLBACK: resetting to $previous_sha and restarting services"
run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" >&2
restart_services
write_state "$previous_sha" "$previous_sha" "rolled-back"
log "ROLLBACK: resetting to $previous_sha"
if ! run git -C "$EVO_DEPLOY_DIR" reset --hard "$previous_sha" >&2; then
write_state "$previous_sha" "$previous_sha" "rollback-failed"
die "deploy failed and the checkout could not be reset to $previous_sha"
fi
run_install || status="rollback-failed"
run_generate || status="rollback-failed"
# Until activation the served build is still the previous one; rebuild only once it was replaced.
if [[ "$WEB_BUILD_REPLACED" == true ]] && ! { build_web && activate_web_build; }; then
status="rollback-failed"
fi
if ! { restart_services && health_check; }; then
status="rollback-failed"
fi
write_state "$previous_sha" "$previous_sha" "$status"
[[ "$status" == "rolled-back" ]] || die "deploy failed; rollback to $previous_sha did not complete"
die "deploy failed; rolled back to $previous_sha"
}
@ -300,16 +362,12 @@ main() {
is_valid_git_sha "$target_sha" || die "invalid target_sha captured from checkout: $target_sha"
if [[ "$previous_sha" == "$target_sha" && "$DRY_RUN" != true ]]; then
log "already at $target_sha — continuing with migrate/restart/health"
log "already at $target_sha — continuing with install/build/migrate/restart/health"
fi
run_install
run_migrate
restart_services
if ! health_check; then
# Any failure rolls back; a failing install, build or migration stops before the restart.
if ! { run_install && build_web && run_migrate && activate_web_build && restart_services && health_check; }; then
rollback "$previous_sha"
exit 1
fi
write_state "$previous_sha" "$target_sha" "success"

View File

@ -0,0 +1,19 @@
# systemd user unit for the evOo stack in this checkout. Template: install and switch modes as
# described in ops/evo-deploy/README.md.
[Unit]
Description=evOo stack (api, worker, sandbox supervisor, web)
[Service]
Type=simple
# Same default checkout as deploy.sh (EVO_DEPLOY_DIR); change it if the checkout lives elsewhere.
WorkingDirectory=%h/Projects/evOo-local
# dev (Vite dev server) or preview (production build); see the README before switching.
Environment=EVO_WEB_MODE=dev
# A login shell puts node and pnpm on PATH the same way deploy.sh finds them.
ExecStart=/bin/bash -lc 'exec ./ops/evo-deploy/start-stack.sh'
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
[Install]
WantedBy=default.target

79
ops/evo-deploy/start-stack.sh Executable file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# evOo-local — start the stack from this checkout (used by evoo-local.service).
# api, worker and sandbox supervisor run as `pnpm dev` runs them. The web app runs as the Vite dev
# server (EVO_WEB_MODE=dev, the default) or serves the production build with `vite preview` on the
# same port (EVO_WEB_MODE=preview). `--check` validates the configuration and exits.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
EVO_WEB_MODE="${EVO_WEB_MODE:-dev}"
log() { printf '[evo-stack] %s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
# `vite preview` runs with NODE_ENV=production, so apps/web/vite.config.ts refuses development
# secrets. Resolve the env the way that config does (process env first, then the root .env files)
# and run the same guard, so a missing value fails here instead of inside a restart loop.
check_preview() {
[[ -f "$ROOT/apps/web/dist/index.html" ]] ||
die "apps/web/dist has no build; run: pnpm --filter @rakazo/web build"
(cd "$ROOT/apps/web" && NODE_ENV=production "$ROOT/node_modules/.bin/tsx" --input-type=module -e '
import { loadEnv } from "vite";
import { resolveScreenProxySecret } from "../../packages/core/src/secrets-guard.ts";
const files = loadEnv("production", "../..", "");
const env = { ...process.env };
for (const key of ["RAKAZO_HOST", "SCREEN_PROXY_SECRET", "SANDBOX_SUPERVISOR_TOKEN", "BETTER_AUTH_SECRET"]) {
env[key] = files[key];
}
const problems = [];
if (!env.RAKAZO_HOST?.trim()) problems.push("Set RAKAZO_HOST to the hostname the web app is served on.");
try {
resolveScreenProxySecret(env);
} catch (error) {
problems.push(error.message);
}
for (const problem of problems) console.error("[evo-stack] ERROR: " + problem);
process.exitCode = problems.length > 0 ? 1 : 0;
')
}
start_preview() {
pnpm dev '--filter=!@rakazo/web' &
SERVICES_PID=$!
NODE_ENV=production pnpm --filter @rakazo/web preview --strictPort &
WEB_PID=$!
trap 'kill "$SERVICES_PID" "$WEB_PID" 2>/dev/null || true' EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
# Polling instead of `wait -n` keeps this working on bash 3.2.
while kill -0 "$SERVICES_PID" 2>/dev/null && kill -0 "$WEB_PID" 2>/dev/null; do
sleep 1
done
die "a service exited; stopping the stack"
}
main() {
case "${1:-}" in
"" | --check) ;;
*) die "usage: start-stack.sh [--check]" ;;
esac
case "$EVO_WEB_MODE" in
dev | preview) ;;
*) die "EVO_WEB_MODE must be dev or preview (got: $EVO_WEB_MODE)" ;;
esac
cd "$ROOT"
if [[ "$EVO_WEB_MODE" == preview ]]; then
check_preview
fi
if [[ "${1:-}" == --check ]]; then
log "ok ($EVO_WEB_MODE)"
return 0
fi
if [[ "$EVO_WEB_MODE" == dev ]]; then
exec pnpm dev
fi
start_preview
}
main "$@"

View File

@ -2,5 +2,6 @@ export * from "./background-jobs.js";
export * from "./cloud-agents.js";
export * from "./interfaces.js";
export * from "./media.js";
export * from "./memory.js";
export * from "./registry.js";
export * from "./types.js";

View File

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

View File

@ -257,6 +257,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;
}
@ -442,7 +444,7 @@ export interface ScriptedTurn {
ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> };
takeover?: { reason: string };
files?: Array<{ path: string; content: string }>;
memory?: Array<{ scope: "bot" | "user"; path: string; content: string }>;
memory?: Array<Pick<MemoryCommitRequest, "scope" | "path" | "content" | "mode">>;
complete?: boolean;
}
@ -592,7 +594,7 @@ export class ArtifactTooLargeError extends Error {
}
export interface NotificationMessage {
kind: "completion" | "failure" | "help" | "takeover";
kind: "completion" | "failure" | "help" | "takeover" | "nudge";
title: string;
body: string;
botId: string;

View File

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

View File

@ -14,7 +14,9 @@ import {
USER_FACT_CATEGORIES,
} from "@rakazo/contracts";
import { z } from "zod";
import { coachTools } from "./coach-tools.js";
import { mediaToolInputSchema } from "./media-tools.js";
import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js";
/** A media tool's parameters from its operation's input schema, plus `continue_after`. */
function mediaParameters(schema: z.ZodType) {
@ -438,12 +440,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"],
},
@ -482,6 +490,28 @@ export const builtinAgentTools: ConnectorTool[] = [
},
},
},
...coachTools,
{
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:

View File

@ -0,0 +1,294 @@
import { randomUUID } from "node:crypto";
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
import { nextCronDate } from "@rakazo/core";
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";
import { updateUserProfileFromTool } from "./user-profile-tools.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<void>;
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));
});
it("moves the check-ins, and only them, when the profile time zone changes", async () => {
const userId = await newUser("Europe/Vienna");
const jobs = fakeJobs();
const deps = { prisma, jobs };
await setCoachRhythm(deps, {
userId,
rhythm: { morning: "07:30", evening: "21:00", weekly: { day: 0, time: "18:00" } },
});
const conversation = await ensureMainConversation(prisma, userId);
const evening = await prisma.routine.findFirstOrThrow({
where: { userId, coachKey: "evening" },
});
await prisma.routine.update({
where: { id: evening.id },
data: { active: false, nextRunAt: null },
});
const own = await prisma.routine.create({
data: {
botId: conversation.leadBotId,
userId,
spaceId: conversation.spaceId,
name: "Water the plants",
prompt: "Remind me to water the plants.",
crons: ["0 9 * * *"],
timezone: "Europe/Vienna",
active: true,
nextRunAt: nextCronDate("0 9 * * *", new Date(), "Europe/Vienna"),
},
});
const setZone = (time_zone: string) =>
updateUserProfileFromTool(
deps,
{
userId,
bot: { id: conversation.leadBotId, kind: "main" },
threadId: conversation.threadId,
sourceMessageId: null,
},
{ time_zone },
);
const before = await coachRoutines(userId);
// Saving the zone the check-ins already use changes nothing.
jobs.enqueue.mockClear();
expect(await setZone("Europe/Vienna")).toMatchObject({ ok: true });
expect(await coachRoutines(userId)).toEqual(before);
expect(jobs.enqueue).not.toHaveBeenCalled();
expect(await setZone("America/New_York")).toMatchObject({ ok: true });
const moved = await coachRoutines(userId);
expect(moved.map((routine) => [routine.coachKey, routine.timezone])).toEqual([
["evening", "America/New_York"],
["morning", "America/New_York"],
["weekly", "America/New_York"],
]);
const localTime = (date: Date) =>
new Intl.DateTimeFormat("en-GB", {
timeZone: "America/New_York",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(date);
const [pausedEvening, morning, weekly] = moved;
expect(localTime(morning!.nextRunAt!)).toMatch(/ 07:30$/);
expect(localTime(weekly!.nextRunAt!)).toBe("Sun 18:00");
for (const routine of [morning!, weekly!]) {
expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
expect(routine.nextRunAt).not.toEqual(before.find((old) => old.id === routine.id)?.nextRunAt);
}
expect(jobs.enqueue).toHaveBeenCalledTimes(2);
expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(morning!.id, morning!.nextRunAt!));
expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(weekly!.id, weekly!.nextRunAt!));
// A paused check-in moves but stays paused; the user's own routine keeps its zone.
expect(pausedEvening).toMatchObject({ active: false, nextRunAt: null });
expect(await prisma.routine.findUniqueOrThrow({ where: { id: own.id } })).toEqual(own);
expect(await getCoachRhythm(prisma, userId)).toEqual({
morning: "07:30",
evening: null,
weekly: { day: 0, time: "18:00" },
});
// Saving the new zone again changes nothing.
jobs.enqueue.mockClear();
await setZone("America/New_York");
expect(await coachRoutines(userId)).toEqual(moved);
expect(jobs.enqueue).not.toHaveBeenCalled();
});
it("creates no check-ins for a user without any when the time zone changes", async () => {
const userId = await newUser("Europe/Vienna");
const jobs = fakeJobs();
const main = await ensureMainConversation(prisma, userId);
await updateUserProfileFromTool(
{ prisma, jobs },
{
userId,
bot: { id: main.leadBotId, kind: "main" },
threadId: main.threadId,
sourceMessageId: null,
},
{ time_zone: "Asia/Tokyo" },
);
expect(await prisma.userProfile.findUnique({ where: { userId } })).toMatchObject({
timeZone: "Asia/Tokyo",
});
expect(await prisma.routine.count({ where: { userId } })).toBe(0);
expect(jobs.enqueue).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,177 @@
import type { JobPublisher } from "@rakazo/adapter-kit";
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
import type { CoachRhythm, CoachRoutineKey } from "@rakazo/contracts";
import {
COACH_RHYTHM_OFF,
COACH_ROUTINE_KEYS,
COACH_ROUTINE_NAMES,
clockTimeToMinutes,
minutesToClockTime,
} from "@rakazo/contracts";
import { nextCronDate, nextCronDateAcross } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db";
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
* 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<CoachKey, { name: string; prompt: string }> = {
morning: {
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: 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: 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.",
},
};
export interface CoachRhythmDeps {
prisma: PrismaClient;
jobs: Pick<JobPublisher, "enqueue" | "cancel">;
}
/** 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 minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time);
return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`;
}
/** 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 = 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;
}
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<CoachRhythm> {
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<CoachRhythm> {
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);
}
/**
* Moves the main bot's check-ins into the user's current profile time zone and re-arms each
* armed one for its next local run. Check-ins already in that zone and routines the user made
* stay as they are, so calling it after every time zone save is safe.
*/
export async function syncCoachTimeZone(deps: CoachRhythmDeps, userId: string): Promise<void> {
const { prisma, jobs } = deps;
const botId = await findMainBotId(prisma, userId);
if (!botId) return;
const timezone = await coachTimeZone(prisma, userId);
const moved = await prisma.routine.findMany({
where: { botId, userId, coachKey: { not: null }, timezone: { not: timezone } },
});
for (const routine of moved) {
const armed = routine.active && routine.nextRunAt !== null;
const nextRunAt = armed
? (nextCronDateAcross(routine.crons, new Date(), timezone) ?? routine.nextRunAt)
: routine.nextRunAt;
// Skipped when another save moved it meanwhile.
const { count } = await prisma.routine.updateMany({
where: { id: routine.id, timezone: routine.timezone },
data: { timezone, nextRunAt },
});
if (count === 1 && armed && nextRunAt) {
await jobs.enqueue(routineWakeupJob(routine.id, nextRunAt));
}
}
}

View File

@ -0,0 +1,102 @@
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;
/** A project's main bot rather than the user's personal agent. */
projectLead?: 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: [],
personalAgent: options.mainBot && !options.projectLead,
},
}).map((tool) => tool.name);
}
/**
* Only the space lookup that tells the personal agent from a project lead may reach the
* database: refusals and invalid input must not write anything.
*/
const lookupOnlyPrisma = new Proxy(
{
space: {
findUnique: async ({ where }: { where: { id: string } }) => ({
isDefault: where.id === "space-home",
}),
},
},
{
get(target, property) {
if (property === "space") return target.space;
throw new Error(`unexpected database access: ${String(property)}`);
},
},
) as unknown as PrismaClient;
const context = (kind: string, spaceId = "space-home") => ({
userId: "user-1",
spaceId,
bot: { id: "bot-1", kind },
});
describe("coach tools", () => {
it("are the personal main agent'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, projectLead: true })).not.toContain(name);
expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain(name);
expect(toolNames({ mainBot: true, foreignAgentRun: true })).not.toContain(name);
}
});
it("refuse specialists and project leads even when they call a tool anyway", async () => {
for (const name of COACH_TOOL_NAMES) {
for (const caller of [context("specialist"), context("main", "space-project")]) {
await expect(
executeCoachTool(lookupOnlyPrisma, caller, 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(lookupOnlyPrisma, main, "update_goal", {})).resolves.toEqual({
error: "A new goal needs a title.",
});
await expect(
executeCoachTool(lookupOnlyPrisma, main, "update_goal", { title: "x", target_date: "soon" }),
).resolves.toEqual({ error: expect.stringContaining("target_date") });
await expect(
executeCoachTool(lookupOnlyPrisma, main, "update_habit", {
title: "Walk",
cadence: "hourly",
}),
).resolves.toEqual({ error: expect.stringContaining("cadence") });
await expect(
executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { habit: "Walk", goal: "10k" }),
).resolves.toEqual({ error: "Log a habit and a goal in separate calls." });
await expect(
executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { mood: 9 }),
).resolves.toEqual({ error: expect.stringContaining("mood") });
await expect(
executeCoachTool(lookupOnlyPrisma, main, "write_daily_brief", { text: " " }),
).resolves.toEqual({ error: expect.stringContaining("text") });
});
});

View File

@ -0,0 +1,357 @@
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,
isPersonalMainBot,
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;
/** The space the run happens in. */
spaceId: string;
bot: { id: string; kind: string };
}
type ToolResult = Record<string, unknown>;
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<Array<{ id: string }>>,
ref: string,
what: "goal" | "habit",
): Promise<string | ToolResult> {
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<string, unknown>,
): Promise<ToolResult> {
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<string, unknown>,
): Promise<ToolResult> {
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<string, unknown>,
): Promise<ToolResult> {
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<string, unknown>,
): Promise<ToolResult> {
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<string, unknown>,
) => Promise<ToolResult>
> = {
update_goal: updateGoalFromTool,
update_habit: updateHabitFromTool,
log_check_in: logCheckInFromTool,
write_daily_brief: writeDailyBriefFromTool,
};
/** Runs one coach tool for the user's personal main agent; every other bot is refused. */
export async function executeCoachTool(
prisma: PrismaClient,
context: CoachToolContext,
name: CoachToolName,
rawArgs: Record<string, unknown>,
): Promise<ToolResult> {
if (!(await isPersonalMainBot(prisma, { kind: context.bot.kind, spaceId: context.spaceId }))) {
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;
}
}

View File

@ -1,6 +1,6 @@
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import { formatCurrentTimeInstruction, loadUserTimeZone } from "./current-time.js";
import { formatCurrentTimeInstruction, formatLocalDate, loadUserTimeZone } from "./current-time.js";
describe("formatCurrentTimeInstruction", () => {
it("anchors the present moment in UTC with the weekday and no sub-second noise", () => {
@ -93,3 +93,12 @@ describe("loadUserTimeZone", () => {
).resolves.toBeUndefined();
});
});
describe("formatLocalDate", () => {
it("is the user's local date, or the UTC date without a known zone", () => {
const now = new Date("2026-01-01T03:00:00Z");
expect(formatLocalDate("America/Los_Angeles", now)).toBe("2025-12-31");
expect(formatLocalDate("Not/AZone", now)).toBe("2026-01-01");
expect(formatLocalDate(null, now)).toBe("2026-01-01");
});
});

View File

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

View File

@ -117,6 +117,9 @@ async function runBot(
user: { findUnique: vi.fn(async () => ({ 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;

View File

@ -319,6 +319,14 @@ describe("mutating tool effect idempotency keys", () => {
]);
await f.run();
expect(f.memoryCommit).toHaveBeenCalledOnce();
// remember appends, so only the effect record keeps a retry from adding the note twice.
expect(f.memoryCommit).toHaveBeenCalledWith(
expect.objectContaining({
mode: "append",
content: expect.stringMatching(/^- \d{4}-\d{2}-\d{2}: durable fact$/),
}),
expect.anything(),
);
expect(f.effects).toHaveLength(1);
f.setCalls([

Some files were not shown because too many files have changed in this diff Show More