feat: show routines the bot sets up from chat as one quiet line (#133)

* feat: show routines the bot sets up from chat as one quiet line

When someone mentions something recurring in passing ("every Sunday I plan the
week"), the bot now creates the routine right away and the conversation shows a
single line with the schedule, Edit and Undo, on web and mobile.

- New `event` message block for routine lines, kept in history so later turns
  know what was set up or removed.
- schedule_create reuses an active repeating routine of the same name instead
  of adding a duplicate, caps a bot at 50 active schedules, and falls back to
  default times when none was given.
- Removing a routine (Undo, the editor, or schedule_cancel) marks its lines
  removed via the new routines.noticeMessageId column.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

* test: give the routine notice test a complete job publisher

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-04 20:36:53 +02:00 committed by GitHub
parent d6dd2bef5e
commit dbf37f86a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1536 additions and 16 deletions

View File

@ -142,6 +142,7 @@ import {
isProjectGoalStatus, isProjectGoalStatus,
issueMessagingLinkCode, issueMessagingLinkCode,
listSpaceUserBotIds, listSpaceUserBotIds,
markRoutineNoticesRemoved,
newestModelCredentialOrder, newestModelCredentialOrder,
newestVoiceCredentialOrder, newestVoiceCredentialOrder,
ownMainAgentsBlockingSpaceDeletion, ownMainAgentsBlockingSpaceDeletion,
@ -2909,6 +2910,8 @@ export function createRouter(deps: RouterDeps) {
if (!existing) throw new IsolationError(); if (!existing) throw new IsolationError();
await deps.prisma.routine.delete({ where: { id: existing.id } }); await deps.prisma.routine.delete({ where: { id: existing.id } });
await deps.jobs.cancel(routineJobKey(existing.id)); await deps.jobs.cancel(routineJobKey(existing.id));
const marked = await markRoutineNoticesRemoved(deps.prisma, existing).catch(() => null);
if (marked) await deps.events.notify(marked.threadId, marked.seq).catch(() => undefined);
return { ok: true as const }; return { ok: true as const };
}), }),
testRun: authed.routines.testRun.handler(async ({ context, input }) => { testRun: authed.routines.testRun.handler(async ({ context, input }) => {

View File

@ -19,6 +19,7 @@ import {
cloudAgentHttpsUrl, cloudAgentHttpsUrl,
deriveWorkspaceFocus, deriveWorkspaceFocus,
isApprovalAskBlock, isApprovalAskBlock,
isRoutineEventMessage,
isRunTerminalEvent, isRunTerminalEvent,
isSecretAskBlock, isSecretAskBlock,
latestAnswerableAskMessageId, latestAnswerableAskMessageId,
@ -67,6 +68,7 @@ import {
type MarkdownArtifactPreviewTarget, type MarkdownArtifactPreviewTarget,
} from "../components/markdown-artifact-preview"; } from "../components/markdown-artifact-preview";
import { NativeSymbol } from "../components/native-symbol"; import { NativeSymbol } from "../components/native-symbol";
import { RoutineEventLine } from "../components/routine-event-line";
import type { import type {
MobileBot, MobileBot,
MobileGroup, MobileGroup,
@ -2400,6 +2402,15 @@ const MessageBubble = memo(function MessageBubble({
</View> </View>
); );
} }
if (isRoutineEventMessage(message.blocks)) {
return (
<View style={{ width: "100%" }}>
{message.blocks.map((block, index) =>
block.kind === "event" ? <RoutineEventLine key={index} block={block} /> : null,
)}
</View>
);
}
const handoff = message.blocks.find((block) => block.kind === "handoff"); const handoff = message.blocks.find((block) => block.kind === "handoff");
if (handoff) { if (handoff) {
const from = memberName(members, handoff.fromBotId) ?? t("bot"); const from = memberName(members, handoff.fromBotId) ?? t("bot");

View File

@ -0,0 +1,105 @@
import type { RoutineEventBlock } from "@rakazo/contracts";
import { isOneShotRoutineCrons } from "@rakazo/core";
import { useRouter } from "expo-router";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import { rpc } from "../lib/api";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { routineLineSchedule } from "../lib/routine-line";
import { NativeSymbol } from "./native-symbol";
/** A routine the bot set up from the conversation: one quiet line with Edit and Undo. */
export function RoutineEventLine({ block }: { block: RoutineEventBlock }) {
const tokens = useMobileTokens();
const { t } = useI18n();
const router = useRouter();
const [undoing, setUndoing] = useState(false);
const [failed, setFailed] = useState(false);
const oneShot = isOneShotRoutineCrons(block.crons);
const schedule = routineLineSchedule(block);
const muted = { color: tokens.mutedForeground, fontSize: 13.5 };
async function undo() {
setUndoing(true);
setFailed(false);
try {
await rpc("routines/remove", { routineId: block.routineId });
} catch {
setFailed(true);
} finally {
setUndoing(false);
}
}
return (
<View
style={{
width: "100%",
paddingVertical: 4,
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
columnGap: 6,
rowGap: 2,
}}
>
<View
accessible
accessibilityRole="image"
accessibilityLabel={oneShot ? t("Reminder") : t("Routine")}
>
<NativeSymbol
ios={oneShot ? "bell" : "repeat"}
android={oneShot ? "notifications-outline" : "repeat"}
size={14}
color={tokens.mutedForeground}
/>
</View>
<Text
style={[
muted,
block.removed
? { textDecorationLine: "line-through" }
: { color: tokens.foreground, fontWeight: "500" },
]}
>
{block.name}
</Text>
{schedule ? <Text style={muted}>· {schedule}</Text> : null}
{block.removed ? (
<Text style={muted}>· {t("Removed")}</Text>
) : (
<>
{block.event === "routine.updated" ? <Text style={muted}>· {t("Updated")}</Text> : null}
<Text style={muted}>·</Text>
<Pressable
accessibilityRole="button"
hitSlop={10}
onPress={() =>
router.push({
pathname: "/routine",
params: { botId: block.botId, routineId: block.routineId },
})
}
>
<Text style={[muted, { fontWeight: "600" }]}>{t("Edit")}</Text>
</Pressable>
<Text style={muted}>·</Text>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: undoing }}
disabled={undoing}
hitSlop={10}
onPress={() => void undo()}
>
<Text style={[muted, { fontWeight: "600", opacity: undoing ? 0.5 : 1 }]}>
{failed ? t("Try again") : t("Undo")}
</Text>
</Pressable>
</>
)}
</View>
);
}

View File

@ -901,6 +901,7 @@ export function blockText(message: MobileMessage) {
return answer ? `${block.question}\n${answer}` : block.question; return answer ? `${block.question}\n${answer}` : block.question;
} }
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
if (block.kind === "event") return block.name;
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`; if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
if (block.kind === "file") { if (block.kind === "file") {
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`; return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;

View File

@ -600,4 +600,18 @@ export const RU_MESSAGES: Record<string, string> = {
"{name} (permission denied)": "{name} (в разрешении отказано)", "{name} (permission denied)": "{name} (в разрешении отказано)",
"{name} (unsupported type)": "{name} (неподдерживаемый тип)", "{name} (unsupported type)": "{name} (неподдерживаемый тип)",
"Saved {name} locally": "{name} сохранен локально.", "Saved {name} locally": "{name} сохранен локально.",
Reminder: "Напоминание",
Removed: "Удалено",
Updated: "Изменено",
Edit: "Изменить",
"Try again": "Повторить",
Undo: "Отменить",
"Every hour": "Каждый час",
"Every {count} min": "Каждые {count} мин",
"Every {count} h": "Каждые {count} ч",
"Daily, {time}": "Ежедневно, {time}",
"Weekdays, {time}": "По будням, {time}",
"Every {day}, {time}": "Еженедельно: {day}, {time}",
"Monthly on day {day}, {time}": "Ежемесячно {day}-го числа, {time}",
"Next {date}": "Далее: {date}",
}; };

View File

@ -584,4 +584,18 @@ export const ZH_MESSAGES: Record<string, string> = {
"Extra high": "超高", "Extra high": "超高",
Low: "低", Low: "低",
High: "高", High: "高",
Reminder: "提醒",
Removed: "已移除",
Updated: "已更新",
Edit: "编辑",
"Try again": "重试",
Undo: "撤销",
"Every hour": "每小时",
"Every {count} min": "每 {count} 分钟",
"Every {count} h": "每 {count} 小时",
"Daily, {time}": "每天 {time}",
"Weekdays, {time}": "工作日 {time}",
"Every {day}, {time}": "每{day} {time}",
"Monthly on day {day}, {time}": "每月 {day} 日 {time}",
"Next {date}": "下次:{date}",
}; };

View File

@ -7,7 +7,8 @@ export function isCenteredAgentEvent(blocks: readonly MessageBlock[]): boolean {
block.kind === "handoff" || block.kind === "handoff" ||
block.kind === "bot_message_sent" || block.kind === "bot_message_sent" ||
block.kind === "bot_message_received" || block.kind === "bot_message_received" ||
block.kind === "channel_message", block.kind === "channel_message" ||
block.kind === "event",
); );
} }

View File

@ -0,0 +1,56 @@
import type { RoutineEventBlock } from "@rakazo/contracts";
import { routineScheduleShape } from "@rakazo/core";
import { dateLocaleForUi, t } from "./i18n";
/** 2024-01-07 is a Sunday, so day n of that week has weekday n. */
const weekdayName = (weekday: number, locale: string) =>
new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }).format(
new Date(Date.UTC(2024, 0, 7 + weekday)),
);
const clock = (hour: number, minute: number, locale: string) =>
new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format(
new Date(Date.UTC(2024, 0, 1, hour, minute)),
);
/** When a routine runs, in a few words of the active locale. */
export function routineLineSchedule(
block: Pick<RoutineEventBlock, "crons" | "nextRunAt" | "timezone">,
locale = dateLocaleForUi(),
): string {
const shape = routineScheduleShape(block.crons);
switch (shape.kind) {
case "hourly":
return t("Every hour");
case "minutes":
return t("Every {count} min", { count: shape.every });
case "hours":
return t("Every {count} h", { count: shape.every });
case "daily":
return t("Daily, {time}", { time: clock(shape.hour, shape.minute, locale) });
case "weekdays":
return t("Weekdays, {time}", { time: clock(shape.hour, shape.minute, locale) });
case "weekly":
return t("Every {day}, {time}", {
day: weekdayName(shape.weekday, locale),
time: clock(shape.hour, shape.minute, locale),
});
case "monthly":
return t("Monthly on day {day}, {time}", {
day: shape.day,
time: clock(shape.hour, shape.minute, locale),
});
default: {
if (!block.nextRunAt) return "";
const date = new Intl.DateTimeFormat(locale, {
weekday: "short",
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
timeZone: block.timezone,
}).format(new Date(block.nextRunAt));
return shape.kind === "once" ? date : t("Next {date}", { date });
}
}
}

