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>
140 lines
5.3 KiB
TypeScript
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`;
|
|
}
|