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,
issueMessagingLinkCode,
listSpaceUserBotIds,
markRoutineNoticesRemoved,
newestModelCredentialOrder,
newestVoiceCredentialOrder,
ownMainAgentsBlockingSpaceDeletion,
@ -2909,6 +2910,8 @@ export function createRouter(deps: RouterDeps) {
if (!existing) throw new IsolationError();
await deps.prisma.routine.delete({ where: { id: 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 };
}),
testRun: authed.routines.testRun.handler(async ({ context, input }) => {

View File

@ -19,6 +19,7 @@ import {
cloudAgentHttpsUrl,
deriveWorkspaceFocus,
isApprovalAskBlock,
isRoutineEventMessage,
isRunTerminalEvent,
isSecretAskBlock,
latestAnswerableAskMessageId,
@ -67,6 +68,7 @@ import {
type MarkdownArtifactPreviewTarget,
} from "../components/markdown-artifact-preview";
import { NativeSymbol } from "../components/native-symbol";
import { RoutineEventLine } from "../components/routine-event-line";
import type {
MobileBot,
MobileGroup,
@ -2400,6 +2402,15 @@ const MessageBubble = memo(function MessageBubble({
</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");
if (handoff) {
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;
}
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 === "file") {
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} (unsupported type)": "{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": "超高",
Low: "低",
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 === "bot_message_sent" ||
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…",
"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.",
"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"
msgstr "Bot-Avatar anpassen"
#: src/lib/routine-line.ts
msgid "Daily, {time}"
msgstr "Täglich, {time}"
#: src/pages/AccountSettingsOverlay.tsx
msgid "Dark"
msgstr "Dunkel"
@ -2129,6 +2133,7 @@ msgid "Earlier message"
msgstr "Frühere Nachricht"
#: src/components/UserProfileSettings.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Edit"
msgstr "Bearbeiten"
@ -2194,6 +2199,18 @@ msgstr "Abend-Check-in"
msgid "Every"
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
msgid "every {intervalAmountSelect} {intervalUnitSelect}"
msgstr "alle {intervalAmountSelect} {intervalUnitSelect}"
@ -2203,6 +2220,7 @@ msgstr "alle {intervalAmountSelect} {intervalUnitSelect}"
msgid "Every day"
msgstr "Jeden Tag"
#: src/lib/routine-line.ts
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every hour"
@ -3053,6 +3071,10 @@ msgstr "Modelle vom Server"
msgid "Monthly"
msgstr "Monatlich"
#: src/lib/routine-line.ts
msgid "Monthly on day {day}, {time}"
msgstr "Monatlich am {day}., {time}"
#: src/pages/shell/transcript.tsx
msgid "More"
msgstr "Mehr"
@ -3175,6 +3197,10 @@ msgstr "Neuer Skill"
msgid "New space"
msgstr "Neuer Space"
#: src/lib/routine-line.ts
msgid "Next {date}"
msgstr "Nächstes Mal {date}"
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Next look"
msgstr "Nächster Look"
@ -3828,6 +3854,10 @@ msgstr "Unterbrochenen Computer freigeben?"
msgid "Reload"
msgstr "Neu laden"
#: src/pages/shell/routine-event-line.tsx
msgid "Reminder"
msgstr "Erinnerung"
#: src/components/ApprovalRulesSettings.tsx
#: src/components/MediaSettings.tsx
#: src/pages/PluginsOverlay.tsx
@ -3866,6 +3896,10 @@ msgstr "Skill {0} entfernen"
msgid "Remove webhook"
msgstr "Webhook entfernen"
#: src/pages/shell/routine-event-line.tsx
msgid "Removed"
msgstr "Entfernt"
#: src/pages/shell/transcript.tsx
msgid "Removed with chat, computer, and memory."
msgstr "Zusammen mit Chat, Computer und Erinnerungen entfernt."
@ -4005,6 +4039,7 @@ msgstr "Schlüssel erneuern"
#: src/pages/RoutineEditor.tsx
#: src/pages/Shell.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Routine"
msgstr "Routine"
@ -4754,6 +4789,7 @@ msgid "Triage in progress…"
msgstr "Triage läuft…"
#: src/pages/Onboarding.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Try again"
msgstr "Erneut versuchen"
@ -4773,6 +4809,10 @@ msgstr "Nicht zugewiesen"
msgid "Unavailable"
msgstr "Nicht verfügbar"
#: src/pages/shell/routine-event-line.tsx
msgid "Undo"
msgstr "Rückgängig"
#: src/pages/PluginsOverlay.tsx
msgid "Uninstall"
msgstr "Deinstallieren"
@ -4829,6 +4869,7 @@ msgid "Update progress"
msgstr "Update-Fortschritt"
#: src/components/SoftwareUpdateSection.tsx
#: src/pages/shell/routine-event-line.tsx
msgid "Updated"
msgstr "Aktualisiert"
@ -4962,6 +5003,10 @@ msgstr "Webhook"
msgid "Weekdays"
msgstr "Wochentage"
#: src/lib/routine-line.ts
msgid "Weekdays, {time}"
msgstr "Werktags, {time}"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr "Wöchentlich"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1524,14 +1524,30 @@ export function ShellPage() {
}
if (!active) return;
if (routineId && routinesBotId === active.id) {
const openRoutine = (routine: Routine | undefined) => {
if (routine) {
setRoutineDraft(draftFromRoutine(routine));
setRoutineWebhookSecret(null);
setEditingRoutine(routine);
setPanel("routine");
} else {
setPanel("computer");
}
};
const routine = routines.find((item) => item.id === routineId);
if (routine) {
setRoutineDraft(draftFromRoutine(routine));
setRoutineWebhookSecret(null);
setEditingRoutine(routine);
setPanel("routine");
openRoutine(routine);
} else {
setPanel("computer");
// 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);
next.delete("routine");

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 { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
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 {
BotAvatar,
@ -47,6 +52,7 @@ import {
McpApprovalCard,
} from "./message-cards";
import { previewMessageText } from "./message-preview";
import { RoutineEventLine } from "./routine-event-line";
import { FALLBACK_BOT_COLOR } from "./shell-types";
export const Transcript = memo(function Transcript({
@ -368,6 +374,15 @@ export const Transcript = memo(function Transcript({
{reactionView.visibleMessages.map((message) => {
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
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);
return (
<div

View File

@ -725,7 +725,7 @@ export const builtinAgentTools: ConnectorTool[] = [
{
name: "schedule_create",
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: {
type: "object",
properties: {

View File

@ -299,7 +299,11 @@ describe("bot capabilities in runs", () => {
]),
},
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" });

View File

@ -2785,6 +2785,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
delayMinutes: args.delayMinutes,
delaySeconds: args.delaySeconds,
},
notice: { threadId: thread.id, botId: bot.id },
});
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,
isOneShotRoutineCron,
listSchedulesFromTool,
MAX_ACTIVE_ROUTINES_PER_BOT,
resolveScheduleOwner,
resolveScheduleTiming,
SCHEDULE_TOOL_NAMES,
@ -169,7 +170,10 @@ describe("schedule tool persistence", () => {
nextRunAt: args.data.nextRunAt,
}));
const deps = {
prisma: { routine: { create }, userProfile: { findUnique: async () => null } },
prisma: {
routine: { create, findFirst: async () => null, count: async () => 0 },
userProfile: { findUnique: async () => null },
},
events: { append },
jobs: { enqueue, cancel: vi.fn(async () => undefined) },
} as unknown as Parameters<typeof createScheduleFromTool>[0];
@ -202,6 +206,36 @@ describe("schedule tool persistence", () => {
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 () => {
const create = vi.fn(async (args: { data: Record<string, unknown> }) => ({
id: "routine-1",
@ -209,7 +243,10 @@ describe("schedule tool persistence", () => {
}));
const findUnique = vi.fn(async () => ({ timeZone: "America/New_York" }));
const deps = {
prisma: { routine: { create }, userProfile: { findUnique } },
prisma: {
routine: { create, findFirst: async () => null, count: async () => 0 },
userProfile: { findUnique },
},
events: { append: vi.fn(async () => undefined) },
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
} as unknown as Parameters<typeof createScheduleFromTool>[0];
@ -250,7 +287,7 @@ describe("schedule tool persistence", () => {
}));
const deps = {
prisma: {
routine: { create },
routine: { create, findFirst: async () => null, count: async () => 0 },
userProfile: { findUnique: vi.fn(async () => ({ timeZone: "Mars/Olympus" })) },
},
events: { append: vi.fn(async () => undefined) },
@ -278,6 +315,8 @@ describe("schedule tool persistence", () => {
prisma: {
userProfile: { findUnique: async () => null },
routine: {
findFirst: async () => null,
count: async () => 0,
create: vi.fn(async () => ({
id: "routine-1",
name: "Morning joke",
@ -316,6 +355,8 @@ describe("schedule tool persistence", () => {
prisma: {
userProfile: { findUnique: async () => null },
routine: {
findFirst: async () => null,
count: async () => 0,
create: vi.fn(async () => ({
id: "routine-1",
name: "Morning joke",
@ -354,6 +395,8 @@ describe("schedule tool persistence", () => {
prisma: {
userProfile: { findUnique: async () => null },
routine: {
findFirst: async () => null,
count: async () => 0,
create: vi.fn(async () => ({
id: "routine-1",
name: "Morning joke",
@ -397,6 +440,8 @@ describe("schedule tool persistence", () => {
prisma: {
userProfile: { findUnique: async () => null },
routine: {
findFirst: async () => null,
count: async () => 0,
create: vi.fn(async () => ({
id: "routine-1",
name: "Morning joke",
@ -631,7 +676,10 @@ describe("scheduling on behalf of another bot", () => {
const append = vi.fn(async () => undefined);
const deps = {
// 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 },
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
} as unknown as Parameters<typeof createScheduleFromTool>[0];

View File

@ -1,5 +1,6 @@
import type { ConnectorTool, JobPublisher } from "@rakazo/adapter-kit";
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
import type { RoutineEventBlock } from "@rakazo/contracts";
import {
cronFromPreset,
isOneShotRoutineCron,
@ -8,6 +9,8 @@ import {
ONCE_ROUTINE_CRON,
} from "@rakazo/core";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { markRoutineNoticesRemoved, postRoutineNotice } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import { loadUserTimeZone } from "./current-time.js";
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}.` };
}
/** 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(
deps: ScheduleToolDeps,
input: ScheduleOwner & {
@ -284,6 +300,7 @@ export async function createScheduleFromTool(
prompt: string;
timezone?: string;
schedule: Record<string, unknown>;
notice?: ScheduleNotice;
},
) {
const name = input.name.trim();
@ -298,6 +315,36 @@ export async function createScheduleFromTool(
const resolved = resolveScheduleTiming(input.schedule, timezone);
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({
data: {
spaceId: input.spaceId,
@ -341,6 +388,16 @@ export async function createScheduleFromTool(
} catch {
// 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 {
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
* 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.jobs.cancel(routineJobKey(existing.id));
await markRemovedRoutineNotice(deps, existing);
return { ok: true as const, routineId: existing.id, name: existing.name };
}

View File

@ -260,6 +260,21 @@ export function inferScript(
}
const coach = inferCoachScript(prompt);
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")) {
const name = namedBot(prompt) ?? "Researcher";
const brief =

View File

@ -173,6 +173,24 @@ export const IntakeBlockSchema = z.discriminatedUnion("kind", [
]);
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", [
z.object({ kind: z.literal("text"), text: z.string() }),
z.object({
@ -182,6 +200,7 @@ export const MessageBlock = z.discriminatedUnion("kind", [
AskBlockSchema,
ChoiceBlockSchema,
IntakeNoteBlockSchema,
RoutineEventBlockSchema,
z.object({
/** Inline app authorization card (Composio-backed): logo, name, one-line
description, and an Authorize button that flips to connected. */
@ -386,6 +405,6 @@ export function canReactToThreadMessage(message: Pick<ThreadMessage, "id" | "blo
return (
!message.id.startsWith("progress:") &&
!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,
isAttachmentImageMimeType,
} from "@rakazo/contracts";
import { routineEventHistoryText } from "./routine-events.js";
export class AttachmentValidationError extends Error {
constructor(message: string) {
@ -137,6 +138,7 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
if (block.kind === "task") {
return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`;
}
if (block.kind === "event") return routineEventHistoryText(block);
if (block.kind === "handoff") {
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 "./quiet-hours.js";
export * from "./response-bytes.js";
export * from "./routine-events.js";
export * from "./run-state.js";
export * from "./sandbox-command.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?
/// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise.
coachKey String?
/// The latest timeline line the bot wrote about this routine; marked removed with it.
noticeMessageId String?
lastRunAt DateTime?
nextRunAt DateTime?
createdAt DateTime @default(now())

View File

@ -18,6 +18,7 @@ export * from "./project-goals.js";
export * from "./project-workspace.js";
export * from "./push-subscriptions.js";
export * from "./repos.js";
export * from "./routine-notices.js";
export * from "./scope.js";
export * from "./spaces.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 };
});
}