View File

@ -0,0 +1,60 @@
import { expect, type Page, test } from "@playwright/test";
import type { Routine } from "@rakazo/contracts";
import {
activeBotId,
captureScreenshot,
completeOnboarding,
openUserSettings,
rpc,
signup,
} from "./helpers";
async function useAppearance(page: Page, appearance: "light" | "dark") {
const settings = await openUserSettings(page);
await settings.getByTestId(`ui-appearance-${appearance}`).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", appearance);
await settings.getByRole("button", { name: "Close user settings" }).click();
await expect(settings).toBeHidden();
}
test("a routine said in passing becomes one quiet line with Edit and Undo", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `routine-line-${stamp}@rakazo.test`, "password12", "Routine Line");
await completeOnboarding(page);
const botId = activeBotId(page);
await page.getByPlaceholder(/Message/).fill("Every Sunday I plan the week, keep me on it.");
await page.keyboard.press("Enter");
const line = page.getByTestId("routine-event-line");
await expect(line).toBeVisible({ timeout: 60_000 });
await expect(line).toContainText("Weekly plan");
await expect(line).toContainText("Every Sunday, 6:00 PM");
await expect(page.getByText("i will remind you every sunday at 6 pm.")).toBeVisible({
timeout: 30_000,
});
await useAppearance(page, "light");
await line.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "routine-line-light");
await useAppearance(page, "dark");
await line.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "routine-line-dark");
await line.getByRole("button", { name: "Edit" }).click();
await expect(page.getByPlaceholder("Name this routine")).toHaveValue("Weekly plan", {
timeout: 15_000,
});
await page.getByRole("button", { name: "Back" }).click();
await line.getByRole("button", { name: "Undo" }).click();
await expect(line).toContainText("Removed", { timeout: 15_000 });
await expect(line.getByRole("button", { name: "Undo" })).toHaveCount(0);
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByTestId("routine-event-line")).toContainText("Removed", {
timeout: 30_000,
});
});

View File

@ -620,5 +620,15 @@
"Loading voices…": "Stimmen werden geladen…", "Loading voices…": "Stimmen werden geladen…",
"Voice is not available yet. You can turn it on later in settings.": "Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren.", "Voice is not available yet. You can turn it on later in settings.": "Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren.",
"Voice set to {0}.": "Stimme auf {0} gesetzt.", "Voice set to {0}.": "Stimme auf {0} gesetzt.",
"Could not update voice": "Stimme konnte nicht aktualisiert werden" "Could not update voice": "Stimme konnte nicht aktualisiert werden",
"Daily, {time}": "Täglich, {time}",
"Every {count} h": "Alle {count} Std.",
"Every {count} min": "Alle {count} Min.",
"Every {day}, {time}": "Jeden {day}, {time}",
"Monthly on day {day}, {time}": "Monatlich am {day}., {time}",
"Next {date}": "Nächstes Mal {date}",
"Reminder": "Erinnerung",
"Removed": "Entfernt",
"Undo": "Rückgängig",
"Weekdays, {time}": "Werktags, {time}"
} }

View File

@ -0,0 +1,35 @@
import { describe, expect, it, vi } from "vitest";
import { routineLineSchedule } from "./routine-line";
// The macros compile at build time; here the English source comes back with its values filled in.
vi.mock("@lingui/core/macro", () => ({
t: (parts: TemplateStringsArray, ...values: unknown[]) =>
parts.reduce((out, part, index) => `${out}${String(values[index - 1])}${part}`),
}));
const at = (crons: string[], nextRunAt: string | null = null) => ({
crons,
nextRunAt,
timezone: "Europe/Berlin",
});
describe("routineLineSchedule", () => {
it("words the common schedules with the locale's clock", () => {
expect(routineLineSchedule(at(["0 18 * * 0"]), "de-DE")).toBe("Every Sonntag, 18:00");
expect(routineLineSchedule(at(["0 18 * * 0"]), "en-US")).toBe("Every Sunday, 6:00 PM");
expect(routineLineSchedule(at(["30 7 * * *"]), "en-US")).toBe("Daily, 7:30 AM");
expect(routineLineSchedule(at(["0 8 * * 1-5"]), "de-DE")).toBe("Weekdays, 8:00");
expect(routineLineSchedule(at(["0 9 1 * *"]), "en-US")).toBe("Monthly on day 1, 9:00 AM");
expect(routineLineSchedule(at(["*/30 * * * *"]), "en-US")).toBe("Every 30 min");
});
it("shows a reminder's time in the routine's zone, and the next run for other schedules", () => {
expect(routineLineSchedule(at(["@once"], "2026-10-05T07:00:00.000Z"), "de-DE")).toBe(
"Mo., 5. Okt., 9:00",
);
expect(routineLineSchedule(at(["0 9 * * 1,3"], "2026-10-05T07:00:00.000Z"), "en-US")).toBe(
"Next Mon, Oct 5, 9:00 AM",
);
expect(routineLineSchedule(at(["0 9 * * 1,3"]), "en-US")).toBe("");
});
});

View File

@ -0,0 +1,67 @@
import { t } from "@lingui/core/macro";
import type { RoutineEventBlock } from "@rakazo/contracts";
import { routineScheduleShape } from "@rakazo/core";
/** 2024-01-07 is a Sunday, so day n of that week has weekday n. */
const weekdayName = (weekday: number, locale: string) =>
new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }).format(
new Date(Date.UTC(2024, 0, 7 + weekday)),
);
const clock = (hour: number, minute: number, locale: string) =>
new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format(
new Date(Date.UTC(2024, 0, 1, hour, minute)),
);
const nextRun = (iso: string, timeZone: string, locale: string) =>
new Intl.DateTimeFormat(locale, {
weekday: "short",
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
timeZone,
}).format(new Date(iso));
/** When a routine runs, in a few words of the active locale. */
export function routineLineSchedule(
block: Pick<RoutineEventBlock, "crons" | "nextRunAt" | "timezone">,
locale: string,
): string {
const shape = routineScheduleShape(block.crons);
switch (shape.kind) {
case "hourly":
return t`Every hour`;
case "minutes": {
const count = shape.every;
return t`Every ${count} min`;
}
case "hours": {
const count = shape.every;
return t`Every ${count} h`;
}
case "daily": {
const time = clock(shape.hour, shape.minute, locale);
return t`Daily, ${time}`;
}
case "weekdays": {
const time = clock(shape.hour, shape.minute, locale);
return t`Weekdays, ${time}`;
}
case "weekly": {
const day = weekdayName(shape.weekday, locale);
const time = clock(shape.hour, shape.minute, locale);
return t`Every ${day}, ${time}`;
}
case "monthly": {
const day = shape.day;
const time = clock(shape.hour, shape.minute, locale);
return t`Monthly on day ${day}, ${time}`;
}
default: {
if (!block.nextRunAt) return "";
const date = nextRun(block.nextRunAt, block.timezone, locale);
return shape.kind === "once" ? date : t`Next ${date}`;
}
}
}

View File

@ -1861,6 +1861,10 @@ msgstr "Kundensupport"
msgid "Customize bot avatar" msgid "Customize bot avatar"
msgstr "Bot-Avatar anpassen" msgstr "Bot-Avatar anpassen"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr "Täglich, {time}"
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
msgid "Dark" msgid "Dark"
msgstr "Dunkel" msgstr "Dunkel"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "Frühere Nachricht" msgstr "Frühere Nachricht"
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit" msgid "Edit"
msgstr "Bearbeiten" msgstr "Bearbeiten"
@ -2194,6 +2199,18 @@ msgstr "Abend-Check-in"
msgid "Every" msgid "Every"
msgstr "Alle" msgstr "Alle"
#: src/lib/routine-line.ts
msgid "Every {count} h"
msgstr "Alle {count} Std."
#: src/lib/routine-line.ts
msgid "Every {count} min"
msgstr "Alle {count} Min."
#: src/lib/routine-line.ts
msgid "Every {day}, {time}"
msgstr "Jeden {day}, {time}"
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "alle {intervalAmountSelect} {intervalUnitSelect}" msgstr "alle {intervalAmountSelect} {intervalUnitSelect}"
@ -2203,6 +2220,7 @@ msgstr "alle {intervalAmountSelect} {intervalUnitSelect}"
msgid "Every day" msgid "Every day"
msgstr "Jeden Tag" msgstr "Jeden Tag"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Every hour" msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "Modelle vom Server"
msgid "Monthly" msgid "Monthly"
msgstr "Monatlich" msgstr "Monatlich"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr "Monatlich am {day}., {time}"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "More" msgid "More"
msgstr "Mehr" msgstr "Mehr"
@ -3175,6 +3197,10 @@ msgstr "Neuer Skill"
msgid "New space" msgid "New space"
msgstr "Neuer Space" msgstr "Neuer Space"
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr "Nächstes Mal {date}"
#: src/components/onboarding/LookPreviewCarousel.tsx #: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look" msgid "Next look"
msgstr "Nächster Look" msgstr "Nächster Look"
@ -3828,6 +3854,10 @@ msgstr "Unterbrochenen Computer freigeben?"
msgid "Reload" msgid "Reload"
msgstr "Neu laden" msgstr "Neu laden"
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr "Erinnerung"
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx #: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "Skill {0} entfernen"
msgid "Remove webhook" msgid "Remove webhook"
msgstr "Webhook entfernen" msgstr "Webhook entfernen"
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr "Entfernt"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory." msgid "Removed with chat, computer, and memory."
msgstr "Zusammen mit Chat, Computer und Erinnerungen entfernt." msgstr "Zusammen mit Chat, Computer und Erinnerungen entfernt."
@ -4005,6 +4039,7 @@ msgstr "Schlüssel erneuern"
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine" msgid "Routine"
msgstr "Routine" msgstr "Routine"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "Triage läuft…" msgstr "Triage läuft…"
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Erneut versuchen" msgstr "Erneut versuchen"
@ -4773,6 +4809,10 @@ msgstr "Nicht zugewiesen"
msgid "Unavailable" msgid "Unavailable"
msgstr "Nicht verfügbar" msgstr "Nicht verfügbar"
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr "Rückgängig"
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Uninstall" msgid "Uninstall"
msgstr "Deinstallieren" msgstr "Deinstallieren"
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "Update-Fortschritt" msgstr "Update-Fortschritt"
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated" msgid "Updated"
msgstr "Aktualisiert" msgstr "Aktualisiert"
@ -4962,6 +5003,10 @@ msgstr "Webhook"
msgid "Weekdays" msgid "Weekdays"
msgstr "Wochentage" msgstr "Wochentage"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr "Werktags, {time}"
#: src/components/heute/CoachRhythmControl.tsx #: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly" msgid "Weekly"
msgstr "Wöchentlich" msgstr "Wöchentlich"

