import type { MessageBlock, PlanBlock, PlanStatus, PlanStep, PlanStepStatus, } from "@rakazo/contracts"; import { PLAN_LIMITS, PlanBlockSchema, PlanStepStatusSchema } from "@rakazo/contracts"; function clip(value: string, max: number): string { const flat = value.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat; } export interface PlanCreateArgs { title?: unknown; steps?: unknown; await_approval?: unknown; } export interface PlanUpdateArgs { plan_id?: unknown; steps?: unknown; } function derivePlanStatus(steps: readonly PlanStep[], awaitingApproval = false): PlanStatus { if (steps.length > 0 && steps.every((s) => s.status === "completed" || s.status === "skipped")) { return "completed"; } if (awaitingApproval && steps.every((s) => s.status === "pending")) { return "awaiting_approval"; } return "active"; } /** Build a validated PlanBlock from `plan_create` tool arguments. */ export function buildPlanFromTool( args: PlanCreateArgs, planId: string, ): { ok: true; block: PlanBlock; awaitApproval: boolean } | { error: string } { const rawTitle = typeof args.title === "string" ? clip(args.title, PLAN_LIMITS.title) : ""; if (!rawTitle) return { error: "title is required." }; const rawSteps = Array.isArray(args.steps) ? args.steps : []; const steps: PlanStep[] = []; for (const item of rawSteps) { const label = typeof item === "string" ? clip(item, PLAN_LIMITS.stepLabel) : item && typeof item === "object" && typeof (item as { label?: unknown }).label === "string" ? clip((item as { label: string }).label, PLAN_LIMITS.stepLabel) : ""; if (!label) continue; const rawStatus = item && typeof item === "object" ? (item as { status?: unknown }).status : undefined; const parsedStatus = PlanStepStatusSchema.safeParse(rawStatus); const note = item && typeof item === "object" && typeof (item as { note?: unknown }).note === "string" ? clip((item as { note: string }).note, PLAN_LIMITS.stepNote) : undefined; steps.push({ id: `s${steps.length + 1}`, label, status: parsedStatus.success ? parsedStatus.data : "pending", ...(note ? { note } : {}), }); if (steps.length >= PLAN_LIMITS.maxSteps) break; } if (steps.length < PLAN_LIMITS.minSteps) { return { error: `steps must contain at least ${PLAN_LIMITS.minSteps} non-empty steps.`, }; } const awaitApproval = args.await_approval === true; if (!awaitApproval && steps.every((s) => s.status === "pending")) { steps[0] = { ...steps[0]!, status: "in_progress" }; } const status = derivePlanStatus(steps, awaitApproval); const parsed = PlanBlockSchema.safeParse({ kind: "plan", planId, title: rawTitle, status, steps, }); if (!parsed.success) { const issue = parsed.error.issues[0]; return { error: `Plan is not valid: ${issue?.path.join(".")} ${issue?.message}`.trim() }; } return { ok: true, block: parsed.data, awaitApproval }; } export interface PlanStepUpdateInput { id?: string; index?: number; status: PlanStepStatus; label?: string; note?: string; } export function parsePlanStepUpdates( rawUpdates: unknown, ): { ok: true; updates: PlanStepUpdateInput[] } | { error: string } { if (!Array.isArray(rawUpdates) || rawUpdates.length === 0) { return { error: "steps must be a non-empty array of step updates." }; } const updates: PlanStepUpdateInput[] = []; for (const raw of rawUpdates) { if (!raw || typeof raw !== "object") continue; const item = raw as Record; const parsedStatus = PlanStepStatusSchema.safeParse(item.status); if (!parsedStatus.success) { return { error: "Each step update requires status: pending, in_progress, completed, or skipped.", }; } const id = typeof item.id === "string" && item.id.trim() ? item.id.trim() : undefined; const index = typeof item.index === "number" && Number.isInteger(item.index) && item.index >= 1 ? item.index : undefined; if (!id && index === undefined) { return { error: "Each step update requires id (e.g. 's1') or 1-based index." }; } const label = typeof item.label === "string" && item.label.trim() ? clip(item.label, PLAN_LIMITS.stepLabel) : undefined; const note = typeof item.note === "string" ? clip(item.note, PLAN_LIMITS.stepNote) : undefined; updates.push({ ...(id ? { id } : {}), ...(index !== undefined ? { index } : {}), status: parsedStatus.data, ...(label ? { label } : {}), ...(note !== undefined ? { note } : {}), }); } if (updates.length === 0) { return { error: "Provide at least one valid step update." }; } return { ok: true, updates }; } /** Apply step updates to an existing PlanBlock; returns null if nothing matched or changed. */ export function applyPlanStepUpdates( plan: PlanBlock, updates: readonly PlanStepUpdateInput[], ): PlanBlock | null { let changed = false; const nextSteps = plan.steps.map((step, idx) => { const match = updates.find( (u) => (u.id && u.id === step.id) || (u.index !== undefined && u.index === idx + 1), ); if (!match) return step; const nextLabel = match.label ?? step.label; const nextNote = match.note !== undefined ? match.note || undefined : step.note; if ( step.status === match.status && step.label === nextLabel && (step.note ?? undefined) === nextNote ) { return step; } changed = true; return { id: step.id, label: nextLabel, status: match.status, ...(nextNote ? { note: nextNote } : {}), }; }); const nextStatus = derivePlanStatus(nextSteps, false); if (!changed && plan.status === nextStatus) return null; return { ...plan, status: nextStatus, steps: nextSteps, }; } /** Update a matching plan block inside a message's blocks array. */ export function updatePlanInBlocks( blocks: readonly MessageBlock[], planId: string | undefined, updates: readonly PlanStepUpdateInput[], ): { blocks: MessageBlock[]; plan: PlanBlock } | null { let updatedPlan: PlanBlock | null = null; const next = blocks.map((block) => { if (block.kind !== "plan") return block; if (planId && block.planId !== planId) return block; if (updatedPlan) return block; const applied = applyPlanStepUpdates(block, updates); if (!applied) return block; updatedPlan = applied; return applied; }); return updatedPlan ? { blocks: next, plan: updatedPlan } : null; } const STEP_GLYPH: Record = { completed: "✓", in_progress: "▸", pending: "○", skipped: "–", }; /** How a later turn reads a plan block in conversation history. */ export function planHistoryText(block: PlanBlock): string { const done = block.steps.filter((s) => s.status === "completed").length; const lines = block.steps.map( (s, idx) => ` ${idx + 1}. [${STEP_GLYPH[s.status]} ${s.status}] ${s.label}${s.note ? ` — ${s.note}` : ""} (${s.id})`, ); return `[plan ${block.planId} (${block.status}, ${done}/${block.steps.length} done): ${block.title}]\n${lines.join("\n")}`; }