/** * Phase machine — the port of DSH anchored-standard's `tool-bootstrap.mjs` + * `compaction-epoch.mjs` promotion logic, expressed over pi session entries. * * bootstrap ── first durable promotion signal ──▶ promoted * ▲ │ * └──────────────── compaction ────────────────────┘ * * - `bootstrap` (unpromoted, no compaction yet): the model sees ONLY the * bootstrap pair (`bash` + `str_replace_editor`). * - `promoted`: bootstrap pair + resident discovery tools + everything the * model unlocked via `dev_tool_search`. * - `controlled` (unpromoted, after a compaction): bootstrap pair + * `compactionTools`, until a NEW promotion signal past the boundary. * * The state is derivable from the session branch, so resume/fork/reload * reconstruct it (see `deriveFromEntries`). */ export type PromoteOn = "either" | "tool-call" | "assistant-message"; export interface PhaseConfig { bootstrapTools: string[]; residentTools: string[]; compactionTools: string[]; promoteOn: PromoteOn; /** Name of the unlock tool whose call arguments carry `toolNames`. */ unlockToolName: string; } export interface PhaseSnapshot { promoted: boolean; /** True when a compaction happened after the last promotion signal. */ afterCompaction: boolean; unlocked: string[]; hinted: boolean; } /** Minimal structural view of the pi session entries we care about. */ export interface EntryLike { type: string; customType?: string; message?: { role?: string; content?: unknown; }; } export const HINT_CUSTOM_TYPE = "anchored-standard-hint"; export class PhaseState { promoted = false; afterCompaction = false; hinted = false; readonly unlocked = new Set(); readonly config: PhaseConfig; constructor(config: PhaseConfig) { this.config = config; } snapshot(): PhaseSnapshot { return { promoted: this.promoted, afterCompaction: this.afterCompaction, unlocked: [...this.unlocked], hinted: this.hinted, }; } /** Which phase name applies right now (for status display). */ phaseName(): "bootstrap" | "controlled" | "promoted" { if (this.promoted) return "promoted"; return this.afterCompaction ? "controlled" : "bootstrap"; } /** The tool names that should be active for the current phase. */ activeSet(): string[] { const keep = new Set(this.config.bootstrapTools); if (this.promoted) { for (const name of this.config.residentTools) keep.add(name); for (const name of this.unlocked) keep.add(name); } else if (this.afterCompaction) { for (const name of this.config.compactionTools) keep.add(name); } return [...keep]; } /** Record a live promotion signal. Returns true if the phase changed. */ signal(kind: "tool-call" | "assistant-message"): boolean { if (this.promoted) return false; const mode = this.config.promoteOn; const accept = mode === "either" || mode === kind; if (!accept) return false; this.promoted = true; this.afterCompaction = false; return true; } /** A compaction rewrote the surface: fall back to the controlled phase. */ compacted(): void { this.promoted = false; this.afterCompaction = true; } unlock(names: Iterable): string[] { const added: string[] = []; for (const name of names) { if (typeof name !== "string" || name.length === 0 || this.unlocked.has(name)) continue; this.unlocked.add(name); added.push(name); } return added; } reset(): void { this.promoted = false; this.afterCompaction = false; this.hinted = false; this.unlocked.clear(); } /** * Rebuild the state from a session branch (oldest → newest). Compactions * demote; assistant messages (or tool calls, per `promoteOn`) promote; * unlock-tool calls accumulate across the whole branch (an unlock survives * compaction, exactly like DSH derives it from all `tool/call` events). */ deriveFromEntries(entries: Iterable): void { this.reset(); for (const entry of entries) { if (entry.type === "compaction") { this.compacted(); continue; } if (entry.type === "custom_message" && entry.customType === HINT_CUSTOM_TYPE) { this.hinted = true; continue; } if (entry.type !== "message" || entry.message?.role !== "assistant") continue; const content = Array.isArray(entry.message.content) ? entry.message.content : []; let sawToolCall = false; for (const block of content as Array>) { if (block?.type !== "toolCall") continue; sawToolCall = true; if (block.name === this.config.unlockToolName) { const args = block.arguments as Record | undefined; const names = args?.toolNames; if (Array.isArray(names)) this.unlock(names.filter((n): n is string => typeof n === "string")); } } this.signal("assistant-message"); if (sawToolCall) this.signal("tool-call"); } } }