View File

@ -1861,6 +1861,10 @@ msgstr "Customer support"
msgid "Customize bot avatar" msgid "Customize bot avatar"
msgstr "Customize bot avatar" msgstr "Customize bot avatar"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr "Daily, {time}"
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
msgid "Dark" msgid "Dark"
msgstr "Dark" msgstr "Dark"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "Earlier message" msgstr "Earlier message"
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@ -2194,6 +2199,18 @@ msgstr "Evening check-in"
msgid "Every" msgid "Every"
msgstr "Every" msgstr "Every"
#: src/lib/routine-line.ts
msgid "Every {count} h"
msgstr "Every {count} h"
#: src/lib/routine-line.ts
msgid "Every {count} min"
msgstr "Every {count} min"
#: src/lib/routine-line.ts
msgid "Every {day}, {time}"
msgstr "Every {day}, {time}"
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "every {intervalAmountSelect} {intervalUnitSelect}" msgstr "every {intervalAmountSelect} {intervalUnitSelect}"
@ -2203,6 +2220,7 @@ msgstr "every {intervalAmountSelect} {intervalUnitSelect}"
msgid "Every day" msgid "Every day"
msgstr "Every day" msgstr "Every day"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Every hour" msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "Models from server"
msgid "Monthly" msgid "Monthly"
msgstr "Monthly" msgstr "Monthly"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr "Monthly on day {day}, {time}"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "More" msgid "More"
msgstr "More" msgstr "More"
@ -3175,6 +3197,10 @@ msgstr "New skill"
msgid "New space" msgid "New space"
msgstr "New space" msgstr "New space"
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr "Next {date}"
#: src/components/onboarding/LookPreviewCarousel.tsx #: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look" msgid "Next look"
msgstr "Next look" msgstr "Next look"
@ -3828,6 +3854,10 @@ msgstr "Release interrupted computer?"
msgid "Reload" msgid "Reload"
msgstr "Reload" msgstr "Reload"
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr "Reminder"
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx #: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "Remove skill {0}"
msgid "Remove webhook" msgid "Remove webhook"
msgstr "Remove webhook" msgstr "Remove webhook"
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr "Removed"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory." msgid "Removed with chat, computer, and memory."
msgstr "Removed with chat, computer, and memory." msgstr "Removed with chat, computer, and memory."
@ -4005,6 +4039,7 @@ msgstr "Rotate key"
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine" msgid "Routine"
msgstr "Routine" msgstr "Routine"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "Triage in progress…" msgstr "Triage in progress…"
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Try again" msgstr "Try again"
@ -4773,6 +4809,10 @@ msgstr "Unassigned"
msgid "Unavailable" msgid "Unavailable"
msgstr "Unavailable" msgstr "Unavailable"
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr "Undo"
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Uninstall" msgid "Uninstall"
msgstr "Uninstall" msgstr "Uninstall"
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "Update progress" msgstr "Update progress"
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated" msgid "Updated"
msgstr "Updated" msgstr "Updated"
@ -4962,6 +5003,10 @@ msgstr "Webhook"
msgid "Weekdays" msgid "Weekdays"
msgstr "Weekdays" msgstr "Weekdays"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr "Weekdays, {time}"
#: src/components/heute/CoachRhythmControl.tsx #: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly" msgid "Weekly"
msgstr "Weekly" msgstr "Weekly"

View File

@ -1861,6 +1861,10 @@ msgstr "Atención al cliente"
msgid "Customize bot avatar" msgid "Customize bot avatar"
msgstr "Personalizar avatar del bot" msgstr "Personalizar avatar del bot"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
msgid "Dark" msgid "Dark"
msgstr "Oscuro" msgstr "Oscuro"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "Mensaje anterior" msgstr "Mensaje anterior"
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -2194,6 +2199,18 @@ msgstr ""
msgid "Every" msgid "Every"
msgstr "Cada" msgstr "Cada"
#: src/lib/routine-line.ts
msgid "Every {count} h"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {count} min"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {day}, {time}"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "cada {intervalAmountSelect} {intervalUnitSelect}" msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
@ -2203,6 +2220,7 @@ msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
msgid "Every day" msgid "Every day"
msgstr "Todos los días" msgstr "Todos los días"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Every hour" msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "Modelos del servidor"
msgid "Monthly" msgid "Monthly"
msgstr "Mensual" msgstr "Mensual"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "More" msgid "More"
msgstr "" msgstr ""
@ -3175,6 +3197,10 @@ msgstr "Nueva habilidad"
msgid "New space" msgid "New space"
msgstr "Nuevo space" msgstr "Nuevo space"
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx #: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look" msgid "Next look"
msgstr "" msgstr ""
@ -3828,6 +3854,10 @@ msgstr ""
msgid "Reload" msgid "Reload"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx #: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "Quitar habilidad {0}"
msgid "Remove webhook" msgid "Remove webhook"
msgstr "Quitar webhook" msgstr "Quitar webhook"
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory." msgid "Removed with chat, computer, and memory."
msgstr "Eliminado junto con el chat, la computadora y la memoria." msgstr "Eliminado junto con el chat, la computadora y la memoria."
@ -4005,6 +4039,7 @@ msgstr "Rotar clave"
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine" msgid "Routine"
msgstr "Rutina" msgstr "Rutina"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Intentarlo de nuevo" msgstr "Intentarlo de nuevo"
@ -4773,6 +4809,10 @@ msgstr "Sin asignar"
msgid "Unavailable" msgid "Unavailable"
msgstr "No disponible" msgstr "No disponible"
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Uninstall" msgid "Uninstall"
msgstr "" msgstr ""
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "" msgstr ""
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated" msgid "Updated"
msgstr "Actualizado" msgstr "Actualizado"
@ -4962,6 +5003,10 @@ msgstr "Webhook"
msgid "Weekdays" msgid "Weekdays"
msgstr "Días laborables" msgstr "Días laborables"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx #: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly" msgid "Weekly"
msgstr "" msgstr ""

View File

@ -1861,6 +1861,10 @@ msgstr ""
msgid "Customize bot avatar" msgid "Customize bot avatar"
msgstr "बॉट अवतार अनुकूलित करें" msgstr "बॉट अवतार अनुकूलित करें"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
msgid "Dark" msgid "Dark"
msgstr "डार्क" msgstr "डार्क"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "पुराना संदेश" msgstr "पुराना संदेश"
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -2194,6 +2199,18 @@ msgstr ""
msgid "Every" msgid "Every"
msgstr "हर" msgstr "हर"
#: src/lib/routine-line.ts
msgid "Every {count} h"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {count} min"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {day}, {time}"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "हर {intervalAmountSelect} {intervalUnitSelect}" msgstr "हर {intervalAmountSelect} {intervalUnitSelect}"
@ -2203,6 +2220,7 @@ msgstr "हर {intervalAmountSelect} {intervalUnitSelect}"
msgid "Every day" msgid "Every day"
msgstr "हर दिन" msgstr "हर दिन"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Every hour" msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "सर्वर से मॉडल"
msgid "Monthly" msgid "Monthly"
msgstr "मासिक" msgstr "मासिक"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "More" msgid "More"
msgstr "" msgstr ""
@ -3175,6 +3197,10 @@ msgstr "नई स्किल"
msgid "New space" msgid "New space"
msgstr "" msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx #: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look" msgid "Next look"
msgstr "" msgstr ""
@ -3828,6 +3854,10 @@ msgstr ""
msgid "Reload" msgid "Reload"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx #: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "स्किल {0} हटाएं"
msgid "Remove webhook" msgid "Remove webhook"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory." msgid "Removed with chat, computer, and memory."
msgstr "चैट, कंप्यूटर और मेमोरी सहित हटाया गया।" msgstr "चैट, कंप्यूटर और मेमोरी सहित हटाया गया।"
@ -4005,6 +4039,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine" msgid "Routine"
msgstr "रूटीन" msgstr "रूटीन"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "फिर से कोशिश करें" msgstr "फिर से कोशिश करें"
@ -4773,6 +4809,10 @@ msgstr "असाइन नहीं किया गया"
msgid "Unavailable" msgid "Unavailable"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Uninstall" msgid "Uninstall"
msgstr "" msgstr ""
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "" msgstr ""
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated" msgid "Updated"
msgstr "" msgstr ""
@ -4962,6 +5003,10 @@ msgstr ""
msgid "Weekdays" msgid "Weekdays"
msgstr "कार्यदिवस" msgstr "कार्यदिवस"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx #: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly" msgid "Weekly"
msgstr "" msgstr ""

View File

