,
+ 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}`;
+ }
+ }
+}
diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po
index e54f8d7..8b3b5e0 100644
--- a/apps/web/src/locales/de/messages.po
+++ b/apps/web/src/locales/de/messages.po
@@ -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"
diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po
index 89f5775..e3f5100 100644
--- a/apps/web/src/locales/en/messages.po
+++ b/apps/web/src/locales/en/messages.po
@@ -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"
diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po
index 31ebe36..ab5d3b3 100644
--- a/apps/web/src/locales/es/messages.po
+++ b/apps/web/src/locales/es/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po
index 33e5203..33700b3 100644
--- a/apps/web/src/locales/hi/messages.po
+++ b/apps/web/src/locales/hi/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po
index 1bd9164..8d02744 100644
--- a/apps/web/src/locales/ko/messages.po
+++ b/apps/web/src/locales/ko/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po
index e96f31e..b48fefd 100644
--- a/apps/web/src/locales/pt-BR/messages.po
+++ b/apps/web/src/locales/pt-BR/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po
index 160e421..860d4b7 100644
--- a/apps/web/src/locales/ru/messages.po
+++ b/apps/web/src/locales/ru/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po
index a2da959..635d728 100644
--- a/apps/web/src/locales/tr/messages.po
+++ b/apps/web/src/locales/tr/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po
index 6fbc473..4ab22f8 100644
--- a/apps/web/src/locales/zh-CN/messages.po
+++ b/apps/web/src/locales/zh-CN/messages.po
@@ -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 ""
diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx
index 8a68433..4e61dbe 100644
--- a/apps/web/src/pages/Shell.tsx
+++ b/apps/web/src/pages/Shell.tsx
@@ -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");
diff --git a/apps/web/src/pages/shell/routine-event-line.tsx b/apps/web/src/pages/shell/routine-event-line.tsx
new file mode 100644
index 0000000..b45900e
--- /dev/null
+++ b/apps/web/src/pages/shell/routine-event-line.tsx
@@ -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 (
+
+
+
+
+ {block.name}
+
+ {schedule ? · : null}
+ {schedule ? {schedule} : null}
+ ·
+ {block.removed ? (
+ {t`Removed`}
+ ) : (
+ <>
+ {block.event === "routine.updated" ? {t`Updated`} : null}
+ {block.event === "routine.updated" ? · : null}
+
+ ·
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx
index 1e1c21c..dd25bdd 100644
--- a/apps/web/src/pages/shell/transcript.tsx
+++ b/apps/web/src/pages/shell/transcript.tsx
@@ -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 (
+
+ {message.blocks.map((block, index) =>
+ block.kind === "event" ? : null,
+ )}
+
+ );
+ }
const messageReactions = reactionView.reactions.get(message.id);
return (
{
]),
},
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" });
diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts
index 7a65cc8..1074430 100644
--- a/packages/adapters/src/executor.ts
+++ b/packages/adapters/src/executor.ts
@@ -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);
}
diff --git a/packages/adapters/src/schedule-notices.postgres.test.ts b/packages/adapters/src/schedule-notices.postgres.test.ts
new file mode 100644
index 0000000..867662d
--- /dev/null
+++ b/packages/adapters/src/schedule-notices.postgres.test.ts
@@ -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;
+ 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();
+ });
+});
diff --git a/packages/adapters/src/schedule-tools.test.ts b/packages/adapters/src/schedule-tools.test.ts
index 469c770..dd678e0 100644
--- a/packages/adapters/src/schedule-tools.test.ts
+++ b/packages/adapters/src/schedule-tools.test.ts
@@ -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[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[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 }) => ({
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[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[0];
diff --git a/packages/adapters/src/schedule-tools.ts b/packages/adapters/src/schedule-tools.ts
index f267200..529ae7a 100644
--- a/packages/adapters/src/schedule-tools.ts
+++ b/packages/adapters/src/schedule-tools.ts
@@ -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;
+ 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,
+ 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 };
}
diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts
index bd849a5..b0197ee 100644
--- a/packages/adapters/src/scripted-runtime.ts
+++ b/packages/adapters/src/scripted-runtime.ts
@@ -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 =
diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts
index 8473593..f82b0ba 100644
--- a/packages/contracts/src/events.ts
+++ b/packages/contracts/src/events.ts
@@ -173,6 +173,24 @@ export const IntakeBlockSchema = z.discriminatedUnion("kind", [
]);
export type IntakeBlock = z.infer;
+/**
+ * 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;
+
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 block.kind === "channel_message")
+ !message.blocks.some((block) => block.kind === "channel_message" || block.kind === "event")
);
}
diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts
index c8ed92d..997b4ba 100644
--- a/packages/core/src/attachments.ts
+++ b/packages/core/src/attachments.ts
@@ -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}`;
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 1a78747..e0f3a54 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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";
diff --git a/packages/core/src/routine-events.test.ts b/packages/core/src/routine-events.test.ts
new file mode 100644
index 0000000..038bce3
--- /dev/null
+++ b/packages/core/src/routine-events.test.ts
@@ -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" });
+ }
+ });
+});
diff --git a/packages/core/src/routine-events.ts b/packages/core/src/routine-events.ts
new file mode 100644
index 0000000..4a521e0
--- /dev/null
+++ b/packages/core/src/routine-events.ts
@@ -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" };
+}
diff --git a/packages/db/prisma/migrations/20261004160000_routine_notice_message/migration.sql b/packages/db/prisma/migrations/20261004160000_routine_notice_message/migration.sql
new file mode 100644
index 0000000..2607007
--- /dev/null
+++ b/packages/db/prisma/migrations/20261004160000_routine_notice_message/migration.sql
@@ -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;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 1923740..8ed7fbd 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -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())
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index d8e5de1..83c8165 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -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";
diff --git a/packages/db/src/routine-notices.ts b/packages/db/src/routine-notices.ts
new file mode 100644
index 0000000..a710c30
--- /dev/null
+++ b/packages/db/src/routine-notices.ts
@@ -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 };
+ });
+}