evOo-local/packages/core/src/routine-events.ts
cynarAI 460eede226
feat: autopilot that keeps working on an order until it is done (#144)
An autopilot per thread continues the user's order run after run, with
no time limit. Each run reports a checkpoint; after three runs without
progress or three failed runs it pauses with Continue and Stop. Start,
pause, stop and finish show as quiet lines in the conversation.
Approvals during an autopilot run are parked as suggestions, and the
temporary approval rules end with the autopilot.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 03:56:45 +02:00

76 lines
3.4 KiB
TypeScript

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" || block.kind === "autopilot")
);
}
/** 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" };
}