evOo-local/packages/core/src/autopilot.ts
cynarAI 54658ee689
feat: autopilot progress, composer start and morning summary (#146)
The autopilot's start line counts its steps and folds open to the latest
checkpoints, and its result reads as a card. The composer's plus menu
starts an autopilot with the draft as the order and a choice of until
done, tomorrow morning or a time. Today lists the autopilots that run,
wait or ended within a day, and their step runs no longer show one by
one. Autopilot runs send no completion or failure push; only a pause
notifies.


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

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

140 lines
5.3 KiB
TypeScript

import type { AutopilotBlock } from "@rakazo/contracts";
import {
AUTOPILOT_CHECKPOINT_LOG,
AUTOPILOT_FAIL_LIMIT,
AUTOPILOT_IDLE_LIMIT,
} from "@rakazo/contracts";
/** What the autopilot remembers between runs. */
export interface AutopilotState {
goal: string;
focus: string | null;
until: Date | null;
lastCheckpoint: string | null;
nextStep: string | null;
stepCount: number;
}
/**
* The prompt of each continuation run: the order in the user's words and where the work stands,
* so a fresh run picks up without the whole history.
*/
export function autopilotContinuationPrompt(state: AutopilotState, now = new Date()): string {
return [
"Autopilot: continue the user's order on your own, one sensible step at a time.",
`Order: ${state.goal}`,
state.focus ? `Focus: ${state.focus}` : null,
state.until ? `Until: ${state.until.toISOString()} (now ${now.toISOString()})` : "Until: done",
`Steps so far: ${state.stepCount}`,
state.lastCheckpoint ? `Last checkpoint: ${state.lastCheckpoint}` : null,
state.nextStep ? `Planned next: ${state.nextStep}` : null,
"Do the next step, then call autopilot_checkpoint with what changed. When the order is done, call autopilot_finish with the result. Actions that need the user's approval are parked as suggestions; continue with other work.",
]
.filter(Boolean)
.join("\n");
}
/** Whether the run that just ended moved the autopilot on, and if it should pause now. */
export function advanceAutopilotGuard(
counts: { idleCount: number; failCount: number },
run: { checkpointed: boolean; failed: boolean },
): { idleCount: number; failCount: number; pause: "idle" | "failing" | null } {
const idleCount = run.checkpointed ? 0 : counts.idleCount + 1;
const failCount = run.failed ? counts.failCount + 1 : 0;
const pause =
failCount >= AUTOPILOT_FAIL_LIMIT
? "failing"
: idleCount >= AUTOPILOT_IDLE_LIMIT
? "idle"
: null;
return { idleCount, failCount, pause };
}
/** The autopilot's time is up. */
export function autopilotExpired(until: Date | null, now = Date.now()): boolean {
return Boolean(until && until.getTime() <= now);
}
/** How a later turn reads an autopilot line. */
export function autopilotHistoryText(block: AutopilotBlock): string {
switch (block.event) {
case "autopilot.started": {
const parts = [block.goal];
if (block.until) parts.push(`until ${block.until}`);
if (block.focus) parts.push(`focus: ${block.focus}`);
if (block.allowed?.length) parts.push(`allowed without asking: ${block.allowed.join(", ")}`);
return `[autopilot ${block.ended ? "was on" : "on"}: ${parts.join(" — ")}]`;
}
case "autopilot.paused":
return `[autopilot paused: ${block.text ?? "no progress"}]`;
case "autopilot.stopped":
return "[autopilot stopped by the user]";
case "autopilot.finished":
return `[autopilot finished after ${block.steps ?? 0} steps: ${block.text ?? ""}]`;
}
}
/** The blocks with the start line of `autopilotId` marked ended, or null when nothing changes. */
export function markAutopilotLinesEnded(
blocks: readonly { kind: string }[],
autopilotId: string,
): AutopilotBlock[] | null {
let changed = false;
const next = blocks.map((block) => {
const line = block as AutopilotBlock;
if (line.kind !== "autopilot" || line.autopilotId !== autopilotId || line.ended) return line;
if (line.event !== "autopilot.started" && line.event !== "autopilot.paused") return line;
changed = true;
return { ...line, ended: true };
});
return changed ? next : null;
}
/** Count a checkpoint on the autopilot's start line and keep the latest few. */
export function addAutopilotCheckpoint(
blocks: readonly { kind: string }[],
autopilotId: string,
checkpoint: { text: string; at: string },
steps: number,
): AutopilotBlock[] | null {
let changed = false;
const next = blocks.map((block) => {
const line = block as AutopilotBlock;
if (line.kind !== "autopilot" || line.autopilotId !== autopilotId) return line;
if (line.event !== "autopilot.started") return line;
changed = true;
return {
...line,
steps,
checkpoints: [...(line.checkpoints ?? []), checkpoint].slice(-AUTOPILOT_CHECKPOINT_LOG),
};
});
return changed ? next : null;
}
/** When the autopilot should stop: never on its own, at the next 8:00, or at the next given time. */
export type AutopilotUntilChoice =
| { kind: "done" }
| { kind: "morning" }
| { kind: "time"; time: string };
export function autopilotUntil(choice: AutopilotUntilChoice, now: Date): Date | undefined {
if (choice.kind === "done") return undefined;
const [hours, minutes] =
choice.kind === "morning" ? [8, 0] : choice.time.split(":").map((part) => Number(part));
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return undefined;
const until = new Date(now);
until.setHours(hours ?? 0, minutes ?? 0, 0, 0);
if (until.getTime() <= now.getTime()) until.setDate(until.getDate() + 1);
return until;
}
/** "3 h 42 min", "12 min", or "1 min" for anything shorter. */
export function formatAutopilotDuration(ms: number): string {
const minutes = Math.max(1, Math.round(ms / 60_000));
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
if (!hours) return `${minutes} min`;
return rest ? `${hours} h ${rest} min` : `${hours} h`;
}