217 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
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<PlanStepStatus, string> = {
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")}`;
}