217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
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")}`;
|
||
}
|