@ -1861,6 +1861,10 @@ msgstr "고객 지원"
msgid "Customize bot avatar" msgid "Customize bot avatar"
msgstr "봇 아바타 맞춤설정" msgstr "봇 아바타 맞춤설정"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
msgid "Dark" msgid "Dark"
msgstr "다크" msgstr "다크"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "이전 메시지" msgstr "이전 메시지"
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -2194,6 +2199,18 @@ msgstr ""
msgid "Every" msgid "Every"
msgstr "매" msgstr "매"
#: src/lib/routine-line.ts
msgid "Every {count} h"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {count} min"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {day}, {time}"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "{intervalAmountSelect} {intervalUnitSelect}마다" msgstr "{intervalAmountSelect} {intervalUnitSelect}마다"
@ -2203,6 +2220,7 @@ msgstr "{intervalAmountSelect} {intervalUnitSelect}마다"
msgid "Every day" msgid "Every day"
msgstr "매일" msgstr "매일"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Every hour" msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "서버에서 찾은 모델"
msgid "Monthly" msgid "Monthly"
msgstr "매월" msgstr "매월"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "More" msgid "More"
msgstr "" msgstr ""
@ -3175,6 +3197,10 @@ msgstr "새 스킬"
msgid "New space" msgid "New space"
msgstr "새 스페이스" msgstr "새 스페이스"
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx #: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look" msgid "Next look"
msgstr "" msgstr ""
@ -3828,6 +3854,10 @@ msgstr ""
msgid "Reload" msgid "Reload"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx #: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "작업 기술 {0} 삭제"
msgid "Remove webhook" msgid "Remove webhook"
msgstr "웹훅 제거" msgstr "웹훅 제거"
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory." msgid "Removed with chat, computer, and memory."
msgstr "대화, 컴퓨터, 기억과 함께 삭제되었습니다." msgstr "대화, 컴퓨터, 기억과 함께 삭제되었습니다."
@ -4005,6 +4039,7 @@ msgstr "key 교체"
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine" msgid "Routine"
msgstr "자동 실행" msgstr "자동 실행"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "다시 시도" msgstr "다시 시도"
@ -4773,6 +4809,10 @@ msgstr "섹션 없음"
msgid "Unavailable" msgid "Unavailable"
msgstr "사용할 수 없음" msgstr "사용할 수 없음"
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Uninstall" msgid "Uninstall"
msgstr "" msgstr ""
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "" msgstr ""
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated" msgid "Updated"
msgstr "업데이트됨" msgstr "업데이트됨"
@ -4962,6 +5003,10 @@ msgstr "웹훅"
msgid "Weekdays" msgid "Weekdays"
msgstr "주중" msgstr "주중"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx #: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly" msgid "Weekly"
msgstr "" msgstr ""

View File

@ -1861,6 +1861,10 @@ msgstr ""
msgid "Customize bot avatar" msgid "Customize bot avatar"
msgstr "Personalizar avatar do bot" msgstr "Personalizar avatar do bot"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
msgid "Dark" msgid "Dark"
msgstr "Escuro" msgstr "Escuro"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "" msgstr ""
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -2194,6 +2199,18 @@ msgstr ""
msgid "Every" msgid "Every"
msgstr "A cada" msgstr "A cada"
#: src/lib/routine-line.ts
msgid "Every {count} h"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {count} min"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {day}, {time}"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "cada {intervalAmountSelect} {intervalUnitSelect}" msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
@ -2203,6 +2220,7 @@ msgstr "cada {intervalAmountSelect} {intervalUnitSelect}"
msgid "Every day" msgid "Every day"
msgstr "Todos os dias" msgstr "Todos os dias"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Every hour" msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "Modelos do servidor"
msgid "Monthly" msgid "Monthly"
msgstr "Mensalmente" msgstr "Mensalmente"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "More" msgid "More"
msgstr "" msgstr ""
@ -3175,6 +3197,10 @@ msgstr "Nova habilidade"
msgid "New space" msgid "New space"
msgstr "" msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx #: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look" msgid "Next look"
msgstr "" msgstr ""
@ -3828,6 +3854,10 @@ msgstr ""
msgid "Reload" msgid "Reload"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx #: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "Remover habilidade {0}"
msgid "Remove webhook" msgid "Remove webhook"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory." msgid "Removed with chat, computer, and memory."
msgstr "Removido com chat, computador e memória." msgstr "Removido com chat, computador e memória."
@ -4005,6 +4039,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine" msgid "Routine"
msgstr "Rotina" msgstr "Rotina"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Tentar novamente" msgstr "Tentar novamente"
@ -4773,6 +4809,10 @@ msgstr "Não atribuído"
msgid "Unavailable" msgid "Unavailable"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Uninstall" msgid "Uninstall"
msgstr "" msgstr ""
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "" msgstr ""
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated" msgid "Updated"
msgstr "" msgstr ""
@ -4962,6 +5003,10 @@ msgstr ""
msgid "Weekdays" msgid "Weekdays"
msgstr "Dias da semana" msgstr "Dias da semana"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx #: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly" msgid "Weekly"
msgstr "" msgstr ""

View File

@ -1861,6 +1861,10 @@ msgstr "Поддержка клиентов"
msgid "Customize bot avatar" msgid "Customize bot avatar"
msgstr "Настроить аватар бота" msgstr "Настроить аватар бота"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
msgid "Dark" msgid "Dark"
msgstr "Тёмная" msgstr "Тёмная"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "Предыдущее сообщение" msgstr "Предыдущее сообщение"
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -2194,6 +2199,18 @@ msgstr ""
msgid "Every" msgid "Every"
msgstr "Каждые" msgstr "Каждые"
#: src/lib/routine-line.ts
msgid "Every {count} h"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {count} min"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {day}, {time}"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "каждые {intervalAmountSelect} {intervalUnitSelect}" msgstr "каждые {intervalAmountSelect} {intervalUnitSelect}"
@ -2203,6 +2220,7 @@ msgstr "каждые {intervalAmountSelect} {intervalUnitSelect}"
msgid "Every day" msgid "Every day"
msgstr "Каждый день" msgstr "Каждый день"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Every hour" msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "Модели с сервера"
msgid "Monthly" msgid "Monthly"
msgstr "Ежемесячно" msgstr "Ежемесячно"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "More" msgid "More"
msgstr "Ещё" msgstr "Ещё"
@ -3175,6 +3197,10 @@ msgstr "Новый навык"
msgid "New space" msgid "New space"
msgstr "Новое пространство" msgstr "Новое пространство"
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx #: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look" msgid "Next look"
msgstr "" msgstr ""
@ -3828,6 +3854,10 @@ msgstr "Освободить прерванный компьютер?"
msgid "Reload" msgid "Reload"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx #: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "Удалить навык {0}"
msgid "Remove webhook" msgid "Remove webhook"
msgstr "Удалить вебхук" msgstr "Удалить вебхук"
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory." msgid "Removed with chat, computer, and memory."
msgstr "Удалено вместе с чатом, компьютером и памятью." msgstr "Удалено вместе с чатом, компьютером и памятью."
@ -4005,6 +4039,7 @@ msgstr "Сменить ключ"
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine" msgid "Routine"
msgstr "Задача" msgstr "Задача"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "" msgstr ""
@ -4773,6 +4809,10 @@ msgstr "Без раздела"
msgid "Unavailable" msgid "Unavailable"
msgstr "Недоступно" msgstr "Недоступно"
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Uninstall" msgid "Uninstall"
msgstr "Удалить" msgstr "Удалить"
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "Ход обновления" msgstr "Ход обновления"
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated" msgid "Updated"
msgstr "Обновлено" msgstr "Обновлено"
@ -4962,6 +5003,10 @@ msgstr "Вебхук"
msgid "Weekdays" msgid "Weekdays"
msgstr "Будни" msgstr "Будни"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx #: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly" msgid "Weekly"
msgstr "" msgstr ""

View File

@ -1861,6 +1861,10 @@ msgstr ""
msgid "Customize bot avatar" msgid "Customize bot avatar"
msgstr "Bot avatarını özelleştir" msgstr "Bot avatarını özelleştir"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
msgid "Dark" msgid "Dark"
msgstr "Koyu" msgstr "Koyu"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "" msgstr ""
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -2194,6 +2199,18 @@ msgstr ""
msgid "Every" msgid "Every"
msgstr "Her" msgstr "Her"
#: src/lib/routine-line.ts
msgid "Every {count} h"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {count} min"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {day}, {time}"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "her {intervalAmountSelect} {intervalUnitSelect}" msgstr "her {intervalAmountSelect} {intervalUnitSelect}"
@ -2203,6 +2220,7 @@ msgstr "her {intervalAmountSelect} {intervalUnitSelect}"
msgid "Every day" msgid "Every day"
msgstr "Her gün" msgstr "Her gün"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Every hour" msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "Sunucudan gelen modeller"
msgid "Monthly" msgid "Monthly"
msgstr "Aylık" msgstr "Aylık"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "More" msgid "More"
msgstr "" msgstr ""
@ -3175,6 +3197,10 @@ msgstr "Yeni beceri"
msgid "New space" msgid "New space"
msgstr "" msgstr ""
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx #: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look" msgid "Next look"
msgstr "" msgstr ""
@ -3828,6 +3854,10 @@ msgstr ""
msgid "Reload" msgid "Reload"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx #: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "{0} becerisini kaldır"
msgid "Remove webhook" msgid "Remove webhook"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory." msgid "Removed with chat, computer, and memory."
msgstr "Sohbeti, bilgisayarı ve hafızasıyla birlikte kaldırıldı." msgstr "Sohbeti, bilgisayarı ve hafızasıyla birlikte kaldırıldı."
@ -4005,6 +4039,7 @@ msgstr ""
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine" msgid "Routine"
msgstr "Rutin" msgstr "Rutin"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Tekrar dene" msgstr "Tekrar dene"
@ -4773,6 +4809,10 @@ msgstr "Atanmamış"
msgid "Unavailable" msgid "Unavailable"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Uninstall" msgid "Uninstall"
msgstr "" msgstr ""
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "" msgstr ""
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated" msgid "Updated"
msgstr "" msgstr ""
@ -4962,6 +5003,10 @@ msgstr ""
msgid "Weekdays" msgid "Weekdays"
msgstr "Hafta içi" msgstr "Hafta içi"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx #: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly" msgid "Weekly"
msgstr "" msgstr ""

View File

@ -1861,6 +1861,10 @@ msgstr "客户支持"
msgid "Customize bot avatar" msgid "Customize bot avatar"
msgstr "自定义机器人头像" msgstr "自定义机器人头像"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx #: src/pages/AccountSettingsOverlay.tsx
msgid "Dark" msgid "Dark"
msgstr "深色" msgstr "深色"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "更早的消息" msgstr "更早的消息"
#: src/components/UserProfileSettings.tsx #: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -2194,6 +2199,18 @@ msgstr ""
msgid "Every" msgid "Every"
msgstr "每" msgstr "每"
#: src/lib/routine-line.ts
msgid "Every {count} h"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {count} min"
msgstr ""
#: src/lib/routine-line.ts
msgid "Every {day}, {time}"
msgstr ""
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "every {intervalAmountSelect} {intervalUnitSelect}" msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "每 {intervalAmountSelect} {intervalUnitSelect}" msgstr "每 {intervalAmountSelect} {intervalUnitSelect}"
@ -2203,6 +2220,7 @@ msgstr "每 {intervalAmountSelect} {intervalUnitSelect}"
msgid "Every day" msgid "Every day"
msgstr "每天" msgstr "每天"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx #: src/pages/RoutineSchedule.tsx
msgid "Every hour" msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "来自服务器的模型"
msgid "Monthly" msgid "Monthly"
msgstr "每月" msgstr "每月"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "More" msgid "More"
msgstr "" msgstr ""
@ -3175,6 +3197,10 @@ msgstr "新建技能"
msgid "New space" msgid "New space"
msgstr "新建工作区" msgstr "新建工作区"
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx #: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look" msgid "Next look"
msgstr "" msgstr ""
@ -3828,6 +3854,10 @@ msgstr ""
msgid "Reload" msgid "Reload"
msgstr "" msgstr ""
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx #: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "移除技能 {0}"
msgid "Remove webhook" msgid "Remove webhook"
msgstr "移除 Webhook" msgstr "移除 Webhook"
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr ""
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory." msgid "Removed with chat, computer, and memory."
msgstr "已连同对话、电脑和记忆一起移除。" msgstr "已连同对话、电脑和记忆一起移除。"
@ -4005,6 +4039,7 @@ msgstr "轮换密钥"
#: src/pages/RoutineEditor.tsx #: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine" msgid "Routine"
msgstr "例行任务" msgstr "例行任务"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "重试" msgstr "重试"
@ -4773,6 +4809,10 @@ msgstr "未分配"
msgid "Unavailable" msgid "Unavailable"
msgstr "不可用" msgstr "不可用"
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Uninstall" msgid "Uninstall"
msgstr "" msgstr ""
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "" msgstr ""
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated" msgid "Updated"
msgstr "已更新" msgstr "已更新"
@ -4962,6 +5003,10 @@ msgstr "Webhook"
msgid "Weekdays" msgid "Weekdays"
msgstr "工作日" msgstr "工作日"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx #: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly" msgid "Weekly"
msgstr "" msgstr ""

View File

@ -1524,7 +1524,7 @@ export function ShellPage() {
} }
if (!active) return; if (!active) return;
if (routineId && routinesBotId === active.id) { if (routineId && routinesBotId === active.id) {
const routine = routines.find((item) => item.id === routineId); const openRoutine = (routine: Routine | undefined) => {
if (routine) { if (routine) {
setRoutineDraft(draftFromRoutine(routine)); setRoutineDraft(draftFromRoutine(routine));
setRoutineWebhookSecret(null); setRoutineWebhookSecret(null);
@ -1533,6 +1533,22 @@ export function ShellPage() {
} else { } else {
setPanel("computer"); setPanel("computer");
} }
};
const routine = routines.find((item) => item.id === routineId);
if (routine) {
openRoutine(routine);
} else {
// The bot may have set it up after the list was loaded.
const botId = active.id;
void rpc.routines
.list({ botId })
.then((fresh) => {
if (activeBotId.current !== botId) return;
setRoutines(fresh);
openRoutine(fresh.find((item) => item.id === routineId));
})
.catch(() => setPanel("computer"));
}
const next = new URLSearchParams(searchParams); const next = new URLSearchParams(searchParams);
next.delete("routine"); next.delete("routine");
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });

View File

@ -0,0 +1,84 @@
import { useLingui } from "@lingui/react/macro";
import type { RoutineEventBlock } from "@rakazo/contracts";
import { isOneShotRoutineCrons } from "@rakazo/core";
import { Icon } from "@rakazo/ui-web";
import { BellRing, Repeat } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { routineLineSchedule } from "../../lib/routine-line";
import { rpc } from "../../lib/rpc";
const actionClass =
"rounded-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50";
/** A routine the bot set up from the conversation: one quiet line with Edit and Undo. */
export function RoutineEventLine({ block }: { block: RoutineEventBlock }) {
const { i18n, t } = useLingui();
const navigate = useNavigate();
const [undoing, setUndoing] = useState(false);
const [failed, setFailed] = useState(false);
const oneShot = isOneShotRoutineCrons(block.crons);
const schedule = routineLineSchedule(block, i18n.locale || "en");
async function undo() {
setUndoing(true);
setFailed(false);
try {
await rpc.routines.remove({ routineId: block.routineId });
} catch {
setFailed(true);
} finally {
setUndoing(false);
}
}
return (
<div
data-testid="routine-event-line"
className="flex justify-center px-4 py-1.5 text-center text-ui-label text-muted-foreground"
>
<p className="inline-flex max-w-full flex-wrap items-center justify-center gap-x-1.5 gap-y-0.5">
<Icon
icon={oneShot ? BellRing : Repeat}
size="sm"
role="img"
aria-hidden={false}
aria-label={oneShot ? t`Reminder` : t`Routine`}
/>
<span
dir="auto"
className={block.removed ? "line-through" : "font-medium text-foreground/80"}
>
{block.name}
</span>
{schedule ? <span aria-hidden>·</span> : null}
{schedule ? <span>{schedule}</span> : null}
<span aria-hidden>·</span>
{block.removed ? (
<span>{t`Removed`}</span>
) : (
<>
{block.event === "routine.updated" ? <span>{t`Updated`}</span> : null}
{block.event === "routine.updated" ? <span aria-hidden>·</span> : null}
<button
type="button"
className={actionClass}
onClick={() => navigate(`/app/${block.botId}?routine=${block.routineId}`)}
>
{t`Edit`}
</button>
<span aria-hidden>·</span>
<button
type="button"
className={actionClass}
disabled={undoing}
onClick={() => void undo()}
>
{failed ? t`Try again` : t`Undo`}
</button>
</>
)}
</p>
</div>
);
}

View File

@ -4,7 +4,12 @@ import { ChatMarkdown } from "@rakazo/chat-ui/web";
import type { MessageReaction, ThreadMessage } from "@rakazo/contracts"; import type { MessageReaction, ThreadMessage } from "@rakazo/contracts";
import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts"; import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
import type { WorkspaceArtifact } from "@rakazo/core"; import type { WorkspaceArtifact } from "@rakazo/core";
import { isPeerReceiptBlocks, isToolActivityBlock, projectMessageReactions } from "@rakazo/core"; import {
isPeerReceiptBlocks,
isRoutineEventMessage,
isToolActivityBlock,
projectMessageReactions,
} from "@rakazo/core";
import type { GroupAvatarMember } from "@rakazo/ui-web"; import type { GroupAvatarMember } from "@rakazo/ui-web";
import { import {
BotAvatar, BotAvatar,
@ -47,6 +52,7 @@ import {
McpApprovalCard, McpApprovalCard,
} from "./message-cards"; } from "./message-cards";
import { previewMessageText } from "./message-preview"; import { previewMessageText } from "./message-preview";
import { RoutineEventLine } from "./routine-event-line";
import { FALLBACK_BOT_COLOR } from "./shell-types"; import { FALLBACK_BOT_COLOR } from "./shell-types";
export const Transcript = memo(function Transcript({ export const Transcript = memo(function Transcript({
@ -368,6 +374,15 @@ export const Transcript = memo(function Transcript({
{reactionView.visibleMessages.map((message) => { {reactionView.visibleMessages.map((message) => {
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null; if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
const peerReceipt = isPeerReceiptBlocks(message.blocks); const peerReceipt = isPeerReceiptBlocks(message.blocks);
if (isRoutineEventMessage(message.blocks)) {
return (
<div key={message.id} data-message-id={message.id}>
{message.blocks.map((block, index) =>
block.kind === "event" ? <RoutineEventLine key={index} block={block} /> : null,
)}
</div>
);
}
const messageReactions = reactionView.reactions.get(message.id); const messageReactions = reactionView.reactions.get(message.id);
return ( return (
<div <div

View File

@ -725,7 +725,7 @@ export const builtinAgentTools: ConnectorTool[] = [
{ {
name: "schedule_create", name: "schedule_create",
description: description:
'Create a reminder or recurring job for this bot. Use for "remind me in 10 minutes" or "every morning send a joke". Repeats: cron or every/unit (min 1 minute). One-shot: runAt, delayMinutes, or delaySeconds.', 'Create a reminder or recurring job for this bot. Use it whenever the user describes something that should happen later or again, even in passing ("remind me in 10 minutes", "every Sunday I plan the week"): create it right away instead of asking first; the user sees it in the conversation and can undo it. Without a time, use 08:00 for mornings, 19:00 for evenings and 09:00 otherwise. Reusing the name of an active repeating schedule changes that schedule instead of adding another. Repeats: cron or every/unit (min 1 minute). One-shot: runAt, delayMinutes, or delaySeconds.',
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {

View File

@ -299,7 +299,11 @@ describe("bot capabilities in runs", () => {
]), ]),
}, },
chatGroup: { findFirst: vi.fn(async () => null) }, chatGroup: { findFirst: vi.fn(async () => null) },
routine: { findMany: vi.fn(async () => []), create: routineCreate }, routine: {
findMany: vi.fn(async () => []),
count: vi.fn(async () => 0),
create: routineCreate,
},
}, },
); );
expect(results[0]).toMatchObject({ ok: true, routineId: "routine-1", bot: "Atlas" }); expect(results[0]).toMatchObject({ ok: true, routineId: "routine-1", bot: "Atlas" });

View File

@ -2785,6 +2785,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
delayMinutes: args.delayMinutes, delayMinutes: args.delayMinutes,
delaySeconds: args.delaySeconds, delaySeconds: args.delaySeconds,
}, },
notice: { threadId: thread.id, botId: bot.id },
}); });
return finish(created); return finish(created);
} }

View File

@ -0,0 +1,144 @@
import { randomUUID } from "node:crypto";
import type { MessageBlock } from "@rakazo/contracts";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import {
bootstrapUserSpace,
createDb,
ensureMainConversation,
markRoutineNoticesRemoved,
} from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { cancelScheduleFromTool, createScheduleFromTool } from "./schedule-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("routine lines in the conversation (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
const userIds: string[] = [];
async function newConversation() {
const id = `sn${randomUUID().replaceAll("-", "")}`;
userIds.push(id);
await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false });
await prisma.userProfile.create({ data: { userId: id, timeZone: "Europe/Berlin" } });
const conversation = await ensureMainConversation(prisma, id);
const notify = vi.fn(async () => undefined);
const deps = {
prisma,
events: { append: vi.fn(async () => undefined), notify } as unknown as ThreadEvents,
jobs: {
enqueue: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
},
};
const owner = {
spaceId: conversation.spaceId,
botId: conversation.leadBotId,
userId: id,
threadId: conversation.threadId,
notice: { threadId: conversation.threadId, botId: conversation.leadBotId },
};
return { deps, owner, notify, conversation };
}
async function lines(threadId: string) {
const messages = await prisma.message.findMany({
where: { threadId },
orderBy: { seq: "asc" },
select: { role: true, blocks: true },
});
return messages.flatMap((message) =>
(message.blocks as MessageBlock[]).filter((block) => block.kind === "event"),
);
}
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("writes a line, changes the routine of the same name, and marks the lines when it is cancelled", async () => {
const { deps, owner, notify, conversation } = await newConversation();
const created = await createScheduleFromTool(deps, {
...owner,
name: "Wochenplan",
prompt: "Plan the week with me",
schedule: { cron: "0 18 * * 0" },
});
expect(created).toMatchObject({ ok: true, cron: "0 18 * * 0" });
const changed = await createScheduleFromTool(deps, {
...owner,
name: "wochenplan",
prompt: "Plan the week with me on Saturday",
schedule: { cron: "0 10 * * 6" },
});
expect(changed).toMatchObject({ ok: true, updated: true, cron: "0 10 * * 6" });
const routines = await prisma.routine.findMany({ where: { userId: owner.userId } });
expect(routines.filter((routine) => routine.coachKey === null)).toHaveLength(1);
const routine = routines.find((row) => row.name === "Wochenplan")!;
expect(routine).toMatchObject({ crons: ["0 10 * * 6"], timezone: "Europe/Berlin" });
expect(await lines(conversation.threadId)).toEqual([
expect.objectContaining({ event: "routine.created", crons: ["0 18 * * 0"] }),
expect.objectContaining({ event: "routine.updated", crons: ["0 10 * * 6"] }),
]);
expect(notify).toHaveBeenCalledTimes(2);
const thread = await prisma.thread.findUniqueOrThrow({ where: { id: conversation.threadId } });
expect(thread.unread).toBe(false);
const cancelled = await cancelScheduleFromTool(deps, { ...owner, name: "Wochenplan" });
expect(cancelled).toMatchObject({ ok: true });
expect((await lines(conversation.threadId)).map((line) => line.removed)).toEqual([true, true]);
});
it("keeps one-shot reminders of the same name apart", async () => {
const { deps, owner } = await newConversation();
for (const delayMinutes of [10, 20]) {
await createScheduleFromTool(deps, {
...owner,
name: "Call mum",
prompt: "Remind me to call mum",
schedule: { delayMinutes },
});
}
expect(await prisma.routine.count({ where: { userId: owner.userId, name: "Call mum" } })).toBe(
2,
);
});
it("does nothing for a routine without a line", async () => {
const { owner } = await newConversation();
expect(
await markRoutineNoticesRemoved(prisma, {
id: "missing",
botId: owner.botId,
noticeMessageId: null,
}),
).toBeNull();
});
});

View File

@ -7,6 +7,7 @@ import {
filterBuiltinToolsForThread, filterBuiltinToolsForThread,
isOneShotRoutineCron, isOneShotRoutineCron,
listSchedulesFromTool, listSchedulesFromTool,
MAX_ACTIVE_ROUTINES_PER_BOT,
resolveScheduleOwner, resolveScheduleOwner,
resolveScheduleTiming, resolveScheduleTiming,
SCHEDULE_TOOL_NAMES, SCHEDULE_TOOL_NAMES,
@ -169,7 +170,10 @@ describe("schedule tool persistence", () => {
nextRunAt: args.data.nextRunAt, nextRunAt: args.data.nextRunAt,
})); }));
const deps = { const deps = {
prisma: { routine: { create }, userProfile: { findUnique: async () => null } }, prisma: {
routine: { create, findFirst: async () => null, count: async () => 0 },
userProfile: { findUnique: async () => null },
},
events: { append }, events: { append },
jobs: { enqueue, cancel: vi.fn(async () => undefined) }, jobs: { enqueue, cancel: vi.fn(async () => undefined) },
} as unknown as Parameters<typeof createScheduleFromTool>[0]; } as unknown as Parameters<typeof createScheduleFromTool>[0];
@ -202,6 +206,36 @@ describe("schedule tool persistence", () => {
expect(enqueue).toHaveBeenCalledOnce(); expect(enqueue).toHaveBeenCalledOnce();
}); });
it("refuses another schedule once the bot has the maximum active", async () => {
const create = vi.fn();
const deps = {
prisma: {
routine: {
create,
findFirst: async () => null,
count: async () => MAX_ACTIVE_ROUTINES_PER_BOT,
},
userProfile: { findUnique: async () => null },
},
events: { append: vi.fn(async () => undefined) },
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
} as unknown as Parameters<typeof createScheduleFromTool>[0];
const result = await createScheduleFromTool(deps, {
spaceId: "ws-1",
botId: "bot-1",
userId: "user-1",
threadId: "thread-1",
name: "One more",
prompt: "Do it",
schedule: { cron: "0 9 * * *" },
});
expect(result).toEqual({
error: `This bot already has ${MAX_ACTIVE_ROUTINES_PER_BOT} active schedules. Cancel one before adding another.`,
});
expect(create).not.toHaveBeenCalled();
});
it("defaults the routine time zone to the user's and lets an explicit zone win", async () => { it("defaults the routine time zone to the user's and lets an explicit zone win", async () => {
const create = vi.fn(async (args: { data: Record<string, unknown> }) => ({ const create = vi.fn(async (args: { data: Record<string, unknown> }) => ({
id: "routine-1", id: "routine-1",
@ -209,7 +243,10 @@ describe("schedule tool persistence", () => {
})); }));
const findUnique = vi.fn(async () => ({ timeZone: "America/New_York" })); const findUnique = vi.fn(async () => ({ timeZone: "America/New_York" }));
const deps = { const deps = {
prisma: { routine: { create }, userProfile: { findUnique } }, prisma: {
routine: { create, findFirst: async () => null, count: async () => 0 },
userProfile: { findUnique },
},
events: { append: vi.fn(async () => undefined) }, events: { append: vi.fn(async () => undefined) },
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
} as unknown as Parameters<typeof createScheduleFromTool>[0]; } as unknown as Parameters<typeof createScheduleFromTool>[0];
@ -250,7 +287,7 @@ describe("schedule tool persistence", () => {
})); }));
const deps = { const deps = {
prisma: { prisma: {
routine: { create }, routine: { create, findFirst: async () => null, count: async () => 0 },
userProfile: { findUnique: vi.fn(async () => ({ timeZone: "Mars/Olympus" })) }, userProfile: { findUnique: vi.fn(async () => ({ timeZone: "Mars/Olympus" })) },
}, },
events: { append: vi.fn(async () => undefined) }, events: { append: vi.fn(async () => undefined) },
@ -278,6 +315,8 @@ describe("schedule tool persistence", () => {
prisma: { prisma: {
userProfile: { findUnique: async () => null }, userProfile: { findUnique: async () => null },
routine: { routine: {
findFirst: async () => null,
count: async () => 0,
create: vi.fn(async () => ({ create: vi.fn(async () => ({
id: "routine-1", id: "routine-1",
name: "Morning joke", name: "Morning joke",
@ -316,6 +355,8 @@ describe("schedule tool persistence", () => {
prisma: { prisma: {
userProfile: { findUnique: async () => null }, userProfile: { findUnique: async () => null },
routine: { routine: {
findFirst: async () => null,
count: async () => 0,
create: vi.fn(async () => ({ create: vi.fn(async () => ({
id: "routine-1", id: "routine-1",
name: "Morning joke", name: "Morning joke",
@ -354,6 +395,8 @@ describe("schedule tool persistence", () => {
prisma: { prisma: {
userProfile: { findUnique: async () => null }, userProfile: { findUnique: async () => null },
routine: { routine: {
findFirst: async () => null,
count: async () => 0,
create: vi.fn(async () => ({ create: vi.fn(async () => ({
id: "routine-1", id: "routine-1",
name: "Morning joke", name: "Morning joke",
@ -397,6 +440,8 @@ describe("schedule tool persistence", () => {
prisma: { prisma: {
userProfile: { findUnique: async () => null }, userProfile: { findUnique: async () => null },
routine: { routine: {
findFirst: async () => null,
count: async () => 0,
create: vi.fn(async () => ({ create: vi.fn(async () => ({
id: "routine-1", id: "routine-1",
name: "Morning joke", name: "Morning joke",
@ -631,7 +676,10 @@ describe("scheduling on behalf of another bot", () => {
const append = vi.fn(async () => undefined); const append = vi.fn(async () => undefined);
const deps = { const deps = {
// No profile time zone: the routine falls back to UTC. // No profile time zone: the routine falls back to UTC.
prisma: { routine: { create }, userProfile: { findUnique: async () => null } }, prisma: {
routine: { create, findFirst: async () => null, count: async () => 0 },
userProfile: { findUnique: async () => null },
},
events: { append }, events: { append },
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
} as unknown as Parameters<typeof createScheduleFromTool>[0]; } as unknown as Parameters<typeof createScheduleFromTool>[0];

View File

@ -1,5 +1,6 @@
import type { ConnectorTool, JobPublisher } from "@rakazo/adapter-kit"; import type { ConnectorTool, JobPublisher } from "@rakazo/adapter-kit";
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
import type { RoutineEventBlock } from "@rakazo/contracts";
import { import {
cronFromPreset, cronFromPreset,
isOneShotRoutineCron, isOneShotRoutineCron,
@ -8,6 +9,8 @@ import {
ONCE_ROUTINE_CRON, ONCE_ROUTINE_CRON,
} from "@rakazo/core"; } from "@rakazo/core";
import type { PrismaClient, ThreadEvents } from "@rakazo/db"; import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { markRoutineNoticesRemoved, postRoutineNotice } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import { loadUserTimeZone } from "./current-time.js"; import { loadUserTimeZone } from "./current-time.js";
export { isOneShotRoutineCron, ONCE_ROUTINE_CRON }; export { isOneShotRoutineCron, ONCE_ROUTINE_CRON };
@ -276,7 +279,20 @@ function pickScheduleTarget<T extends { id: string; name: string; spaceId: strin
return { error: `No bot named ${ref}.` }; return { error: `No bot named ${ref}.` };
} }
/** Persist and enqueue a bot routine that wakes in the originating thread. */ /** More active routines than one bot can sensibly keep track of. */
export const MAX_ACTIVE_ROUTINES_PER_BOT = 50;
/** Where the bot that set a routine up writes its timeline line. */
export interface ScheduleNotice {
threadId: string;
botId: string;
}
/**
* Persist and enqueue a bot routine that wakes in the originating thread. A repeating schedule
* with the name of one of the bot's active repeating routines changes that routine instead of
* adding a second one. With `notice`, the conversation gets a quiet line about it.
*/
export async function createScheduleFromTool( export async function createScheduleFromTool(
deps: ScheduleToolDeps, deps: ScheduleToolDeps,
input: ScheduleOwner & { input: ScheduleOwner & {
@ -284,6 +300,7 @@ export async function createScheduleFromTool(
prompt: string; prompt: string;
timezone?: string; timezone?: string;
schedule: Record<string, unknown>; schedule: Record<string, unknown>;
notice?: ScheduleNotice;
}, },
) { ) {
const name = input.name.trim(); const name = input.name.trim();
@ -298,6 +315,36 @@ export async function createScheduleFromTool(
const resolved = resolveScheduleTiming(input.schedule, timezone); const resolved = resolveScheduleTiming(input.schedule, timezone);
if (!resolved.ok) return { error: resolved.error }; if (!resolved.ok) return { error: resolved.error };
if (!resolved.oneShot) {
const existing = await deps.prisma.routine.findFirst({
where: {
botId: input.botId,
userId: input.userId,
active: true,
coachKey: null,
name: { equals: name, mode: "insensitive" },
NOT: { crons: { has: ONCE_ROUTINE_CRON } },
},
});
if (existing) {
return updateScheduleFromTool(deps, input, existing.id, {
prompt,
timezone,
cron: resolved.cron,
nextRunAt: resolved.nextRunAt,
});
}
}
const activeCount = await deps.prisma.routine.count({
where: { botId: input.botId, active: true },
});
if (activeCount >= MAX_ACTIVE_ROUTINES_PER_BOT) {
return {
error: `This bot already has ${MAX_ACTIVE_ROUTINES_PER_BOT} active schedules. Cancel one before adding another.`,
};
}
const row = await deps.prisma.routine.create({ const row = await deps.prisma.routine.create({
data: { data: {
spaceId: input.spaceId, spaceId: input.spaceId,
@ -341,6 +388,16 @@ export async function createScheduleFromTool(
} catch { } catch {
// Match routines.create: the reminder is live even if the thread signal fails. // Match routines.create: the reminder is live even if the thread signal fails.
} }
await postNotice(deps, input.notice, input.spaceId, {
kind: "event",
event: "routine.created",
routineId: row.id,
botId: input.botId,
name: row.name,
crons: row.crons,
timezone: row.timezone,
nextRunAt: row.nextRunAt?.toISOString() ?? null,
});
return { return {
ok: true as const, ok: true as const,
@ -353,6 +410,93 @@ export async function createScheduleFromTool(
}; };
} }
async function updateScheduleFromTool(
deps: ScheduleToolDeps,
input: ScheduleOwner & { notice?: ScheduleNotice },
routineId: string,
change: { prompt: string; timezone: string; cron: string; nextRunAt: Date },
) {
const row = await deps.prisma.routine.update({
where: { id: routineId },
data: {
prompt: change.prompt,
timezone: change.timezone,
crons: [change.cron],
nextRunAt: change.nextRunAt,
},
});
try {
await deps.jobs.enqueue(routineWakeupJob(row.id, change.nextRunAt));
} catch {
// The job reconciler re-enqueues active routines whose wakeup is missing.
}
try {
await deps.events.append({
spaceId: row.spaceId,
threadId: row.threadId ?? input.threadId,
botId: row.botId,
type: "routine.updated",
payload: { name: row.name },
});
} catch {
// The routine is updated even if the thread signal fails.
}
await postNotice(deps, input.notice, input.spaceId, {
kind: "event",
event: "routine.updated",
routineId: row.id,
botId: row.botId,
name: row.name,
crons: row.crons,
timezone: row.timezone,
nextRunAt: row.nextRunAt?.toISOString() ?? null,
});
return {
ok: true as const,
updated: true as const,
routineId: row.id,
name: row.name,
cron: row.crons[0],
nextRunAt: row.nextRunAt?.toISOString() ?? null,
oneShot: false,
...(input.createdByBotId ? { bot: input.botName } : {}),
};
}
/** The line is a courtesy: a routine that is live stays live when the line cannot be written. */
async function postNotice(
deps: ScheduleToolDeps,
notice: ScheduleNotice | undefined,
spaceId: string,
block: RoutineEventBlock,
) {
if (!notice) return;
try {
const posted = await postRoutineNotice(deps.prisma, {
spaceId,
threadId: notice.threadId,
botId: notice.botId,
block,
});
await deps.events.notify(notice.threadId, posted.seq);
} catch (error) {
getLogger().error("routine notice", error);
}
}
/** Mark the timeline line of a removed routine; the removal itself has already happened. */
export async function markRemovedRoutineNotice(
deps: Pick<ScheduleToolDeps, "prisma" | "events">,
routine: { id: string; botId: string; noticeMessageId: string | null },
) {
try {
const marked = await markRoutineNoticesRemoved(deps.prisma, routine);
if (marked) await deps.events.notify(marked.threadId, marked.seq);
} catch (error) {
getLogger().error("routine notice removal", error);
}
}
/** /**
* Routines a bot manages: its own (in a group only the ones of that thread) and the ones it * Routines a bot manages: its own (in a group only the ones of that thread) and the ones it
* scheduled for other bots, wherever they wake. * scheduled for other bots, wherever they wake.
@ -429,5 +573,6 @@ export async function cancelScheduleFromTool(
await deps.prisma.routine.delete({ where: { id: existing.id } }); await deps.prisma.routine.delete({ where: { id: existing.id } });
await deps.jobs.cancel(routineJobKey(existing.id)); await deps.jobs.cancel(routineJobKey(existing.id));
await markRemovedRoutineNotice(deps, existing);
return { ok: true as const, routineId: existing.id, name: existing.name }; return { ok: true as const, routineId: existing.id, name: existing.name };
} }

View File

@ -260,6 +260,21 @@ export function inferScript(
} }
const coach = inferCoachScript(prompt); const coach = inferCoachScript(prompt);
if (coach) return coach; if (coach) return coach;
// Said in passing: the bot sets the routine up without asking first.
if (lower.includes("every sunday i plan the week")) {
return [
{
assistant: "i will remind you every sunday at 6 pm.",
toolCalls: [
{
name: "schedule_create",
args: { name: "Weekly plan", prompt: "Plan the week with me.", cron: "0 18 * * 0" },
},
],
complete: true,
},
];
}
if (lower.includes("delegate to the bot named")) { if (lower.includes("delegate to the bot named")) {
const name = namedBot(prompt) ?? "Researcher"; const name = namedBot(prompt) ?? "Researcher";
const brief = const brief =

View File

@ -173,6 +173,24 @@ export const IntakeBlockSchema = z.discriminatedUnion("kind", [
]); ]);
export type IntakeBlock = z.infer<typeof IntakeBlockSchema>; export type IntakeBlock = z.infer<typeof IntakeBlockSchema>;
/**
* A quiet line in the timeline for a routine the bot set up from the conversation. `removed` is
* set once the routine is gone, wherever it was removed.
*/
export const RoutineEventBlockSchema = z.object({
kind: z.literal("event"),
event: z.enum(["routine.created", "routine.updated"]),
routineId: Id,
/** The bot the routine belongs to; it may differ from the bot that wrote the line. */
botId: Id,
name: z.string(),
crons: z.array(z.string()),
timezone: z.string(),
nextRunAt: z.string().nullable(),
removed: z.boolean().optional(),
});
export type RoutineEventBlock = z.infer<typeof RoutineEventBlockSchema>;
export const MessageBlock = z.discriminatedUnion("kind", [ export const MessageBlock = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text"), text: z.string() }), z.object({ kind: z.literal("text"), text: z.string() }),
z.object({ z.object({
@ -182,6 +200,7 @@ export const MessageBlock = z.discriminatedUnion("kind", [
AskBlockSchema, AskBlockSchema,
ChoiceBlockSchema, ChoiceBlockSchema,
IntakeNoteBlockSchema, IntakeNoteBlockSchema,
RoutineEventBlockSchema,
z.object({ z.object({
/** Inline app authorization card (Composio-backed): logo, name, one-line /** Inline app authorization card (Composio-backed): logo, name, one-line
description, and an Authorize button that flips to connected. */ description, and an Authorize button that flips to connected. */
@ -386,6 +405,6 @@ export function canReactToThreadMessage(message: Pick<ThreadMessage, "id" | "blo
return ( return (
!message.id.startsWith("progress:") && !message.id.startsWith("progress:") &&
!message.id.startsWith("subagent:") && !message.id.startsWith("subagent:") &&
!message.blocks.some((block) => block.kind === "channel_message") !message.blocks.some((block) => block.kind === "channel_message" || block.kind === "event")
); );
} }

View File

@ -6,6 +6,7 @@ import {
isAllowedAttachmentMimeType, isAllowedAttachmentMimeType,
isAttachmentImageMimeType, isAttachmentImageMimeType,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { routineEventHistoryText } from "./routine-events.js";
export class AttachmentValidationError extends Error { export class AttachmentValidationError extends Error {
constructor(message: string) { constructor(message: string) {
@ -137,6 +138,7 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
if (block.kind === "task") { if (block.kind === "task") {
return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`; return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`;
} }
if (block.kind === "event") return routineEventHistoryText(block);
if (block.kind === "handoff") { if (block.kind === "handoff") {
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`; return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
} }

View File

@ -35,6 +35,7 @@ export * from "./model-providers.js";
export * from "./oo-avatar.js"; export * from "./oo-avatar.js";
export * from "./quiet-hours.js"; export * from "./quiet-hours.js";
export * from "./response-bytes.js"; export * from "./response-bytes.js";
export * from "./routine-events.js";
export * from "./run-state.js"; export * from "./run-state.js";
export * from "./sandbox-command.js"; export * from "./sandbox-command.js";
export * from "./screen-lease.js"; export * from "./screen-lease.js";

View File

@ -0,0 +1,82 @@
import type { MessageBlock, RoutineEventBlock } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import { blocksToAgentHistoryText } from "./attachments.js";
import {
isRoutineEventMessage,
markRoutineEventsRemoved,
routineScheduleShape,
} from "./routine-events.js";
const line: RoutineEventBlock = {
kind: "event",
event: "routine.created",
routineId: "routine-1",
botId: "bot-1",
name: "Weekly plan",
crons: ["0 18 * * 1"],
timezone: "Europe/Berlin",
nextRunAt: "2026-10-05T16:00:00.000Z",
};
describe("routine lines", () => {
it("are messages of their own", () => {
expect(isRoutineEventMessage([line])).toBe(true);
expect(isRoutineEventMessage([line, { kind: "text", text: "Done." }])).toBe(false);
expect(isRoutineEventMessage([])).toBe(false);
});
it("are marked removed once and leave other routines alone", () => {
const other: MessageBlock = { ...line, routineId: "routine-2" };
const marked = markRoutineEventsRemoved([line, other], "routine-1");
expect(marked).toEqual([{ ...line, removed: true }, other]);
expect(markRoutineEventsRemoved(marked!, "routine-1")).toBeNull();
expect(markRoutineEventsRemoved([other], "routine-1")).toBeNull();
});
it("tell a later turn what was set up, changed or removed", () => {
expect(blocksToAgentHistoryText([line])).toBe(
"[routine set up: Weekly plan (Every Monday at 6:00 PM, Europe/Berlin)]",
);
expect(
blocksToAgentHistoryText([
{ ...line, event: "routine.updated", crons: ["@once"], name: "Call mum" },
]),
).toBe("[routine updated: Call mum (once at 2026-10-05T16:00:00.000Z)]");
expect(blocksToAgentHistoryText([{ ...line, removed: true }])).toBe(
"[routine removed: Weekly plan]",
);
});
});
describe("routineScheduleShape", () => {
it("reads the schedules people say out loud", () => {
expect(routineScheduleShape(["@once"])).toEqual({ kind: "once" });
expect(routineScheduleShape(["0 8 * * *"])).toEqual({ kind: "daily", hour: 8, minute: 0 });
expect(routineScheduleShape(["30 7 * * 1-5"])).toEqual({
kind: "weekdays",
hour: 7,
minute: 30,
});
expect(routineScheduleShape(["0 18 * * 7"])).toEqual({
kind: "weekly",
weekday: 0,
hour: 18,
minute: 0,
});
expect(routineScheduleShape(["0 9 1 * *"])).toEqual({
kind: "monthly",
day: 1,
hour: 9,
minute: 0,
});
expect(routineScheduleShape(["*/15 * * * *"])).toEqual({ kind: "minutes", every: 15 });
expect(routineScheduleShape(["0 */2 * * *"])).toEqual({ kind: "hours", every: 2 });
expect(routineScheduleShape(["0 * * * *"])).toEqual({ kind: "hourly" });
});
it("leaves everything else to the next run", () => {
for (const crons of [["0 9 * * 1,3"], ["0 9 * 1 *"], ["0 9 * * *", "0 18 * * *"], ["nope"]]) {
expect(routineScheduleShape(crons)).toEqual({ kind: "other" });
}
});
});

View File

@ -0,0 +1,72 @@
import type { MessageBlock, RoutineEventBlock } from "@rakazo/contracts";
import { formatCron, isOneShotRoutineCrons } from "./cron.js";
/** A message that is only timeline lines, shown without a speaker, bubble or reactions. */
export function isRoutineEventMessage(blocks: readonly MessageBlock[]): boolean {
return blocks.length > 0 && blocks.every((block) => block.kind === "event");
}
/** The blocks with every line about `routineId` marked removed, or null when nothing changes. */
export function markRoutineEventsRemoved(
blocks: readonly MessageBlock[],
routineId: string,
): MessageBlock[] | null {
let changed = false;
const next = blocks.map((block) => {
if (block.kind !== "event" || block.routineId !== routineId || block.removed) return block;
changed = true;
return { ...block, removed: true };
});
return changed ? next : null;
}
/** How a later turn reads the line, so the bot knows what it set up and what is gone. */
export function routineEventHistoryText(block: RoutineEventBlock): string {
if (block.removed) return `[routine removed: ${block.name}]`;
const verb = block.event === "routine.updated" ? "updated" : "set up";
const schedule = isOneShotRoutineCrons(block.crons)
? `once at ${block.nextRunAt ?? "an unknown time"}`
: `${block.crons.map(formatCron).join(", ")}, ${block.timezone}`;
return `[routine ${verb}: ${block.name} (${schedule})]`;
}
/** The parts of a routine schedule a one-line summary needs; clients word it in their locale. */
export type RoutineScheduleShape =
| { kind: "once" }
| { kind: "hourly" }
| { kind: "minutes" | "hours"; every: number }
| { kind: "daily" | "weekdays"; hour: number; minute: number }
| { kind: "weekly"; weekday: number; hour: number; minute: number }
| { kind: "monthly"; day: number; hour: number; minute: number }
| { kind: "other" };
const CLOCK_FIELD = /^\d{1,2}$/;
export function routineScheduleShape(crons: readonly string[]): RoutineScheduleShape {
if (isOneShotRoutineCrons([...crons])) return { kind: "once" };
if (crons.length !== 1) return { kind: "other" };
const parts = crons[0]!.trim().split(/\s+/);
if (parts.length !== 5) return { kind: "other" };
const [minute, hour, day, month, weekday] = parts as [string, string, string, string, string];
if (month !== "*") return { kind: "other" };
const step = (field: string) => /^\*\/(\d+)$/.exec(field)?.[1];
if (day === "*" && weekday === "*") {
if (minute === "0" && hour === "*") return { kind: "hourly" };
const minuteStep = step(minute);
if (minuteStep && hour === "*") return { kind: "minutes", every: Number(minuteStep) };
const hourStep = step(hour);
if (minute === "0" && hourStep) return { kind: "hours", every: Number(hourStep) };
}
if (!CLOCK_FIELD.test(minute) || !CLOCK_FIELD.test(hour)) return { kind: "other" };
const clock = { hour: Number(hour), minute: Number(minute) };
if (clock.hour > 23 || clock.minute > 59) return { kind: "other" };
if (day === "*" && weekday === "*") return { kind: "daily", ...clock };
if (day === "*" && weekday === "1-5") return { kind: "weekdays", ...clock };
if (day === "*" && /^[0-7]$/.test(weekday)) {
return { kind: "weekly", weekday: Number(weekday) % 7, ...clock };
}
if (weekday === "*" && /^([1-9]|[12]\d|3[01])$/.test(day)) {
return { kind: "monthly", day: Number(day), ...clock };
}
return { kind: "other" };
}

View File

@ -0,0 +1,3 @@
-- A routine the bot set up from the conversation remembers its timeline line, so removing the
-- routine anywhere can mark that line removed.
ALTER TABLE "routines" ADD COLUMN "noticeMessageId" TEXT;

View File

@ -779,6 +779,8 @@ model Routine {
messageProvider String? messageProvider String?
/// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise. /// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise.
coachKey String? coachKey String?
/// The latest timeline line the bot wrote about this routine; marked removed with it.
noticeMessageId String?
lastRunAt DateTime? lastRunAt DateTime?
nextRunAt DateTime? nextRunAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -18,6 +18,7 @@ export * from "./project-goals.js";
export * from "./project-workspace.js"; export * from "./project-workspace.js";
export * from "./push-subscriptions.js"; export * from "./push-subscriptions.js";
export * from "./repos.js"; export * from "./repos.js";
export * from "./routine-notices.js";
export * from "./scope.js"; export * from "./scope.js";
export * from "./spaces.js"; export * from "./spaces.js";
export * from "./studio-agent.js"; export * from "./studio-agent.js";

View File

@ -0,0 +1,84 @@
import type { RoutineEventBlock } from "@rakazo/contracts";
import { markRoutineEventsRemoved } from "@rakazo/core";
import type { Prisma, PrismaClient } from "./client.js";
import { appendEventInTransaction } from "./events.js";
import { createThreadMessageInTransaction } from "./messages.js";
export interface RoutineNoticeInput {
spaceId: string;
threadId: string;
/** The bot that set the routine up and writes the line. */
botId: string;
block: RoutineEventBlock;
}
/** Write the timeline line for a routine the bot set up and remember it on the routine. */
export async function postRoutineNotice(
prisma: PrismaClient,
input: RoutineNoticeInput,
): Promise<{ messageId: string; seq: number }> {
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const message = await createThreadMessageInTransaction(tx, {
threadId: input.threadId,
role: "bot",
blocks: [input.block],
botId: input.botId,
markUnread: false,
});
await tx.routine.updateMany({
where: { id: input.block.routineId },
data: { noticeMessageId: message.id },
});
const event = await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "bot", blocks: [input.block] },
});
return { messageId: message.id, seq: event.seq };
});
}
/**
* Mark every line about a removed routine in the thread of its latest line. Returns the thread and
* event seq to notify, or null when the routine had no line or nothing changed.
*/
export async function markRoutineNoticesRemoved(
prisma: PrismaClient,
routine: { id: string; botId: string; noticeMessageId: string | null },
): Promise<{ threadId: string; seq: number } | null> {
if (!routine.noticeMessageId) return null;
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const notice = await tx.message.findUnique({
where: { id: routine.noticeMessageId! },
select: { threadId: true, thread: { select: { spaceId: true } } },
});
if (!notice) return null;
const messages = await tx.message.findMany({
where: {
threadId: notice.threadId,
blocks: { array_contains: [{ kind: "event", routineId: routine.id }] },
},
select: { id: true, role: true, botId: true, blocks: true },
});
let seq: number | null = null;
for (const message of messages) {
const blocks = markRoutineEventsRemoved(message.blocks as RoutineEventBlock[], routine.id);
if (!blocks) continue;
await tx.message.update({
where: { id: message.id },
data: { blocks: blocks as Prisma.InputJsonValue },
});
const event = await appendEventInTransaction(tx, {
spaceId: notice.thread.spaceId,
threadId: notice.threadId,
botId: message.botId ?? routine.botId,
type: "thread.message.updated",
payload: { messageId: message.id, role: message.role, blocks },
});
seq = event.seq;
}
return seq === null ? null : { threadId: notice.threadId, seq };
});
}