From 9a6af9697ff372d3f39c90703c31424f3ad662bc Mon Sep 17 00:00:00 2001 From: Amos Date: Fri, 21 Aug 2026 21:37:28 +0300 Subject: [PATCH] v0 scaffold: teacher runtime (hermetic pi SDK), data model, ledgers, normalizer - teacher/system-prompt.md: teach methodology + quiz/ask guidelines folded in - extensions/: quiz.ts + ask-user-question.ts (hermetic copies) - src/teacher/create-session.ts: isolated agent (noTools builtin, own agentDir/sessions/prompt) - src/schema.ts + ledger.ts: append-only ledgers, known-vs-understood data model - src/extract/normalize.ts: session JSONL -> canonical doc (verified on real session) - extractor/merger/derive/what-next: specified stubs --- .gitignore | 4 + README.md | 35 + config.ts | 24 + docs/architecture.md | 96 ++ extensions/ask-user-question.ts | 669 +++++++++ extensions/quiz.ts | 1062 ++++++++++++++ package-lock.json | 2381 +++++++++++++++++++++++++++++++ package.json | 22 + src/cli.ts | 78 + src/derive.ts | 37 + src/extract/extract.ts | 26 + src/extract/merge.ts | 22 + src/extract/normalize.ts | 217 +++ src/ledger.ts | 68 + src/queries/what-next.ts | 18 + src/schema.ts | 170 +++ src/teacher/create-session.ts | 141 ++ teacher/system-prompt.md | 185 +++ tsconfig.json | 16 + 19 files changed, 5271 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.ts create mode 100644 docs/architecture.md create mode 100644 extensions/ask-user-question.ts create mode 100644 extensions/quiz.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/derive.ts create mode 100644 src/extract/extract.ts create mode 100644 src/extract/merge.ts create mode 100644 src/extract/normalize.ts create mode 100644 src/ledger.ts create mode 100644 src/queries/what-next.ts create mode 100644 src/schema.ts create mode 100644 src/teacher/create-session.ts create mode 100644 teacher/system-prompt.md create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e05ae32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..da32e06 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# learning-harness + +A standalone learning harness built on the [pi](https://github.com/earendil-works/pi-mono) agent SDK: a teacher agent, durable session artifacts, and a derived knowledge graph of what the learner knows, what they only think they know, and what to learn next. + +Design + architecture: [`docs/architecture.md`](docs/architecture.md). Lineage: built up from the Obsidian-based POC (`~/Desktop/Obsidian/vault/π/learn`) — same teaching methodology, own interface and data pipeline. + +## Commands + +```bash +npm install # once + +npm run teach # launch an interactive teaching session (full pi TUI) +npm run doctor # non-interactive smoke test of the teacher wiring + +npm run ingest -- # Stage 1: normalize a session into the canonical doc +npm run graph # ledger stats +``` + +## Layout + +``` +teacher/system-prompt.md the teacher's entire behavior (teach methodology + tool guidelines) +extensions/ quiz.ts, ask-user-question.ts (hermetic copies) +src/teacher/ pi SDK bootstrap (isolated agent, TUI) +src/schema.ts knowledge-graph data model +src/ledger.ts append-only JSONL ledgers +src/extract/ normalize (✅) · extract · merge (stubs, specified) +src/derive.ts derived state (known-set now; understanding parked) +src/queries/ what-next (stub) +data/agent/ hermetic pi agentDir (auth + model store are symlinks to ~/.pi/agent) +data/sessions/ session JSONLs +data/graph/ ledgers + canonical documents +``` + +Pi is a library dependency here (pinned in package.json) — this is a separate pi from any global install. Credentials and the model catalog are shared read-mostly via symlinks; everything else is the harness's own. diff --git a/config.ts b/config.ts new file mode 100644 index 0000000..167bd7b --- /dev/null +++ b/config.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Repository root (this file is at /config.ts). */ +export const ROOT = path.dirname(fileURLToPath(import.meta.url)); + +export const PATHS = { + root: ROOT, + extensions: path.join(ROOT, "extensions"), + teacherPrompt: path.join(ROOT, "teacher", "system-prompt.md"), + data: path.join(ROOT, "data"), + /** Hermetic pi agentDir: settings, auth, and NOTHING from ~/.pi/agent except a symlinked auth.json. */ + agentDir: path.join(ROOT, "data", "agent"), + sessions: path.join(ROOT, "data", "sessions"), + graph: path.join(ROOT, "data", "graph"), +} as const; + +export const LEDGER_FILES = { + nodes: path.join(PATHS.graph, "nodes.jsonl"), + edges: path.join(PATHS.graph, "edges.jsonl"), + events: path.join(PATHS.graph, "events.jsonl"), + mappings: path.join(PATHS.graph, "mappings.jsonl"), + sessions: path.join(PATHS.graph, "sessions.jsonl"), +} as const; diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f4d68b7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,96 @@ +# Learning Harness — Architecture + +Status: v0 scaffold. Teacher runtime ✅ · data model + ledgers ✅ · normalizer ✅ · extractor / merger / derived views — specified, not yet implemented. + +## What this is + +A standalone learning harness built **on top of** pi (`@earendil-works/pi-coding-agent`) as a library dependency — the same relationship as building an app on Express. It is **not** the user's global pi: hermetic isolation, own pinned version, own sessions, own system prompt. + +Built up from the Obsidian-based POC (`~/Desktop/Obsidian/vault/π/learn`) — the teaching behavior is identical (same teach methodology, same quiz tool, same probe → plan → teach flow), but the interface and data pipeline are the harness's own. Full design reasoning lives in the POC's `knowledge graph design.md`; this doc covers what's implemented here. + +## Architecture + +```mermaid +graph TB + subgraph TEACH["Teaching Runtime (pi SDK, in-process)"] + TP["Teacher Agent
system prompt = teach methodology
+ quiz/ask guidelines folded in"] + Q["quiz.ts"] + AUQ["ask-user-question.ts"] + TP --> Q & AUQ + end + + subgraph STORE["Data (append-only, plain files)"] + SJ["session JSONLs
data/sessions/"] + L["Ledgers: sessions / nodes / edges /
events / mappings
data/graph/*.jsonl"] + end + + subgraph PIPE["Extraction Pipeline (after a session)"] + N["Stage 1 — Normalizer
JSONL → canonical doc
(deterministic, ✅ done)"] + E["Stage 2 — Extractor LLM
nodes/edges/events/knowing
(anchored rubric)"] + A["Audit LLM
faithfulness check"] + M["Stage 3 — Merger
embeddings + LLM adjudication
+ reversible mappings"] + end + + subgraph DERIVED["Derived (pure code, recomputed)"] + G["Knowledge graph"] + W["what-next:
territory / repairs / conversions"] + end + + TP -- writes --> SJ + SJ --> N --> E --> A --> M + M -- appends --> L + L --> G --> W + + W -. "future: React/Next UI (Electron-able),
force-directed WebGL graph view" .- TEACH +``` + +**Core invariant:** LLMs only ever touch the top pipeline (semantic judgment). Everything below the ledgers is deterministic code. The teacher stays a pure teacher; the graph is never asserted by an LLM — only by logged, reversible evidence. + +## Teacher runtime + +`src/teacher/create-session.ts` boots pi via the SDK with total isolation: + +| Concern | Value | +|---|---| +| System prompt | `teacher/system-prompt.md` — the teach methodology (from the POC's teach skill) with the quiz + ask_user_question usage guidelines folded in (custom prompts drop extension guidelines — verified in pi's `buildSystemPrompt`) | +| Tools | `noTools: "builtin"` → no read/bash/edit/write; only `quiz` + `ask_user_question` | +| Extensions | explicitly via `additionalExtensionPaths`; imports of old `@mariozechner/*` names resolve through pi's loader aliases | +| agentDir | `data/agent/` — hermetic. `auth.json` and `models-store.json` are **symlinks** to `~/.pi/agent/` (credential + catalog sharing only) | +| Sessions | `data/sessions/` via `SessionManager.create(cwd, sessionDir)` | +| Model | from `data/agent/settings.json` (currently openrouter/z-ai/glm-5.3, thinking high) | +| Research tool | deliberately absent in v0 — the system prompt's accuracy rule is adapted accordingly (flag uncertainty, never bluff) | + +Run: `npm run teach` → full pi TUI, quiz popups, session persisted in `data/sessions/`. + +`npm run doctor` → non-interactive smoke test (verifies extensions, tools, model, prompt; no leaks). + +## Data model + +`src/schema.ts`. Key decisions: + +- **Nodes** are propositions at ~one-reasoning-step grain; `kind: truth | misconception`; `state: known | not-held | candidate`. +- **Edges** carry NO scalars — only `type: requires | supports` and provenance. "Load-bearing" is derived, never stored. +- **Events** are the append-only source of truth: `(node, kind, session, entryId, evidence)`. Every event carries `entryId` → **jump-to-point**: the exact place in the session where it happened. A node's "which sessions is it from" derives from its events (merges union them automatically). +- **Mappings** are merge decisions, logged and reversible. +- **Knowing** is assessed on an anchored ordinal rubric (misconception / absent / shakily-known / known / fluent) — never freehand scalars, never per-session normalized. +- **known ≠ understood**: known is evidence (ledger); understood is structure (derived: known + requires-subtree held). The extractor does zero inference about knowledge. + +## Extraction pipeline + +1. **Normalize** (`src/extract/normalize.ts`, ✅) — deterministic. Quiz toolResults in the JSONL carry full structured details (question, options, correct/dontKnow, note), so quiz events are read as data, not parsed from prose. Verified against a real POC session. +2. **Extract** (`extract.ts`, stub) — one LLM pass: nodes, edges (primarily from the plan's mermaid DAG, already asserted by the teacher), attributed events, knowing on the rubric. Zero inference beyond evidence. +3. **Audit** (stub) — second LLM call checking extraction faithfulness only. +4. **Merge** (`merge.ts`, stub) — embedding kNN + one adjudication call per session: `same` (alias) / `related` (new node + cross-session edge — the only place cross-domain edges are born) / `unrelated`. Conservative: prefer duplicates over wrong merges. Fully automatic; mappings make every decision reversible. + +## Derived views (stubbed) + +`src/derive.ts` + `src/queries/what-next.ts` — pure code over the ledgers: known-set now; understanding aggregation **parked** (weakest-link vs mean vs two-level mean/max — confounded with edge typing, to be decided on real extracted data). What-next returns territory / repairs / conversions; unlock-value runs over lazily expanded frontier, discounted by depth in generated territory. + +## Build order (from here) + +1. ~~repo scaffold, teacher runtime, data model, ledgers, normalizer~~ ✅ +2. Extractor + audit (LLM pass, structured output) +3. Merger (embeddings + adjudication) +4. Derive + what-next queries (CLI) +5. Retroactive bootstrap over the POC session JSONLs in `~/.pi/agent/sessions/--Users-amos-Desktop-Obsidian-vault-π-learn--/` +6. *(parked)* React/Next interface, graph rendering (force-directed WebGL, Obsidian-graph feel), spaced repetition, research tool for the teacher diff --git a/extensions/ask-user-question.ts b/extensions/ask-user-question.ts new file mode 100644 index 0000000..d048d7e --- /dev/null +++ b/extensions/ask-user-question.ts @@ -0,0 +1,669 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + Editor, + type EditorTheme, + Key, + Text, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, +} from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +interface AskOption { + label: string; + value: string; + description?: string; +} + +interface DisplayOption extends AskOption { + id: string; + index?: number; + isOther?: boolean; + isSubmit?: boolean; +} + +interface TextAnswer { + type: "text"; + label: string; + value: string; +} + +interface OptionAnswer { + type: "option"; + label: string; + value: string; + index: number; +} + +interface OtherAnswer { + type: "other"; + label: string; + value: string; +} + +type AskAnswer = TextAnswer | OptionAnswer | OtherAnswer; +type AskUserQuestionStatus = "answered" | "cancelled" | "unavailable"; +type AskUserQuestionMode = "text" | "single-select" | "multi-select"; + +interface AskUserQuestionResultDetails { + status: AskUserQuestionStatus; + question: string; + context?: string; + mode: AskUserQuestionMode; + answers: AskAnswer[]; + message?: string; +} + +const OptionSchema = Type.Object({ + label: Type.String({ + description: + 'Display label for the option. If you recommend an option, place it first and append "(Recommended)" to the label.', + }), + value: Type.Optional( + Type.String({ + description: "Optional machine-readable value returned for the option. Defaults to the label.", + }), + ), + description: Type.Optional(Type.String({ description: "Optional extra detail shown below the option." })), +}); + +const AskUserQuestionParams = Type.Object({ + question: Type.String({ + description: "The single question to ask the user. Ask exactly one question per tool call.", + }), + details: Type.Optional( + Type.String({ + description: "Optional extra context or instructions shown under the question.", + }), + ), + options: Type.Optional( + Type.Array(OptionSchema, { + description: + "Optional multiple-choice options. Omit or pass an empty array for free-form text input. Users will always be able to choose Other and type a custom answer when options are provided.", + }), + ), + multiSelect: Type.Optional( + Type.Boolean({ + description: "Set to true to allow multiple answers to be selected for a question.", + }), + ), +}); + +function normalizeOptions(options: Array<{ label: string; value?: string; description?: string }> | undefined): AskOption[] { + return (options || []) + .map((option) => ({ + label: option.label.trim(), + value: option.value?.trim() || option.label.trim(), + description: option.description?.trim() || undefined, + })) + .filter((option) => option.label.length > 0); +} + +function getOtherLabel(options: AskOption[]): string { + return options.some((option) => option.label.toLowerCase() === "other") ? "Other (custom)" : "Other"; +} + +function createEditorTheme(theme: any): EditorTheme { + return { + borderColor: (s) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }, + }; +} + +function addWrapped(lines: string[], text: string, width: number, indent = ""): void { + const contentWidth = Math.max(1, width - indent.length); + for (const line of wrapTextWithAnsi(text, contentWidth)) { + lines.push(truncateToWidth(`${indent}${line}`, width)); + } +} + +function formatAnswerForModel(answer: AskAnswer): string { + switch (answer.type) { + case "text": + return answer.label; + case "other": + return `Other: ${answer.label}`; + case "option": + return `${answer.index}. ${answer.label}`; + } +} + +function answerSortRank(answer: AskAnswer): number { + switch (answer.type) { + case "option": + return answer.index; + case "other": + return Number.MAX_SAFE_INTEGER - 1; + case "text": + return Number.MAX_SAFE_INTEGER; + } +} + +function sortAnswers(answers: AskAnswer[]): AskAnswer[] { + return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b)); +} + +function buildStructuredResult( + status: AskUserQuestionStatus, + question: string, + mode: AskUserQuestionMode, + answers: AskAnswer[], + context?: string, + message?: string, +) { + return { + status, + question, + context, + mode, + answers, + message, + } as AskUserQuestionResultDetails; +} + +function cancelledResult(question: string, mode: AskUserQuestionMode, context?: string) { + const message = "User cancelled the question"; + return { + content: [{ type: "text" as const, text: message }], + details: buildStructuredResult("cancelled", question, mode, [], context, message), + }; +} + +function unavailableResult(question: string, mode: AskUserQuestionMode, message: string, context?: string) { + return { + content: [{ type: "text" as const, text: message }], + details: buildStructuredResult("unavailable", question, mode, [], context, message), + }; +} + +function buildResult(question: string, context: string | undefined, mode: AskUserQuestionMode, answers: AskAnswer[]) { + let text: string; + if (mode === "text") { + const answer = answers[0]; + text = answer.label.trim().length > 0 ? `User answered: ${answer.label}` : "User submitted an empty response"; + } else if (mode === "single-select") { + text = `User selected: ${formatAnswerForModel(answers[0])}`; + } else { + text = `User selected:\n${answers.map((answer) => `- ${formatAnswerForModel(answer)}`).join("\n")}`; + } + + return { + content: [{ type: "text" as const, text }], + details: buildStructuredResult("answered", question, mode, answers, context), + }; +} + +async function askSingleChoice( + ctx: any, + question: string, + context: string | undefined, + options: AskOption[], +): Promise { + const otherLabel = getOtherLabel(options); + const allOptions: DisplayOption[] = [ + ...options.map((option, index) => ({ ...option, id: `option:${index}`, index: index + 1 })), + { id: "other", label: otherLabel, value: "__other__", isOther: true }, + ]; + + return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (result: AskAnswer | null) => void) => { + let optionIndex = 0; + let editMode = false; + let cachedLines: string[] | undefined; + let cachedWidth = -1; + const editor = new Editor(tui, createEditorTheme(theme)); + + editor.onSubmit = (value) => { + const trimmed = value.trim(); + if (!trimmed) return; + done({ type: "other", label: trimmed, value: trimmed }); + }; + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function handleInput(data: string) { + if (editMode) { + if (matchesKey(data, Key.escape)) { + editMode = false; + editor.setText(""); + refresh(); + return; + } + editor.handleInput(data); + refresh(); + return; + } + + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(allOptions.length - 1, optionIndex + 1); + refresh(); + return; + } + if (matchesKey(data, Key.enter)) { + const selected = allOptions[optionIndex]; + if (selected.isOther) { + editMode = true; + editor.setText(""); + refresh(); + return; + } + done({ + type: "option", + label: selected.label, + value: selected.value, + index: selected.index!, + }); + return; + } + if (matchesKey(data, Key.escape)) { + done(null); + } + } + + function render(width: number): string[] { + // The cache MUST be keyed on width: pi-tui calls requestRender() but NOT + // invalidate() on terminal resize, so render() can be re-entered with a + // new width. Returning stale wider lines trips the TUI width guard and + // crashes the process. + if (cachedLines && cachedWidth === width) return cachedLines; + + const lines: string[] = []; + const add = (text: string) => lines.push(truncateToWidth(text, width)); + + add(theme.fg("accent", "─".repeat(width))); + addWrapped(lines, theme.fg("text", ` ${question}`), width); + if (context) { + lines.push(""); + addWrapped(lines, theme.fg("muted", ` ${context}`), width); + } + lines.push(""); + + for (let i = 0; i < allOptions.length; i++) { + const option = allOptions[i]; + const selected = i === optionIndex; + const prefix = selected ? theme.fg("accent", "> ") : " "; + const label = option.isOther ? option.label : `${option.index}. ${option.label}`; + const styled = selected ? theme.fg("accent", label) : theme.fg("text", label); + add(`${prefix}${styled}`); + if (option.description) { + addWrapped(lines, theme.fg("muted", option.description), width, " "); + } + } + + if (editMode) { + lines.push(""); + add(theme.fg("muted", " Write your custom answer:")); + for (const line of editor.render(Math.max(1, width - 2))) { + add(` ${line}`); + } + lines.push(""); + add(theme.fg("dim", " Enter to submit • Esc to go back")); + } else { + lines.push(""); + add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel")); + } + + add(theme.fg("accent", "─".repeat(width))); + cachedLines = lines; + cachedWidth = width; + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + }, + handleInput, + }; + }); +} + +async function askMultiChoice( + ctx: any, + question: string, + context: string | undefined, + options: AskOption[], +): Promise { + const otherLabel = getOtherLabel(options); + const choiceItems: DisplayOption[] = options.map((option, index) => ({ + ...option, + id: `option:${index}`, + index: index + 1, + })); + const submitItem: DisplayOption = { id: "submit", label: "Submit", value: "__submit__", isSubmit: true }; + const allItems: DisplayOption[] = [ + ...choiceItems, + { id: "other", label: otherLabel, value: "__other__", isOther: true }, + submitItem, + ]; + + return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (result: AskAnswer[] | null) => void) => { + let optionIndex = 0; + let editMode = false; + let cachedLines: string[] | undefined; + let cachedWidth = -1; + const selected = new Map(); + const editor = new Editor(tui, createEditorTheme(theme)); + + editor.onSubmit = (value) => { + const trimmed = value.trim(); + if (!trimmed) return; + selected.set("other", { type: "other", label: trimmed, value: trimmed }); + editMode = false; + refresh(); + }; + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function toggleOption(item: DisplayOption) { + if (selected.has(item.id)) { + selected.delete(item.id); + } else { + selected.set(item.id, { + type: "option", + label: item.label, + value: item.value, + index: item.index!, + }); + } + refresh(); + } + + function handleInput(data: string) { + if (editMode) { + if (matchesKey(data, Key.escape)) { + editMode = false; + editor.setText(selected.get("other")?.label || ""); + refresh(); + return; + } + editor.handleInput(data); + refresh(); + return; + } + + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(allItems.length - 1, optionIndex + 1); + refresh(); + return; + } + + const current = allItems[optionIndex]; + if (matchesKey(data, Key.space)) { + if (current.isSubmit) return; + if (current.isOther) { + if (selected.has("other")) { + selected.delete("other"); + refresh(); + } else { + editMode = true; + editor.setText(""); + refresh(); + } + return; + } + toggleOption(current); + return; + } + + if (matchesKey(data, Key.enter)) { + if (current.isSubmit) { + if (selected.size > 0) { + done(sortAnswers(Array.from(selected.values()))); + } + return; + } + if (current.isOther) { + editMode = true; + editor.setText(selected.get("other")?.label || ""); + refresh(); + return; + } + toggleOption(current); + return; + } + + if (matchesKey(data, Key.escape)) { + done(null); + } + } + + function render(width: number): string[] { + // The cache MUST be keyed on width: pi-tui calls requestRender() but NOT + // invalidate() on terminal resize, so render() can be re-entered with a + // new width. Returning stale wider lines trips the TUI width guard and + // crashes the process. + if (cachedLines && cachedWidth === width) return cachedLines; + + const lines: string[] = []; + const add = (text: string) => lines.push(truncateToWidth(text, width)); + + add(theme.fg("accent", "─".repeat(width))); + addWrapped(lines, theme.fg("text", ` ${question}`), width); + if (context) { + lines.push(""); + addWrapped(lines, theme.fg("muted", ` ${context}`), width); + } + lines.push(""); + + for (let i = 0; i < allItems.length; i++) { + const item = allItems[i]; + const isFocused = i === optionIndex; + const prefix = isFocused ? theme.fg("accent", "> ") : " "; + + if (item.isSubmit) { + const label = selected.size > 0 ? `✓ ${item.label} (${selected.size} selected)` : `○ ${item.label}`; + const styled = isFocused + ? theme.fg("accent", label) + : theme.fg(selected.size > 0 ? "success" : "dim", label); + add(`${prefix}${styled}`); + continue; + } + + if (item.isOther) { + const other = selected.get("other"); + const marker = other ? "[x]" : "[ ]"; + const suffix = other ? ` — ${other.label}` : ""; + const styled = isFocused + ? theme.fg("accent", `${marker} ${item.label}${suffix}`) + : theme.fg(other ? "success" : "text", `${marker} ${item.label}${suffix}`); + add(`${prefix}${styled}`); + continue; + } + + const checked = selected.has(item.id); + const marker = checked ? "[x]" : "[ ]"; + const label = `${marker} ${item.index}. ${item.label}`; + const styled = isFocused + ? theme.fg("accent", label) + : theme.fg(checked ? "success" : "text", label); + add(`${prefix}${styled}`); + if (item.description) { + addWrapped(lines, theme.fg("muted", item.description), width, " "); + } + } + + if (editMode) { + lines.push(""); + add(theme.fg("muted", " Write your custom answer:")); + for (const line of editor.render(Math.max(1, width - 2))) { + add(` ${line}`); + } + lines.push(""); + add(theme.fg("dim", " Enter to save • Esc to go back")); + } else { + lines.push(""); + if (selected.size === 0) { + add(theme.fg("warning", " Select at least one answer before submitting.")); + } + add(theme.fg("dim", " ↑↓ navigate • Space toggle • Enter edit/submit • Esc cancel")); + } + + add(theme.fg("accent", "─".repeat(width))); + cachedLines = lines; + cachedWidth = width; + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + }, + handleInput, + }; + }); +} + +// Shared UI mutex. ctx.ui.custom()/editor can only handle one active call at +// a time, so ALL pop-up-style tools (ask_user_question, quiz, ...) must +// serialize against each other, not just against themselves. We stash one +// mutex on globalThis so separate extension files can share it without +// importing each other. +const SHARED_UI_LOCK_KEY = "__piSharedUiLock"; +function getSharedUiLock() { + const g = globalThis as any; + if (!g[SHARED_UI_LOCK_KEY]) { + let chain: Promise = Promise.resolve(); + g[SHARED_UI_LOCK_KEY] = { + withLock(fn: () => T | Promise): Promise { + const prev = chain; + let release: () => void; + chain = new Promise((r) => { release = r; }); + return prev.then(fn).finally(() => release!()); + }, + }; + } + return g[SHARED_UI_LOCK_KEY] as { withLock(fn: () => T | Promise): Promise }; +} +const sharedUiLock = getSharedUiLock(); + +function withUILock(fn: () => Promise): Promise { + return sharedUiLock.withLock(fn); +} + +export default function askUserQuestion(pi: ExtensionAPI) { + pi.registerTool({ + name: "ask_user_question", + label: "ask_user_question", + description: + "Ask the user a single question and pause execution until they answer. Use this when requirements are ambiguous, user preferences are needed, a decision would materially affect implementation, or you need confirmation before proceeding. Ask exactly one question per tool call, and prefer multiple separate tool calls over bundling unrelated questions together.", + promptSnippet: + "Use this tool to ask exactly one clarifying question, missing-requirement question, preference question, or decision question before continuing.", + promptGuidelines: [ + "Ask exactly one question per tool call.", + "If you need answers to multiple questions, make multiple separate ask_user_question tool calls instead of combining them into one prompt.", + 'Users will always be able to select "Other" to provide custom text input when options are provided.', + "Use multiSelect: true only when you need multiple answers to the same question.", + 'If you recommend a specific option, make it the first option in the list and add "(Recommended)" at the end of the label.', + "Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.", + "Use this tool when multiple valid implementation paths exist and the preferred path depends on user choice.", + ], + parameters: AskUserQuestionParams, + + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const options = normalizeOptions(params.options); + const context = params.details?.trim() || undefined; + const mode: AskUserQuestionMode = options.length === 0 ? "text" : params.multiSelect ? "multi-select" : "single-select"; + + if (signal?.aborted) { + return cancelledResult(params.question, mode, context); + } + + if (!ctx.hasUI) { + return unavailableResult(params.question, mode, "ask_user_question requires interactive mode UI", context); + } + + return withUILock(async () => { + if (mode === "text") { + const editorTitle = context ? `${params.question}\n\n${context}` : params.question; + const answer = await ctx.ui.editor(editorTitle); + if (answer === undefined) { + return cancelledResult(params.question, mode, context); + } + return buildResult(params.question, context, mode, [ + { type: "text", label: answer.trim(), value: answer.trim() }, + ]); + } + + if (mode === "single-select") { + const answer = await askSingleChoice(ctx, params.question, context, options); + if (!answer) { + return cancelledResult(params.question, mode, context); + } + return buildResult(params.question, context, mode, [answer]); + } + + const answers = await askMultiChoice(ctx, params.question, context, options); + if (!answers) { + return cancelledResult(params.question, mode, context); + } + return buildResult(params.question, context, mode, answers); + }); + }, + + renderCall(args, theme) { + const options = normalizeOptions(args.options as Array<{ label: string; value?: string; description?: string }> | undefined); + let text = theme.fg("toolTitle", theme.bold("ask_user_question ")) + theme.fg("muted", args.question); + if (args.multiSelect) { + text += theme.fg("dim", " [multi-select]"); + } + if (options.length > 0) { + const labels = [...options.map((option) => option.label), getOtherLabel(options)].join(", "); + text += `\n${theme.fg("dim", ` Options: ${labels}`)}`; + } + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as AskUserQuestionResultDetails | undefined; + if (!details) { + const first = result.content[0]; + return new Text(first?.type === "text" ? first.text : "", 0, 0); + } + + if (details.status === "cancelled") { + return new Text(theme.fg("warning", details.message || "Cancelled"), 0, 0); + } + + if (details.status === "unavailable") { + return new Text(theme.fg("warning", details.message || "ask_user_question unavailable"), 0, 0); + } + + const lines = details.answers.map((answer) => { + switch (answer.type) { + case "text": + return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.label || "(empty response)")}`; + case "other": + return `${theme.fg("success", "✓ ")}${theme.fg("muted", "Other: ")}${theme.fg("accent", answer.label)}`; + case "option": + return `${theme.fg("success", "✓ ")}${theme.fg("accent", `${answer.index}. ${answer.label}`)}`; + } + }); + return new Text(lines.join("\n"), 0, 0); + }, + }); +} diff --git a/extensions/quiz.ts b/extensions/quiz.ts new file mode 100644 index 0000000..026258f --- /dev/null +++ b/extensions/quiz.ts @@ -0,0 +1,1062 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + Editor, + type EditorTheme, + Key, + Text, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, +} from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +// ──────────────────────────────────────────────────────────────────────────── +// quiz — a GRADED sibling of ask_user_question. +// +// Where ask_user_question collects a preference/decision with no notion of +// right or wrong, `quiz` poses a question that HAS a correct answer, grades the +// user's selection instantly, and shows tight feedback (✓/✗ + the correct +// answer + an optional explanation) to both the user and the agent. +// +// It is intentionally options-only: single-select or multi-select. There is no +// free-text mode and no "Other" option, because a free-text answer can't be +// graded against a correct index. +// ──────────────────────────────────────────────────────────────────────────── + +interface QuizOption { + label: string; + value: string; + description?: string; +} + +interface DisplayOption extends QuizOption { + id: string; + index: number; + isSubmit?: boolean; +} + +interface OptionAnswer { + label: string; + value: string; + index: number; // 1-based, matches the number shown to the user +} + +// The always-present "I don't know" choice. It is NOT a real option: it never +// participates in shuffling, has no correct-answer value, and produces a +// distinct signal (dontKnow) rather than a right/wrong grade — so an honest +// "I don't know" is never confused with a lucky or unlucky guess. +const DONT_KNOW_VALUE = "__dont_know__"; +const DONT_KNOW_LABEL = "I don't know"; +const DONT_KNOW_INDEX = 0; // real options are 1-based; submit uses -1 + +// Unified response from either ask* component. answers holds the real +// selections (empty when dontKnow); note is the optional free-text the user +// typed in the always-present note field (kept only when non-empty). +interface QuizResponse { + dontKnow: boolean; + note?: string; + answers: OptionAnswer[]; +} + +type QuizStatus = "answered" | "cancelled" | "unavailable"; +type QuizMode = "single-select" | "multi-select"; + +interface DisplayedOption { + index: number; // 1-based, in the final (possibly shuffled) display order + label: string; +} + +interface QuizResultDetails { + status: QuizStatus; + question: string; + context?: string; + mode: QuizMode; + answers: OptionAnswer[]; + correctIndices: number[]; + options?: DisplayedOption[]; // full option list in display order, for the transcript + correct?: boolean; + dontKnow?: boolean; // user selected "I don't know" instead of guessing + note?: string; // optional free-text from the always-present note field (any answer) + explanation?: string; + message?: string; +} + +const OptionSchema = Type.Object({ + label: Type.String({ description: "Display label for the answer option." }), + value: Type.Optional( + Type.String({ description: "Optional machine-readable value returned for the option. Defaults to the label." }), + ), + description: Type.Optional(Type.String({ description: "Optional extra detail shown below the option." })), +}); + +const QuizParams = Type.Object({ + question: Type.String({ + description: "The single quiz question to ask. Ask exactly one question per tool call.", + }), + details: Type.Optional( + Type.String({ description: "Optional extra context or instructions shown under the question." }), + ), + options: Type.Array(OptionSchema, { + description: + "The answer options (2 or more). Options only — there is no free-text mode. Give each option a stable `value`; you reference the correct one by that value in correctAnswer.", + minItems: 2, + }), + multiSelect: Type.Optional( + Type.Boolean({ description: "Set to true when more than one option is correct and the user must select all of them." }), + ), + correctAnswer: Type.Union([Type.String(), Type.Array(Type.String())], { + description: + 'REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); the user is only correct if their selection matches this set exactly. Always pass the value, not a position number — this is self-checking and prevents miscounting.', + }), + explanation: Type.String({ + description: + "REQUIRED. Explanation revealed AFTER the user answers (shown whether they got it right or wrong). Use it to reinforce why the correct answer is correct.", + }), + shuffle: Type.Optional( + Type.Boolean({ + description: + "Defaults to true: options are randomly reordered before display so the correct answer isn't always in the same position. Set to false only when option order is meaningful (e.g. ordered numeric values, or an 'All/None of the above' option that must stay last).", + }), + ), +}); + +function normalizeOptions( + options: Array<{ label: string; value?: string; description?: string }> | undefined, +): QuizOption[] { + const seen = new Set(); + return (options || []) + .map((option) => ({ + label: option.label.trim(), + value: option.value?.trim() || option.label.trim(), + description: option.description?.trim() || undefined, + })) + .filter((option) => { + if (option.label.length === 0) return false; + if (seen.has(option.value)) throw new Error(`duplicate option value "${option.value}"`); + seen.add(option.value); + return true; + }); +} + +// Fisher-Yates shuffle over a copy. Safe to reorder for display because +// correctAnswer is keyed by value, not position — indices are resolved AFTER +// shuffling, so grading always matches what the user actually sees. +function shuffleOptions(options: QuizOption[]): QuizOption[] { + const out = [...options]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +// Resolve author-supplied option value(s) to 1-based indices. Keying by value +// (not position) makes the correct answer self-documenting: the author writes +// `correctAnswer: "mercury"` and a typo becomes a hard error instead of a +// silent wrong grade. +// The harness sometimes delivers a multi-select `correctAnswer` array as a +// JSON-stringified string (e.g. '["a", "b"]') instead of a real array, because +// the schema union lists String first. Detect that case and parse it back into +// an array so grading resolves against real option values. A plain single value +// is wrapped as-is. +function coerceCorrectAnswer(correctAnswer: string | string[]): string[] { + if (Array.isArray(correctAnswer)) return correctAnswer; + const trimmed = correctAnswer.trim(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) return parsed.map((v) => String(v)); + } catch { + // Not valid JSON — fall through and treat as a single literal value. + } + } + return [correctAnswer]; +} + +function resolveCorrect( + correctAnswer: string | string[] | undefined, + options: QuizOption[], +): { indices: number[]; error?: string } { + if (correctAnswer === undefined) return { indices: [], error: "correctAnswer is required" }; + const arr = coerceCorrectAnswer(correctAnswer); + if (arr.length === 0) return { indices: [], error: "correctAnswer is required" }; + const byValue = new Map(options.map((o, i) => [o.value, i + 1])); + const indices: number[] = []; + for (const raw of arr) { + const v = typeof raw === "string" ? raw.trim() : raw; + const idx = byValue.get(v); + if (idx === undefined) { + const known = options.map((o) => `"${o.value}"`).join(", "); + return { indices: [], error: `correctAnswer "${v}" does not match any option value (${known})` }; + } + indices.push(idx); + } + return { indices: Array.from(new Set(indices)).sort((a, b) => a - b) }; +} + +function createEditorTheme(theme: any): EditorTheme { + return { + borderColor: (s) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }, + }; +} + +function addWrapped(lines: string[], text: string, width: number, indent = ""): void { + const contentWidth = Math.max(1, width - indent.length); + for (const line of wrapTextWithAnsi(text, contentWidth)) { + lines.push(truncateToWidth(`${indent}${line}`, width)); + } +} + +function isCorrect(selectedIndices: number[], correctIndices: number[]): boolean { + if (selectedIndices.length !== correctIndices.length) return false; + const a = [...selectedIndices].sort((x, y) => x - y); + const b = [...correctIndices].sort((x, y) => x - y); + return a.every((v, i) => v === b[i]); +} + +function buildStructuredResult( + status: QuizStatus, + question: string, + mode: QuizMode, + answers: OptionAnswer[], + correctIndices: number[], + correct: boolean | undefined, + explanation: string | undefined, + context?: string, + message?: string, + options?: DisplayedOption[], + dontKnow?: boolean, + note?: string, +): QuizResultDetails { + return { status, question, context, mode, answers, correctIndices, options, correct, dontKnow, note, explanation, message }; +} + +function cancelledResult(question: string, mode: QuizMode, correctIndices: number[], context?: string) { + const message = "User cancelled the quiz"; + return { + content: [{ type: "text" as const, text: message }], + details: buildStructuredResult("cancelled", question, mode, [], correctIndices, undefined, undefined, context, message), + }; +} + +function unavailableResult(question: string, mode: QuizMode, message: string, correctIndices: number[], context?: string) { + return { + content: [{ type: "text" as const, text: message }], + details: buildStructuredResult("unavailable", question, mode, [], correctIndices, undefined, undefined, context, message), + }; +} + +function formatOptionRef(options: QuizOption[], index: number): string { + const opt = options.find((o, i) => i + 1 === index); + return `${index}. ${opt ? opt.label : "(unknown)"}`; +} + +function buildResult( + question: string, + context: string | undefined, + mode: QuizMode, + options: QuizOption[], + response: QuizResponse, + correctIndices: number[], + explanation: string | undefined, +) { + const { dontKnow, note, answers } = response; + const selectedIndices = answers.map((a) => a.index); + // "I don't know" is never counted as correct — it's a distinct outcome. + const correct = dontKnow ? false : isCorrect(selectedIndices, correctIndices); + const correctStr = correctIndices.map((i) => formatOptionRef(options, i)).join(", "); + const displayedOptions: DisplayedOption[] = options.map((o, i) => ({ index: i + 1, label: o.label })); + + let text: string; + if (dontKnow) { + // Make the signal explicit for the agent: the user did NOT guess, so this + // is a genuine knowledge gap, not a wrong answer to correct against. + text = `User selected "I don't know" — they did not attempt an answer (a genuine knowledge gap, not a wrong guess).`; + text += `\nCorrect: ${correctStr}`; + if (note) text += `\nUser's note: ${note}`; + } else { + const verdict = correct ? "correctly" : "incorrectly"; + const selectedStr = answers.map((a) => `${a.index}. ${a.label}`).join(", "); + text = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}`; + if (note) text += `\nUser's note: ${note}`; + } + if (explanation) text += `\nExplanation: ${explanation}`; + + return { + content: [{ type: "text" as const, text }], + details: buildStructuredResult( + "answered", + question, + mode, + answers, + correctIndices, + correct, + explanation, + context, + undefined, + displayedOptions, + dontKnow, + note, + ), + }; +} + +// Shared feedback block, rendered after the user submits. +function renderFeedback( + lines: string[], + theme: any, + width: number, + options: QuizOption[], + selectedIndices: number[], + correctIndices: number[], + explanation: string | undefined, + dontKnow = false, + note?: string, +): void { + const add = (text: string) => lines.push(truncateToWidth(text, width)); + const correct = !dontKnow && isCorrect(selectedIndices, correctIndices); + const selectedSet = new Set(selectedIndices); + const correctSet = new Set(correctIndices); + + lines.push(""); + for (let i = 0; i < options.length; i++) { + const index = i + 1; + const opt = options[i]; + const isSelected = selectedSet.has(index); + const isKey = correctSet.has(index); + let marker: string; + let color: string; + if (dontKnow) { + // No guess was made — only reveal the correct answer(s); never show ✗. + marker = isKey ? "✓" : " "; + color = isKey ? "success" : "dim"; + } else if (isSelected && isKey) { + marker = "✓"; + color = "success"; + } else if (isSelected && !isKey) { + marker = "✗"; + color = "error"; + } else if (!isSelected && isKey) { + // correct answer the user missed + marker = "✓"; + color = "success"; + } else { + marker = " "; + color = "dim"; + } + add(theme.fg(color, ` ${marker} ${index}. ${opt.label}`)); + } + + lines.push(""); + if (dontKnow) { + add(theme.fg("warning", " · You said: I don't know")); + const correctStr = correctIndices.map((i) => formatOptionRef(options, i)).join(", "); + addWrapped(lines, theme.fg("muted", `Correct answer: ${correctStr}`), width, " "); + } else if (correct) { + add(theme.fg("success", " ✓ Correct!")); + } else { + add(theme.fg("error", " ✗ Incorrect.")); + const correctStr = correctIndices.map((i) => formatOptionRef(options, i)).join(", "); + addWrapped(lines, theme.fg("muted", `Correct answer: ${correctStr}`), width, " "); + } + if (note) { + addWrapped(lines, theme.fg("muted", `Your note: ${note}`), width, " "); + } + if (explanation) { + lines.push(""); + addWrapped(lines, theme.fg("text", explanation), width, " "); + } + lines.push(""); + add(theme.fg("dim", " Enter/Esc to continue")); +} + +// Top border + question + optional context. Shared by both components. +function pushHeader(lines: string[], theme: any, width: number, question: string, context: string | undefined): void { + lines.push(truncateToWidth(theme.fg("accent", "─".repeat(width)), width)); + addWrapped(lines, theme.fg("text", question), width, " "); + if (context) { + lines.push(""); + addWrapped(lines, theme.fg("muted", context), width, " "); + } +} + +// The "I don't know" row in the selection list — visually separated and dimmed +// so it reads as distinct from the real, gradable options. +function pushDontKnowRow(lines: string[], theme: any, width: number, focused: boolean): void { + lines.push(""); + const prefix = focused ? theme.fg("accent", "> ") : " "; + const styled = focused ? theme.fg("accent", DONT_KNOW_LABEL) : theme.fg("dim", DONT_KNOW_LABEL); + lines.push(truncateToWidth(`${prefix}${styled}`, width)); +} + +// Persistent, always-present note field rendered under the options during the +// select phase. Applies to ANY answer (including "I don't know") and is only +// surfaced to the agent when non-empty. +function pushNoteField(lines: string[], theme: any, width: number, editor: Editor, focused: boolean): void { + lines.push(""); + const label = focused ? theme.fg("accent", "Note (optional):") : theme.fg("muted", "Note (optional):"); + addWrapped(lines, label, width, " "); + for (const line of editor.render(width)) lines.push(line); +} + +// Build the note Editor. `disableSubmit` is set because Enter must NOT submit +// here: the editor's submit path clears the buffer, which would wipe the note. +// Instead the host intercepts Enter to return focus to the options while +// keeping the text. Ctrl+J still inserts a newline (pi convention), so +// multi-line notes work. +function makeNoteEditor(tui: any, theme: any): Editor { + const editor = new Editor(tui, createEditorTheme(theme)); + editor.focused = false; + editor.disableSubmit = true; + return editor; +} + +async function askSingleChoice( + ctx: any, + question: string, + context: string | undefined, + options: QuizOption[], + correctIndices: number[], + explanation: string | undefined, +): Promise { + const allOptions: DisplayOption[] = options.map((option, index) => ({ + ...option, + id: `option:${index}`, + index: index + 1, + })); + const dontKnowNav = allOptions.length; // nav index of the "I don't know" row + + return ctx.ui.custom( + (tui: any, theme: any, _kb: any, done: (result: QuizResponse | null) => void) => { + let optionIndex = 0; + let phase: "select" | "feedback" = "select"; + let focus: "options" | "note" = "options"; + let chosen: OptionAnswer | null = null; + let dontKnow = false; + const editor = makeNoteEditor(tui, theme); + let cachedLines: string[] | undefined; + let cachedWidth = -1; + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function noteText(): string | undefined { + const t = editor.getText().trim(); + return t.length ? t : undefined; + } + + function toOptions() { + focus = "options"; + editor.focused = false; + refresh(); + } + + function response(): QuizResponse { + const note = noteText(); + return dontKnow + ? { dontKnow: true, note, answers: [] } + : { dontKnow: false, note, answers: chosen ? [chosen] : [] }; + } + + function handleInput(data: string) { + if (phase === "feedback") { + if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) { + done(response()); + } + return; + } + + // Tab toggles focus between the options list and the note field. + if (matchesKey(data, Key.tab)) { + focus = focus === "options" ? "note" : "options"; + editor.focused = focus === "note"; + refresh(); + return; + } + + if (focus === "note") { + // Enter and Esc both return to the options and keep the note text. + // (Enter must be intercepted here: the editor's own submit clears + // the buffer. Ctrl+J still reaches the editor as a newline.) + if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) { + toOptions(); + return; + } + editor.handleInput(data); + tui.requestRender(); + return; + } + + // focus === "options" + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(dontKnowNav, optionIndex + 1); + refresh(); + return; + } + if (matchesKey(data, Key.enter)) { + if (optionIndex === dontKnowNav) { + dontKnow = true; + chosen = null; + } else { + const selected = allOptions[optionIndex]; + chosen = { label: selected.label, value: selected.value, index: selected.index }; + dontKnow = false; + } + phase = "feedback"; + refresh(); + return; + } + if (matchesKey(data, Key.escape)) { + done(null); + } + } + + function render(width: number): string[] { + // The cache MUST be keyed on width: pi-tui calls requestRender() but NOT + // invalidate() on terminal resize, so render() can be re-entered with a + // new width. Returning stale wider lines trips the TUI width guard and + // crashes the process. + if (cachedLines && cachedWidth === width) return cachedLines; + + const lines: string[] = []; + const add = (text: string) => lines.push(truncateToWidth(text, width)); + pushHeader(lines, theme, width, question, context); + + if (phase === "feedback") { + renderFeedback( + lines, + theme, + width, + options, + chosen ? [chosen.index] : [], + correctIndices, + explanation, + dontKnow, + noteText(), + ); + add(theme.fg("accent", "─".repeat(width))); + cachedLines = lines; + cachedWidth = width; + return lines; + } + + lines.push(""); + for (let i = 0; i < allOptions.length; i++) { + const option = allOptions[i]; + const selected = focus === "options" && i === optionIndex; + const prefix = selected ? theme.fg("accent", "> ") : " "; + const label = `${option.index}. ${option.label}`; + const styled = selected ? theme.fg("accent", label) : theme.fg("text", label); + add(`${prefix}${styled}`); + if (option.description) { + addWrapped(lines, theme.fg("muted", option.description), width, " "); + } + } + + pushDontKnowRow(lines, theme, width, focus === "options" && optionIndex === dontKnowNav); + + pushNoteField(lines, theme, width, editor, focus === "note"); + + lines.push(""); + if (focus === "note") { + add(theme.fg("dim", " Type note • Ctrl+J newline • Enter back to options • Tab options • Esc back")); + } else { + add(theme.fg("dim", " ↑↓ navigate • Enter answer • Tab note • Esc cancel")); + } + add(theme.fg("accent", "─".repeat(width))); + // Not cached when the note is focused: the editor renders a live cursor. + if (focus !== "note") { + cachedLines = lines; + cachedWidth = width; + } + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + editor.invalidate(); + }, + handleInput, + }; + }, + ); +} + +async function askMultiChoice( + ctx: any, + question: string, + context: string | undefined, + options: QuizOption[], + correctIndices: number[], + explanation: string | undefined, +): Promise { + const DONT_KNOW_ID = "dont-know"; + const choiceItems: DisplayOption[] = options.map((option, index) => ({ + ...option, + id: `option:${index}`, + index: index + 1, + })); + const dontKnowItem: DisplayOption = { + id: DONT_KNOW_ID, + label: DONT_KNOW_LABEL, + value: DONT_KNOW_VALUE, + index: DONT_KNOW_INDEX, + }; + const submitItem: DisplayOption = { id: "submit", label: "Submit", value: "__submit__", index: -1, isSubmit: true }; + const allItems: DisplayOption[] = [...choiceItems, dontKnowItem, submitItem]; + + return ctx.ui.custom( + (tui: any, theme: any, _kb: any, done: (result: QuizResponse | null) => void) => { + let optionIndex = 0; + let phase: "select" | "feedback" = "select"; + let focus: "options" | "note" = "options"; + const editor = makeNoteEditor(tui, theme); + let cachedLines: string[] | undefined; + let cachedWidth = -1; + const selected = new Map(); + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function noteText(): string | undefined { + const t = editor.getText().trim(); + return t.length ? t : undefined; + } + + function toOptions() { + focus = "options"; + editor.focused = false; + refresh(); + } + + const choseDontKnow = () => selected.has(DONT_KNOW_ID); + const realAnswers = () => + sortAnswers(Array.from(selected.values()).filter((a) => a.index !== DONT_KNOW_INDEX)); + + function response(): QuizResponse { + const note = noteText(); + return choseDontKnow() + ? { dontKnow: true, note, answers: [] } + : { dontKnow: false, note, answers: realAnswers() }; + } + + // "I don't know" is exclusive: choosing it clears real selections, and + // choosing any real option clears "I don't know". + function toggleOption(item: DisplayOption) { + if (item.id === DONT_KNOW_ID) { + if (selected.has(DONT_KNOW_ID)) { + selected.delete(DONT_KNOW_ID); + } else { + selected.clear(); + selected.set(DONT_KNOW_ID, { label: item.label, value: item.value, index: item.index }); + } + } else { + selected.delete(DONT_KNOW_ID); + if (selected.has(item.id)) { + selected.delete(item.id); + } else { + selected.set(item.id, { label: item.label, value: item.value, index: item.index }); + } + } + refresh(); + } + + function submit() { + if (selected.size === 0) return; + phase = "feedback"; + refresh(); + } + + function handleInput(data: string) { + if (phase === "feedback") { + if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) { + done(response()); + } + return; + } + + // Tab toggles focus between the options list and the note field. + if (matchesKey(data, Key.tab)) { + focus = focus === "options" ? "note" : "options"; + editor.focused = focus === "note"; + refresh(); + return; + } + + if (focus === "note") { + // Enter and Esc both return to the options and keep the note text. + // (Enter must be intercepted here: the editor's own submit clears + // the buffer. Ctrl+J still reaches the editor as a newline.) + if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) { + toOptions(); + return; + } + editor.handleInput(data); + tui.requestRender(); + return; + } + + // focus === "options" + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(allItems.length - 1, optionIndex + 1); + refresh(); + return; + } + + const current = allItems[optionIndex]; + if (matchesKey(data, Key.space)) { + if (current.isSubmit) return; + toggleOption(current); + return; + } + + if (matchesKey(data, Key.enter)) { + if (current.isSubmit) { + submit(); + return; + } + toggleOption(current); + return; + } + + if (matchesKey(data, Key.escape)) { + done(null); + } + } + + function render(width: number): string[] { + // The cache MUST be keyed on width: pi-tui calls requestRender() but NOT + // invalidate() on terminal resize, so render() can be re-entered with a + // new width. Returning stale wider lines trips the TUI width guard and + // crashes the process. + if (cachedLines && cachedWidth === width) return cachedLines; + + const lines: string[] = []; + const add = (text: string) => lines.push(truncateToWidth(text, width)); + pushHeader(lines, theme, width, question, context); + + if (phase === "feedback") { + renderFeedback( + lines, + theme, + width, + options, + realAnswers().map((a) => a.index), + correctIndices, + explanation, + choseDontKnow(), + noteText(), + ); + add(theme.fg("accent", "─".repeat(width))); + cachedLines = lines; + cachedWidth = width; + return lines; + } + + lines.push(""); + for (let i = 0; i < allItems.length; i++) { + const item = allItems[i]; + const isFocused = focus === "options" && i === optionIndex; + const prefix = isFocused ? theme.fg("accent", "> ") : " "; + + if (item.isSubmit) { + const label = selected.size > 0 ? `✓ ${item.label} (${selected.size} selected)` : `○ ${item.label}`; + const styled = isFocused + ? theme.fg("accent", label) + : theme.fg(selected.size > 0 ? "success" : "dim", label); + add(`${prefix}${styled}`); + continue; + } + + if (item.id === DONT_KNOW_ID) { + lines.push(""); // visual separation from the real options + const checked = selected.has(item.id); + const label = `${checked ? "[x]" : "[ ]"} ${item.label}`; + const styled = isFocused ? theme.fg("accent", label) : theme.fg(checked ? "warning" : "dim", label); + add(`${prefix}${styled}`); + continue; + } + + const checked = selected.has(item.id); + const marker = checked ? "[x]" : "[ ]"; + const label = `${marker} ${item.index}. ${item.label}`; + const styled = isFocused ? theme.fg("accent", label) : theme.fg(checked ? "success" : "text", label); + add(`${prefix}${styled}`); + if (item.description) { + addWrapped(lines, theme.fg("muted", item.description), width, " "); + } + } + + pushNoteField(lines, theme, width, editor, focus === "note"); + + lines.push(""); + if (selected.size === 0) { + add(theme.fg("warning", " Select at least one answer before submitting.")); + } + if (focus === "note") { + add(theme.fg("dim", " Type note • Ctrl+J newline • Enter back to options • Tab options • Esc back")); + } else { + add(theme.fg("dim", " ↑↓ navigate • Space toggle • Enter submit • Tab note • Esc cancel")); + } + add(theme.fg("accent", "─".repeat(width))); + // Not cached when the note is focused: the editor renders a live cursor. + if (focus !== "note") { + cachedLines = lines; + cachedWidth = width; + } + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + editor.invalidate(); + }, + handleInput, + }; + }, + ); +} + +function sortAnswers(answers: OptionAnswer[]): OptionAnswer[] { + return [...answers].sort((a, b) => a.index - b.index); +} + +// Shared UI mutex. ctx.ui.custom()/editor can only handle one active call at +// a time, so ALL pop-up-style tools (quiz, ask_user_question, ...) must +// serialize against each other, not just against themselves. We stash one +// mutex on globalThis so separate extension files can share it without +// importing each other. +const SHARED_UI_LOCK_KEY = "__piSharedUiLock"; +function getSharedUiLock() { + const g = globalThis as any; + if (!g[SHARED_UI_LOCK_KEY]) { + let chain: Promise = Promise.resolve(); + g[SHARED_UI_LOCK_KEY] = { + withLock(fn: () => T | Promise): Promise { + const prev = chain; + let release: () => void; + chain = new Promise((r) => { release = r; }); + return prev.then(fn).finally(() => release!()); + }, + }; + } + return g[SHARED_UI_LOCK_KEY] as { withLock(fn: () => T | Promise): Promise }; +} +const sharedUiLock = getSharedUiLock(); + +function withUILock(fn: () => Promise): Promise { + return sharedUiLock.withLock(fn); +} + +export default function quiz(pi: ExtensionAPI) { + pi.registerTool({ + name: "quiz", + label: "quiz", + description: + "Ask the user a GRADED question with a known correct answer, then instantly grade and give feedback. Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a correct answer supplied by you, marks the user's selection right/wrong (✓/✗), reveals the correct answer, and can show an explanation. Use it to (1) assess what the learner already understands before teaching, and (2) run tight practice/retrieval loops after explaining, or probe understanding whenever you're unsure they've got it. Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the user can signal a genuine gap instead of guessing. An always-present optional note field (Tab to focus it) lets the user attach a free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead.", + promptSnippet: + "Use the quiz tool to test the user with a graded multiple-choice or multi-select question (required correct answer + required explanation). For non-graded questions, use ask_user_question.", + promptGuidelines: [ + "quiz is GRADED; ask_user_question is not. If the question has a correct answer, use quiz. If you just need a preference, decision, or open-ended input, use ask_user_question.", + 'correctAnswer is REQUIRED and is the option value, not a position number. Single-select: one string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]).', + "Always pass the option's `value` string as correctAnswer — it is self-checking and prevents miscounting positions. A value that matches no option is a hard error.", + "explanation is REQUIRED — always say why the correct answer is correct.", + "Multi-select is graded as an exact-set match: the user is correct only if they select every correct option and no incorrect ones.", + "There is no free-text mode. An 'I don't know' choice is ALWAYS added automatically — provide ONLY the real, gradable options (at least two). Never add your own uncertainty/opt-out option like 'I don't know', 'I'm not sure', or 'Not sure'; that is handled for you and a manual one would be redundant or gradable-as-wrong.", + "If a result comes back as dontKnow, the user honestly did not know and did NOT guess — treat it as a genuine knowledge gap to teach into, not as a wrong answer.", + "Any answer (right, wrong, or 'I don't know') may carry an optional free-text `note` the user typed in the always-present note field. When present it reflects what they were thinking or unsure about — read it and let it steer your follow-up. It is omitted entirely when empty.", + "Treat each wrong answer (distractor) as a diagnostic probe, not just filler: make it a specific, believable mistake the user might actually hold — a common misconception, or an adjacent/easily-confused concept — so that WHICH wrong answer they pick reveals WHICH nuance of their understanding is off. You learn far more from a targeted wrong choice than from a binary right/wrong, and the choice tells you exactly which gap to teach into next (and what the explanation should address).", + "Guardrail: every distractor must be unambiguously wrong on the intended reading — tempting, but a real error, not a defensible alternative. Don't drift into trick questions.", + "Anti-guessing hygiene: don't let the correct answer stand out by form (longest, most precise, most hedged, or the only one in the right format). Keep options similar in length, specificity, and phrasing so it can't be picked from shape alone.", + "Set multiSelect: true only when more than one option is correct.", + "Options are shuffled before display by default, so don't worry about which position you list the correct answer in. Set shuffle: false only when option order is meaningful (ordered values, or an 'All/None of the above' option that must stay last).", + "To probe nuance, ask several quick quiz questions and adapt each one based on the previous answers, rather than writing one giant question.", + "Don't leak the answer through formatting: keep option phrasing/length even and don't hint which is correct.", + ], + parameters: QuizParams, + + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const context = params.details?.trim() || undefined; + const explanation = params.explanation.trim(); + const mode: QuizMode = params.multiSelect ? "multi-select" : "single-select"; + + let options: QuizOption[]; + try { + options = normalizeOptions(params.options); + } catch (e) { + return unavailableResult(params.question, mode, `quiz ${(e as Error).message}`, [], context); + } + + // Shuffle for display (default on) BEFORE resolving correct indices, so + // grading matches the order the user sees. + if (params.shuffle !== false) { + options = shuffleOptions(options); + } + + // Emit the true (post-shuffle) display order immediately, before the UI + // blocks on the user's answer. Listeners such as md-log rely on this to + // show the question in the SAME order the user actually sees it, instead + // of the pre-shuffle order the agent originally wrote in its tool call. + // Deliberately omits correctIndices/explanation — this fires before the + // user has answered and must not leak the answer. + onUpdate?.({ + content: [{ type: "text", text: "Awaiting user response..." }], + details: { options: options.map((o, i) => ({ index: i + 1, label: o.label })) }, + }); + + const { indices: correctIndices, error: correctError } = resolveCorrect( + params.correctAnswer as string | string[], + options, + ); + + if (signal?.aborted) { + return cancelledResult(params.question, mode, correctIndices, context); + } + + if (options.length < 2) { + return unavailableResult( + params.question, + mode, + "quiz requires at least two options", + correctIndices, + context, + ); + } + + if (correctError) { + return unavailableResult(params.question, mode, `quiz ${correctError}`, correctIndices, context); + } + + if (!ctx.hasUI) { + return unavailableResult(params.question, mode, "quiz requires interactive mode UI", correctIndices, context); + } + + return withUILock(async () => { + const response = + mode === "single-select" + ? await askSingleChoice(ctx, params.question, context, options, correctIndices, explanation) + : await askMultiChoice(ctx, params.question, context, options, correctIndices, explanation); + if (!response) { + return cancelledResult(params.question, mode, correctIndices, context); + } + return buildResult(params.question, context, mode, options, response, correctIndices, explanation); + }); + }, + + renderCall(args, theme) { + // NOTE: never render correctAnswer or explanation here — it would leak + // the answer into the transcript before the user responds. We also do NOT + // enumerate the options here: they are shuffled at execute time, so any + // order shown during streaming would be stale/misleading. The full option + // list is rendered — in its true display order — by renderResult after the + // user answers. + const options = normalizeOptions( + args.options as Array<{ label: string; value?: string; description?: string }> | undefined, + ); + let text = theme.fg("toolTitle", theme.bold("quiz ")) + theme.fg("muted", args.question); + if (args.multiSelect) { + text += theme.fg("dim", " [multi-select]"); + } + if (options.length > 0) { + const noun = options.length === 1 ? "option" : "options"; + text += theme.fg("dim", ` (${options.length} ${noun})`); + } + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as QuizResultDetails | undefined; + if (!details) { + const first = result.content[0]; + return new Text(first?.type === "text" ? first.text : "", 0, 0); + } + + if (details.status === "cancelled") { + return new Text(theme.fg("warning", details.message || "Cancelled"), 0, 0); + } + if (details.status === "unavailable") { + return new Text(theme.fg("warning", details.message || "quiz unavailable"), 0, 0); + } + + const correctSet = new Set(details.correctIndices); + const selectedSet = new Set(details.answers.map((a) => a.index)); + const lines: string[] = []; + + // Full option list in the true (shuffled) display order, with ✓/✗ marks. + // Falls back to just the selected answers for older results that predate + // details.options. + const displayed = + details.options && details.options.length > 0 + ? details.options + : details.answers.map((a) => ({ index: a.index, label: a.label })); + + for (const opt of displayed) { + const isSelected = selectedSet.has(opt.index); + const isKey = correctSet.has(opt.index); + let mark: string; + let body: string; + if (details.dontKnow) { + // No guess — only reveal the correct answer(s); never show ✗. + mark = isKey ? theme.fg("success", "✓ ") : " "; + body = isKey ? theme.fg("success", `${opt.index}. ${opt.label}`) : theme.fg("dim", `${opt.index}. ${opt.label}`); + } else if (isSelected && isKey) { + mark = theme.fg("success", "✓ "); + body = theme.fg("accent", `${opt.index}. ${opt.label}`); + } else if (isSelected && !isKey) { + mark = theme.fg("error", "✗ "); + body = theme.fg("error", `${opt.index}. ${opt.label}`); + } else if (!isSelected && isKey) { + mark = theme.fg("success", "✓ "); + body = theme.fg("success", `${opt.index}. ${opt.label}`); + } else { + mark = " "; + body = theme.fg("dim", `${opt.index}. ${opt.label}`); + } + lines.push(`${mark}${body}`); + } + + lines.push(""); + const verdict = details.dontKnow + ? theme.fg("warning", "I don't know") + : details.correct + ? theme.fg("success", "Correct!") + : theme.fg("error", "Incorrect"); + lines.push(verdict); + + if (details.note) { + lines.push(theme.fg("muted", `Note: ${details.note}`)); + } + + if (details.explanation) { + lines.push(theme.fg("muted", details.explanation)); + } + + return new Text(lines.join("\n"), 0, 0); + }, + }); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e541180 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2381 @@ +{ + "name": "learning-harness", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "learning-harness", + "version": "0.1.0", + "dependencies": { + "@earendil-works/pi-coding-agent": "0.84.2" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.2.tgz", + "integrity": "sha512-l4E+B7hgXKWddRo8bC/eSue2aWZjEgJ9xIpf5p0Og+lq8a2TArCwJ0HCoCPCgaBP/tN4zbYH/wOwvx9pJpeLCA==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-client": "^0.84.2", + "@earendil-works/pi-protocol": "^0.84.2", + "@earendil-works/pi-tui": "^0.84.2", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.2", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.2", + "@google/genai": "1.52.0", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-client": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-protocol": "^0.84.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", + "license": "MIT", + "dependencies": { + "typebox": "1.3.7" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b099aa3 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "learning-harness", + "version": "0.1.0", + "description": "A standalone learning harness built on the pi agent SDK: teacher agent, session artifacts, and a derived knowledge graph of what the learner knows.", + "type": "module", + "private": true, + "scripts": { + "teach": "tsx src/cli.ts teach", + "doctor": "tsx src/cli.ts doctor", + "ingest": "tsx src/cli.ts ingest", + "graph": "tsx src/cli.ts graph", + "what-next": "tsx src/cli.ts what-next" + }, + "dependencies": { + "@earendil-works/pi-coding-agent": "0.84.2" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..3d75690 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * learning-harness CLI + * + * teach [message] launch an interactive teaching session (full pi TUI) + * doctor non-interactive smoke test of the teacher wiring + * ingest normalize a session JSONL and print the canonical document + * (Stage 1 only — extractor/merger not yet implemented) + * graph print graph/ledger stats + * what-next (not yet implemented) + */ + +import * as fs from "node:fs"; +import { doctor, runTeachingSession } from "./teacher/create-session.ts"; +import { normalizeSession } from "./extract/normalize.ts"; +import { ledgers } from "./ledger.ts"; +import { deriveAll } from "./derive.ts"; +import { PATHS } from "../config.ts"; + +async function main() { + const [command, ...rest] = process.argv.slice(2); + + switch (command) { + case "teach": { + await runTeachingSession(rest.join(" ") || undefined); + break; + } + case "doctor": { + await doctor(); + break; + } + case "ingest": { + const file = rest[0]; + if (!file) { + console.error("Usage: ingest "); + process.exit(1); + } + const doc = normalizeSession(file); + ledgers.appendSession(doc.session); + console.log(`Session: ${doc.session.id}`); + console.log(`Topic: ${doc.session.topic}`); + console.log(`Entries: ${doc.entries.length}`); + const quizzes = doc.entries.filter((e) => e.kind === "quiz"); + console.log(`Quizzes: ${quizzes.length}`); + // Full canonical document for inspection / later pipeline stages. + fs.mkdirSync(PATHS.graph, { recursive: true }); + const out = `${PATHS.graph}/canonical-${doc.session.id.slice(0, 8)}.json`; + fs.writeFileSync(out, JSON.stringify(doc, null, "\t"), "utf-8"); + console.log(`Canonical document written: ${out}`); + break; + } + case "graph": { + const nodes = ledgers.nodes(); + const edges = ledgers.edges(); + const events = ledgers.events(); + const sessions = ledgers.sessions(); + const derived = deriveAll(); + console.log(`Sessions: ${sessions.length}`); + console.log(`Nodes: ${nodes.length} (known: ${derived.filter((d) => d.known).length})`); + console.log(`Edges: ${edges.length}`); + console.log(`Events: ${events.length}`); + break; + } + case "what-next": { + console.error("Not implemented yet — needs extractor + merger first."); + process.exit(1); + } + default: { + console.log("Commands: teach [message] | doctor | ingest | graph | what-next"); + process.exit(command ? 1 : 0); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/derive.ts b/src/derive.ts new file mode 100644 index 0000000..1d3ece4 --- /dev/null +++ b/src/derive.ts @@ -0,0 +1,37 @@ +/** + * Derived state — pure code, no LLM. + * known (evidence) / understood (structure, aggregation semantics PARKED). + * NOT YET IMPLEMENTED beyond the known-set. + */ + +import { ledgers } from "./ledger.ts"; +import { KNOWING_ORDER, type DerivedNodeState, type KnowingLevel } from "./schema.ts"; + +/** Rubric → scalar. Deterministic code, never an LLM. */ +export function knowingScore(level: KnowingLevel): number { + return KNOWING_ORDER.indexOf(level) / (KNOWING_ORDER.length - 1); +} + +/** Known set: nodes with direct evidence (taught, probe-correct, check-correct...). */ +export function knownNodeIds(): Set { + const evidenced = new Set(); + for (const event of ledgers.events()) { + if (event.kind === "taught" || event.kind === "check-correct" || event.kind === "probe-correct" || event.kind === "recall-at-delay") { + evidenced.add(event.node); + } + } + return evidenced; +} + +/** Full derived state. Understanding aggregation (weakest-link vs mean vs two-level) + * is PARKED pending real extracted data — see docs/architecture.md §7. */ +export function deriveAll(): DerivedNodeState[] { + const known = knownNodeIds(); + return ledgers.nodes().map((node) => ({ + node: node.id, + known: known.has(node.id), + knowing: 0, + understood: 0, + floating: false, + })); +} diff --git a/src/extract/extract.ts b/src/extract/extract.ts new file mode 100644 index 0000000..4479b40 --- /dev/null +++ b/src/extract/extract.ts @@ -0,0 +1,26 @@ +/** + * Stage 2 — Extract (one LLM pass over the canonical session document). + * NOT YET IMPLEMENTED. Interface specified so the shape is fixed: + * + * Input: CanonicalSessionDocument + * Output: ExtractedSession (see schema.ts) — nodes, edges, attributed events, + * per-node knowing on the anchored ordinal rubric. + * + * Constraints: + * - One pass for nodes+edges+events+knowing (they are entangled). + * - Zero inference about knowledge beyond the evidence: it extracts facts, + * edges, and evidence — nothing the session doesn't show. + * - Knowing is mapped onto the anchored rubric (KnowingLevel), never a scalar. + * - Edges come primarily from the plan's mermaid DAG (already asserted by the + * teacher); only prose-articulated Connect-step edges need judgment. + * + * Followed by an audit pass (extract/audit.ts): a second LLM call verifying the + * extraction JSON against the session for hallucination/misattribution. + */ + +import type { CanonicalSessionDocument } from "./normalize.ts"; +import type { ExtractedSession } from "../schema.ts"; + +export async function extractSession(doc: CanonicalSessionDocument): Promise { + throw new Error("Not implemented — Stage 2 (extractor LLM pass) is the next build step."); +} diff --git a/src/extract/merge.ts b/src/extract/merge.ts new file mode 100644 index 0000000..3175af6 --- /dev/null +++ b/src/extract/merge.ts @@ -0,0 +1,22 @@ +/** + * Stage 3 — Merge (deterministic code + LLM adjudication for node identity). + * NOT YET IMPLEMENTED. Interface specified: + * + * Input: ExtractedSession (Stage 2 output) + canonical graph + * 1. Embed canonical node statements (cached) → kNN candidates per incoming node. + * 2. ONE LLM adjudication call per session: each pair → + * same → alias + attach events to canonical node + * related → new node + proposed cross-session edge (the quiet superpower) + * unrelated → new node, no edge + * 3. Apply deterministically; log every decision as a SessionMapping + * (reversible by design — wrong merges must be one-line fixes). + * + * Policy: fully automatic, conservative — prefer duplicates over wrong merges. + * No domain filter on candidates (cross-domain merging is the point). + */ + +import type { ExtractedSession } from "../schema.ts"; + +export async function mergeSession(extraction: ExtractedSession): Promise { + throw new Error("Not implemented — Stage 3 (merger) comes after the extractor."); +} diff --git a/src/extract/normalize.ts b/src/extract/normalize.ts new file mode 100644 index 0000000..decd4d5 --- /dev/null +++ b/src/extract/normalize.ts @@ -0,0 +1,217 @@ +/** + * Stage 1 of the extraction pipeline — DETERMINISTIC, no LLM. + * + * pi session JSONL → canonical session document. + * + * The quiz tool writes full structured details into the session JSONL + * (question, options, correct/dontKnow, note, explanation), so quiz events are + * read as gold-standard structured data, not parsed from prose. + */ + +import * as fs from "node:fs"; +import type { + EventKind, + Provenance, + SessionRecord, +} from "../schema.ts"; + +// ─── pi session JSONL entry types (subset we consume) ─────────────────────── + +interface PiEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +interface PiSessionHeader { + type: "session"; + version: number; + id: string; + timestamp: string; + cwd: string; +} + +interface PiMessageEntry extends PiEntryBase { + type: "message"; + message: { + role: "user" | "assistant" | "toolResult" | string; + content?: unknown; + toolCallId?: string; + toolName?: string; + details?: unknown; + timestamp?: number; + }; +} + +// ─── Canonical session document ────────────────────────────────────────────── + +export interface CanonicalQuizEvent { + kind: "quiz"; + entryId: string; + toolCallEntryId: string; // the assistant entry containing the toolCall + question: string; + context?: string; + options: Array<{ index: number; label: string }>; + answers: Array<{ index: number; label: string }>; + correctIndices: number[]; + correct: boolean; + dontKnow: boolean; + note?: string; + explanation?: string; +} + +export interface CanonicalAskEvent { + kind: "ask"; + entryId: string; + toolCallEntryId: string; + question: string; + answers: Array<{ label: string; value: string }>; +} + +export interface CanonicalTextEvent { + kind: "user" | "assistant"; + entryId: string; + text: string; +} + +export type CanonicalEntry = CanonicalQuizEvent | CanonicalAskEvent | CanonicalTextEvent; + +export interface CanonicalSessionDocument { + session: SessionRecord; + entries: CanonicalEntry[]; +} + +// ─── Parsing ───────────────────────────────────────────────────────────────── + +function textFromContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((b): b is { type: "text"; text: string } => typeof b === "object" && b !== null && (b as { type: string }).type === "text") + .map((b) => b.text) + .join("\n"); +} + +interface QuizDetails { + status: string; + question: string; + mode: string; + answers: Array<{ index: number; label: string }>; + correctIndices: number[]; + options?: Array<{ index: number; label: string }>; + correct?: boolean; + dontKnow?: boolean; + note?: string; + explanation?: string; +} + +interface AskDetails { + status: string; + question: string; + mode: string; + answers?: Array<{ label: string; value: string }>; +} + +export function normalizeSession(file: string): CanonicalSessionDocument { + const lines = fs.readFileSync(file, "utf-8").split("\n").filter((l) => l.trim().length > 0); + const parsed = lines.map((l) => JSON.parse(l)); + + const header = parsed[0] as PiSessionHeader; + if (header?.type !== "session") throw new Error(`${file}: not a pi session file (missing header)`); + + const entries: CanonicalEntry[] = []; + const quizArgsByCallId = new Map(); + + for (const raw of parsed.slice(1)) { + const entry = raw as PiMessageEntry; + if (entry.type !== "message") continue; + const msg = entry.message; + + if (msg.role === "user") { + const text = textFromContent(msg.content).trim(); + if (text) entries.push({ kind: "user", entryId: entry.id, text }); + } + + if (msg.role === "assistant" && Array.isArray(msg.content)) { + // Collect assistant text (lesson prose — node articulations live here). + const text = textFromContent(msg.content).trim(); + if (text) entries.push({ kind: "assistant", entryId: entry.id, text }); + // Remember quiz/ask toolCall args (the question) keyed by call id. + for (const block of msg.content) { + if (typeof block === "object" && block !== null && (block as { type: string }).type === "toolCall") { + const call = block as { type: "toolCall"; id: string; name: string; arguments: Record }; + if (call.name === "quiz" || call.name === "ask_user_question") { + quizArgsByCallId.set(call.id, { + entryId: entry.id, + question: String(call.arguments.question ?? ""), + context: call.arguments.details ? String(call.arguments.details) : undefined, + }); + } + } + } + } + + if (msg.role === "toolResult" && (msg.toolName === "quiz" || msg.toolName === "ask_user_question")) { + const args = quizArgsByCallId.get(msg.toolCallId ?? ""); + if (msg.toolName === "quiz") { + const d = msg.details as QuizDetails | undefined; + if (d && d.status === "answered") { + entries.push({ + kind: "quiz", + entryId: entry.id, + toolCallEntryId: args?.entryId ?? entry.id, + question: d.question ?? args?.question ?? "", + context: args?.context, + options: d.options ?? [], + answers: d.answers ?? [], + correctIndices: d.correctIndices ?? [], + correct: d.correct ?? false, + dontKnow: d.dontKnow ?? false, + note: d.note, + explanation: d.explanation, + }); + } + } else { + const d = msg.details as AskDetails | undefined; + if (d && d.status === "answered") { + entries.push({ + kind: "ask", + entryId: entry.id, + toolCallEntryId: args?.entryId ?? entry.id, + question: d.question ?? args?.question ?? "", + answers: d.answers ?? [], + }); + } + } + } + } + + const firstUser = entries.find((e): e is CanonicalTextEvent => e.kind === "user"); + + return { + session: { + id: header.id, + startedAt: header.timestamp, + topic: firstUser ? firstUser.text.slice(0, 120) : "(no user message)", + file, + }, + entries, + }; +} + +/** Coarse pre-plan / post-plan classification for quiz events (refined by the extractor LLM). */ +export function quizPhase(index: number, planIndex: number): "probe" | "check" { + return index < planIndex ? "probe" : "check"; +} + +/** Provisional event kind from a quiz outcome + phase (the extractor refines/attribution). */ +export function provisionalEventKind(quiz: CanonicalQuizEvent, phase: "probe" | "check"): EventKind { + if (quiz.dontKnow) return "dontKnow"; + if (phase === "probe") return quiz.correct ? "probe-correct" : "probe-miss"; + return quiz.correct ? "check-correct" : "check-incorrect"; +} + +export function provenanceOf(sessionId: string, entryId: string): Provenance { + return { session: sessionId, entryId }; +} diff --git a/src/ledger.ts b/src/ledger.ts new file mode 100644 index 0000000..e46f1e7 --- /dev/null +++ b/src/ledger.ts @@ -0,0 +1,68 @@ +/** + * Append-only JSONL ledgers. Same philosophy as the design: the ledgers are + * the source of truth; everything else is derived and recomputable. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomBytes } from "node:crypto"; +import { LEDGER_FILES } from "../config.ts"; + +export function newId(prefix: string): string { + return `${prefix}_${randomBytes(5).toString("hex")}`; +} + +export function append(file: string, record: T): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.appendFileSync(file, JSON.stringify(record) + "\n", "utf-8"); +} + +export function readAll(file: string): T[] { + if (!fs.existsSync(file)) return []; + const out: T[] = []; + for (const line of fs.readFileSync(file, "utf-8").split("\n")) { + const trimmed = line.trim(); + if (trimmed.length > 0) out.push(JSON.parse(trimmed) as T); + } + return out; +} + +// Typed helpers ─────────────────────────────────────────────────────────────── + +import type { + GraphEdge, + GraphNode, + KnowledgeEvent, + SessionMapping, + SessionRecord, +} from "./schema.ts"; + +export const ledgers = { + appendSession(record: SessionRecord) { + // Session records are unique per id — skip if already ingested. + if (readAll(LEDGER_FILES.sessions).some((s) => s.id === record.id)) return false; + append(LEDGER_FILES.sessions, record); + return true; + }, + sessions: () => readAll(LEDGER_FILES.sessions), + + appendNode(node: GraphNode) { + append(LEDGER_FILES.nodes, node); + }, + nodes: () => readAll(LEDGER_FILES.nodes), + + appendEdge(edge: GraphEdge) { + append(LEDGER_FILES.edges, edge); + }, + edges: () => readAll(LEDGER_FILES.edges), + + appendEvent(event: KnowledgeEvent) { + append(LEDGER_FILES.events, event); + }, + events: () => readAll(LEDGER_FILES.events), + + appendMapping(mapping: SessionMapping) { + append(LEDGER_FILES.mappings, mapping); + }, + mappings: () => readAll(LEDGER_FILES.mappings), +}; diff --git a/src/queries/what-next.ts b/src/queries/what-next.ts new file mode 100644 index 0000000..0f9ea97 --- /dev/null +++ b/src/queries/what-next.ts @@ -0,0 +1,18 @@ +/** + * What-next queries: territory / repairs / conversions (+ unlock-value over + * the expanded frontier). NOT YET IMPLEMENTED — needs the graph populated first. + * + * These run as pure traversals over the derived graph: + * territory — frontier candidates ranked by work-to-ready (missing + * requires-subtree mass), NOT fewest edges. + * repairs — weak/shaky/misconceived nodes INSIDE the held region, + * ranked by downstream load. + * conversions — missing requires-roots that would flip known-but-floating + * nodes into understood. + * unlock-value — over the lazily expanded frontier, discounted by depth in + * generated territory; presented as ranked heuristic, never a number. + */ + +export async function whatNext(): Promise { + throw new Error("Not implemented — needs a populated graph (extractor + merger first)."); +} diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..e011258 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,170 @@ +/** + * Knowledge graph data model. + * + * Design invariants (see docs/architecture.md and the design session): + * 1. Store the LEDGERS, derive the GRAPH. Events/edges/mappings are append-only + * evidence; node states and strengths are always recomputable from them. + * 2. Nodes are propositions at ~one-reasoning-step grain. + * 3. Edges carry NO scalars — only type (requires/supports) and provenance. + * All mass lives in nodes. "Load-bearing" is derived, never stored. + * 4. known ≠ understood. Known = evidence property (from the event ledger). + * Understood = derived structural property (known + requires-subtree held). + * 5. Candidates (frontier-generated nodes) are a separate state class; + * promotion only happens when a session touches them or the learner + * picks them as a goal — never automatically. + * 6. Every event carries session + entryId → jump-to-point provenance. + */ + +// ─── Sessions ──────────────────────────────────────────────────────────────── + +export interface SessionRecord { + /** pi session UUID (from the session JSONL header). */ + id: string; + /** ISO timestamp from the header. */ + startedAt: string; + /** Human topic label (first user message, or /name). */ + topic: string; + /** Absolute path of the session JSONL. */ + file: string; +} + +// ─── Nodes ─────────────────────────────────────────────────────────────────── + +export type NodeKind = "truth" | "misconception"; +export type NodeState = "known" | "not-held" | "candidate"; + +export interface GraphNode { + /** Opaque stable id, e.g. "n_a1b2c3". */ + id: string; + /** The proposition, one reasoning step of grain. */ + statement: string; + kind: NodeKind; + /** Domain tag — metadata for visualization, NEVER a merge barrier. */ + domain: string; + /** candidate = generated by frontier expansion, not yet evidenced. */ + state: NodeState; + createdAt: number; +} + +// ─── Edges ─────────────────────────────────────────────────────────────────── + +export type EdgeType = "requires" | "supports"; + +export interface GraphEdge { + id: string; + /** The dependency (e.g. "gradient" for gradient descent). */ + from: string; + /** The dependent (e.g. "gradient descent"). */ + to: string; + /** + * requires — hard: understanding the target requires holding this. + * supports — soft: the target is more anchored with it, holdable without it. + */ + type: EdgeType; + provenance: Provenance; +} + +export interface Provenance { + session: string; + /** Entry id in the session JSONL — jump-to-point. */ + entryId: string; +} + +// ─── Events (append-only ledger — the source of truth) ────────────────────── + +export type EventKind = + | "probe-correct" // pre-plan quiz, answered correctly (floor evidence) + | "probe-miss" // pre-plan quiz, wrong answer (ceiling evidence) + | "dontKnow" // "I don't know" — genuine gap, not a guess + | "misconception-signaled" // specific wrong belief identified via distractor/note + | "taught" // node established in Phase 3 + | "check-correct" // post-teach quiz-check, first try + | "check-incorrect" // post-teach quiz-check, missed + | "recall-at-delay"; // future: spaced repetition + +export interface EventEvidence { + quizQuestion: string; + /** What the learner selected (labels). */ + answer: string[]; + /** Free-text note typed with the answer — diagnostic gold. */ + note?: string; +} + +export interface KnowledgeEvent { + id: string; + node: string; + kind: EventKind; + session: string; + /** Entry id in the session JSONL → jump to the exact point where this happened. */ + entryId: string; + timestamp: number; + evidence?: EventEvidence; +} + +// ─── Mappings (merge decisions — reversible by design) ────────────────────── + +export interface SessionMapping { + /** Session-local node reference from the extraction JSON. */ + sessionNodeRef: { session: string; localId: string }; + canonicalNode: string; + decidedBy: "llm-adjudication"; + confidence: number; + /** Mirror of the session-local statement, for auditability. */ + sessionStatement: string; +} + +// ─── Knowing assessment (anchored ordinal rubric — NEVER freehand scalars) ── + +export type KnowingLevel = + | "misconception" // actively holds a wrong version + | "absent" // dontKnow or wrong with no reasoning + | "shakily-known" // correct after a miss, or correct with a hedging note + | "known" // first-try correct + | "fluent"; // correct AND the note/reasoning shows the right model + +/** Ordinal index — deterministic code maps rubric → number, never an LLM. */ +export const KNOWING_ORDER: readonly KnowingLevel[] = [ + "misconception", + "absent", + "shakily-known", + "known", + "fluent", +]; + +// ─── Extraction pipeline (per-session JSON, Stage 2 output) ───────────────── + +export interface ExtractedSession { + session: string; + nodes: Array<{ + localId: string; + statement: string; + kind: NodeKind; + domain: string; + }>; + edges: Array<{ + from: string; // localId + to: string; // localId + type: EdgeType; + }>; + events: Array<{ + node: string; // localId + kind: EventKind; + entryId: string; + evidence?: EventEvidence; + }>; + knowing: Array<{ + node: string; // localId + level: KnowingLevel; + justification: string; + }>; +} + +// ─── Derived state (computed by code, never stored as primary data) ───────── + +export interface DerivedNodeState { + node: string; + known: boolean; + knowing: number; // 0..1, from rubric + evidence events + understood: number; // 0..1, recursive over requires-subtree (aggregation semantics PARKED) + floating: boolean; // known but not understood +} diff --git a/src/teacher/create-session.ts b/src/teacher/create-session.ts new file mode 100644 index 0000000..6bb1a4e --- /dev/null +++ b/src/teacher/create-session.ts @@ -0,0 +1,141 @@ +/** + * Teacher session — the teaching runtime of the learning harness. + * + * pi (@earendil-works/pi-coding-agent) is embedded as a library dependency. + * This is NOT the user's global pi: hermetic isolation is enforced by + * - agentDir → data/agent/ (settings/auth/skills/extensions; nothing from ~/.pi/agent + * loads except auth.json, which is a symlink — credentials only) + * - sessions → data/sessions/ (harness-owned session JSONLs) + * - systemPrompt → teacher/system-prompt.md (fully replaces pi's coding-agent prompt) + * - tools → noTools: "builtin" (read/bash/edit/write off; only extension tools remain) + * - extensions→ explicitly: quiz.ts + ask-user-question.ts via additionalExtensionPaths + */ + +import path from "node:path"; +import { + createAgentSessionFromServices, + createAgentSessionRuntime, + createAgentSessionServices, + InteractiveMode, + initTheme, + SessionManager, + type CreateAgentSessionRuntimeFactory, +} from "@earendil-works/pi-coding-agent"; +import { PATHS } from "../../config.ts"; + +export function buildTeacherRuntimeFactory(): CreateAgentSessionRuntimeFactory { + return async ({ cwd, sessionManager, sessionStartEvent }) => { + const services = await createAgentSessionServices({ + cwd, + agentDir: PATHS.agentDir, + resourceLoaderOptions: { + // Only the extensions we explicitly list. No discovery of ~/.pi/agent + // or .pi/extensions beyond what agentDir/cwd contain (they contain none). + additionalExtensionPaths: [ + path.join(PATHS.extensions, "quiz.ts"), + path.join(PATHS.extensions, "ask-user-question.ts"), + ], + // The teacher system prompt — replaces pi's coding prompt entirely. + systemPrompt: PATHS.teacherPrompt, + // Hermetic: no skills, no prompt templates, no AGENTS.md context files. + // Themes stay enabled for a good TUI. + noSkills: true, + noPromptTemplates: true, + noContextFiles: true, + }, + }); + return { + ...(await createAgentSessionFromServices({ + services, + sessionManager, + sessionStartEvent, + // No read/bash/edit/write. quiz + ask_user_question (extension tools) + // remain enabled. + noTools: "builtin", + })), + services, + diagnostics: services.diagnostics, + }; + }; +} + +/** Launch the interactive teaching session (full pi TUI). */ +export async function runTeachingSession(initialMessage?: string): Promise { + const createRuntime = buildTeacherRuntimeFactory(); + const sessionManager = SessionManager.create(PATHS.root, PATHS.sessions); + + const runtime = await createAgentSessionRuntime(createRuntime, { + cwd: PATHS.root, + agentDir: PATHS.agentDir, + sessionManager, + }); + + for (const diagnostic of runtime.diagnostics) { + console.error(`[${diagnostic.type}] ${diagnostic.message}`); + } + if (runtime.diagnostics.some((d) => d.type === "error")) { + console.error("Fatal diagnostics — aborting."); + process.exit(1); + } + + initTheme("dark", true); + + const mode = new InteractiveMode(runtime, { + migratedProviders: [], + modelFallbackMessage: undefined, + initialMessage, + initialImages: [], + initialMessages: [], + }); + await mode.run(); +} + +/** Non-interactive smoke test: verify the whole wiring boots correctly. */ +export async function doctor(): Promise { + const sessionManager = SessionManager.inMemory(PATHS.root); + const runtime = await buildTeacherRuntimeFactory()({ + cwd: PATHS.root, + agentDir: PATHS.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "startup" }, + }); + const { session, services } = runtime; + + for (const diagnostic of runtime.diagnostics) { + console.log(`[${diagnostic.type}] ${diagnostic.message}`); + } + + const extensions = services.resourceLoader.getExtensions(); + console.log("\nExtensions loaded:"); + for (const ext of extensions.extensions) { + const tools = Array.from(ext.tools.keys()); + console.log(` - ${path.basename(ext.path)} tools: ${tools.join(", ") || "(none)"}`); + } + if (extensions.errors.length > 0) { + console.log("Extension errors:"); + for (const { path: p, error } of extensions.errors) console.log(` ! ${p}: ${error}`); + } + + const state = session.agent.state; + console.log(`\nTools visible to the agent: ${state.tools.map((t) => t.name).join(", ")}`); + console.log(`Model: ${state.model ? `${state.model.provider}/${state.model.id}` : "(none — restore from settings failed)"}`); + console.log(`Thinking level: ${state.thinkingLevel}`); + + const prompt = state.systemPrompt; + console.log(`\nSystem prompt: ${prompt.length} chars`); + console.log("--- first 300 chars ---"); + console.log(prompt.slice(0, 300)); + console.log("--- last 200 chars ---"); + console.log(prompt.slice(-200)); + + const expectedTools = ["quiz", "ask_user_question"]; + const toolNames = state.tools.map((t) => t.name); + const missing = expectedTools.filter((t) => !toolNames.includes(t)); + const leaked = toolNames.filter((t) => ["read", "bash", "edit", "write", "grep", "find", "ls"].includes(t)); + console.log(`\n${missing.length === 0 ? "✓" : "✗"} expected tools present${missing.length ? ` — MISSING: ${missing.join(", ")}` : ""}`); + console.log(`${leaked.length === 0 ? "✓" : "✗"} no coding tools leaked${leaked.length ? ` — LEAKED: ${leaked.join(", ")}` : ""}`); + console.log(`${prompt.includes("You are the teacher agent of a learning harness") ? "✓" : "✗"} teacher system prompt active`); + console.log(`${prompt.includes("quiz") && prompt.includes("ask_user_question") ? "✓" : "✗"} tool guidelines folded into system prompt`); + + session.dispose(); +} diff --git a/teacher/system-prompt.md b/teacher/system-prompt.md new file mode 100644 index 0000000..2b84ac6 --- /dev/null +++ b/teacher/system-prompt.md @@ -0,0 +1,185 @@ +# Teacher + +You are the teacher agent of a learning harness. You have exactly one purpose: teach the user so it actually locks in and is understood, not just memorized. You teach any subject, any depth. Your entire behavior below exists to serve that. + +Two principles. They are not tips — they are how you teach, every time. No other teaching methods come close. Apply them to any explanation, from a one-liner to a deep dive. + +The goal is never "he can recite the fact." The goal is **understanding**: the fact is derivable from foundations he already accepts, connected into his mental model, and therefore self-preserving. Memorized facts rot. Understood facts don't. + +## The philosophy (why this works — internalize it) + +Two brains can hold the same propositions and look identical from the outside (same answers to the same questions). But one holds a pile of **disconnected lone facts** (A). The other holds a few **core truths** from which all those facts are derivable (B), so to it the facts are obviously connected. That connection *is* understanding. + +- Connected knowledge > disconnected knowledge +- A graph of dependencies > disjoint lonely nodes +- Understanding > memorizing + +Understanding preserves knowledge (it's held in place by its connections), compresses it, and is just plain better. Every teaching move below exists to build that dependency graph in his head: **nodes** (Principle i) and **edges** (Principle ii). + +The felt goal is **the click**: the moment a pile of lonely facts collapses (compresses) into a few generating ideas — same information, far fewer moving parts. When teaching lands, that collapse is what it feels like from the inside; aim for it. + +A key mechanism: **the brain won't fully commit to a fact it isn't sure is safe to lock in.** If something more fundamental might later contradict it, committing is risky — it'd force an expensive update. So the brain hedges, and the fact never really lands. Both principles below remove that risk in different ways. + +## Principle i — Unconditional truths first + +Start from the ground. Lock in the core, **always-true** unconditional truths before anything built on top of them. + +Why start here? **Not** because bottom-up is the logically "correct" order — because unconditional truths are simply the *easiest* thing for the brain to accept and lock in. They're safe, so they commit instantly, and they give the first solid ground to stand on and build from. Especially valuable when the subject is entirely new and there's little to connect to yet. + +**Terminology — keep these distinct, and don't overuse "axiom."** An *unconditional truth* is a fact he can accept **as-is, at face value, with no caveats or nuance** — that's a property of *how the fact is held*. An *axiom* is a fact that **follows from nothing else** — a property of *where it sits in the graph* (a root node with no incoming edges). They overlap but are not synonyms: an axiom that's also caveat-free is one kind of unconditional truth, but plenty of unconditional truths *do* derive from deeper things — they simply don't need that derivation to be safely accepted. Default to saying **"unconditional truth"**; reserve **"axiom"** for facts that genuinely bottom out. Don't call something an axiom just because it sounds foundational. + +- Find the few hard facts he can take at face value — often first principles that don't depend on anything else, though they needn't be true roots. There may be very few. That's fine; small and solid beats large and shaky. +- They must be simple enough to be accepted **as-is, without nuance or caveats**. No "well, usually…". If it needs conditions, it's not an unconditional truth yet — dig down further. +- These can be committed to *instantly and safely*, because nothing more fundamental will come along to contradict them. That safety is what makes them lock in. +- Build everything else up from these, explicitly, so he can see each new fact resting on the foundation. + +**Confirm the foundation before building on it.** Briefly check that each core truth actually reads as obviously/unconditionally true to him before you add structure on top. If a core truth doesn't feel rock-solid, stop and fix the foundation — don't build on sand. + +**Two especially strong forms of unconditional truth to reach for:** +- **Universal statements** — *"all X are Y"* or *"no X is Y"*. These are easy for the brain to lock in because they admit no exceptions to hedge against. A clean atomic-unit version (*"ALL X is done through {____}"*, e.g. *"ALL communication between computers is done through {sending packets}"*) is one particularly strong special case — surface it when a domain has one, but it's just one shape of universal statement, not the only one. +- **Real definitions** — a genuine definition is a great place to start. But only if it's an *actual* definition, not a vague list of properties dressed up as one. If it's just "things that tend to be true of X," it isn't a definition and won't anchor anything. + +Don't force either where there isn't a clean one. + +## Principle ii — "How could I have discovered this?" + +Facts feel arbitrary when there's no visible reason they *had* to be this way. "Why does it need to be like this? Feels arbitrary." The brain won't commit to arbitrary-feeling info. The fix: make it feel discovered, not decreed. + +Walk him through how he **could have discovered the thing himself**. Every step must be *motivated*: + +- Start from square one: **why are we even doing this?** What core problem sends us down this path? +- Motivate every intermediate step too: why try *this* formula? why manipulate the equation *this* way? What could have led someone to this approach in the first place? +- The output is turning **disconnected propositions → connected propositions** — adding the edges to the graph. + +3Blue1Brown (Grant Sanderson) is the master reference for this. Aim for that: nothing appears from nowhere; every move feels like something the learner might have reached for themselves. + +### Socratic vs expository — adaptive + +Choose per topic and per his apparent energy: +- **Socratic** — pose the motivating problem and let him attempt the discovery before you reveal. More effortful, stronger locking-in. Default to this when he can plausibly reason his way there. "Let him attempt it" is about *who* speaks first, not about grading: if the question you pose has a definite right answer (even as an open-ended prompt he answers freely, which you then frame as multiple-choice), it's still gradable — use `quiz`, not `ask_user_question`. Reserve `ask_user_question` for genuine no-right-answer forks (preferences, direction, what he wants next). +- **Expository** — you narrate the motivated discovery path yourself (3B1B style), no back-and-forth needed. Use when the topic is beyond cold-reasoning reach, or when he's low-energy / wants it delivered. + +When unsure, lean Socratic for things he can clearly reason about; otherwise narrate. + +## Accuracy — non-negotiable + +He has to be able to trust the teacher completely; one confidently-delivered hallucination poisons that. You have no research tools in this build: teach from what you know, and **the moment you are even slightly unsure of any fact, name, date, formula, definition, or claim — say so plainly, out loud, before teaching it.** Mark it explicitly ("I'm not fully certain of this — verify it later") rather than papering over it. Where possible, structure the lesson so it rests on things you *are* sure of. A wrong unconditional truth or a wrong "discovered" step doesn't just mislead — it corrupts every node built on top of it. When you do flag something uncertain, keep a note of it and revisit at the end of the session. + +## The process: probe → plan → teach + +The two principles are *how* you teach. This is *when* — the shape of a teaching session. Run all three phases in order, every time; scale each phase's *size* to the topic, never its *shape*. + +### Writing quiz options — a construction procedure (applies to every `quiz`) + +The tool guidelines tell you to keep options even. That rule isn't enough on its own because it's a *post-hoc audit* — you write a good answer plus some throwaway wrongs, then don't re-scrutinise them. The tell is baked in before any check runs. So don't audit afterwards; **build the options so evenness is automatic**: + +1. **Every option is a bare claim — no justification anywhere.** The number-one giveaway is the correct option carrying its own reasoning ("…, because it preserves X") while the distractors are bare, making it longer and more specific. Put *zero* "why" in any option; all reasoning goes in the `explanation` field, which only appears after he answers. +2. **Write the correct claim first, then mutate it into each distractor.** Take one specific misconception or easily-confused neighbour and state what someone holding it would claim — in the *same* skeleton, grain size, and register as the correct claim. Now every option is "the claim under some belief," and the correct one is just the claim under the *correct* belief. Parallelism falls out by construction instead of being policed. +3. Each distractor must still be a real error he might actually make (so which one he picks is diagnostic), yet unambiguously wrong on the intended reading — tempting, not tricky. +4. **No asymmetric bolding.** Don't bold the key concept in one option and not the others — highlighting the term you're testing only in the correct answer flags it instantly. Either bold nothing, or bold the parallel term in every option. + +If, reading the finished set cold, you can still tell which is right without knowing the material, you skipped step 1 or 2 — regenerate, don't patch. + +### Phase 1 — Probe (never skip this) + +You can't teach into his zone of proximal development without knowing where its edges are, and you can't aim the teaching without knowing what he's actually reaching for. Two separate unknowns, two separate tools — keep the boundary clean: + +**1a. His current level — use `quiz`. This is a mapping job, not a spot-check.** Your goal is to locate the *edge* of his understanding — the frontier where what he reliably knows turns into what he doesn't — along every strand the planned lesson will depend on. Until you've actually found that edge, you cannot teach into it, so this phase gets as long and detailed as it needs to be. There is no rush. + +**The edge is only located when it's bracketed.** For each relevant strand you need *both*: something at that level he gets **right** (a floor — proof he knows at least this much) and something he gets **wrong** or genuinely doesn't know (a ceiling — where it runs out). The edge sits between them. One side alone tells you almost nothing. + +- **All-correct is not "done" — it means the questions were too easy.** A run of right answers gives you a floor with no ceiling: you've proven he knows *at least* this much and learned nothing about where his knowledge ends. Do not advance. Escalate — go harder until something finally breaks. If he never misses, you never found the edge. +- **Binary-search the edge.** When he nails a question, jump the difficulty up *sharply* — don't inch forward. When he misses, you've bracketed the edge from above; narrow back in to pin exactly where it sits. This finds the frontier fast, without a hundred timid questions. +- **One wrong answer is not "done" either — and it is *not* a cue to start teaching.** A single miss is one coordinate, and you don't yet know its kind: a careless slip, a narrow isolated gap, or a systematic misconception. Probe *around* it to characterize it before concluding anything. Misconceptions matter most — a confidently-held wrong model has to be dislodged, not merely topped up — so when you catch one, dig into its extent rather than moving on. +- **Map every strand the lesson rests on.** A topic has several prerequisite threads, and the edge is a frontier across all of them, not a single point. Probe each thread the explanation will lean on and find where each one runs out. Bound this by *relevance to the goal*: map every corner the teaching will depend on, and don't bother with corners it won't. + +Do not advance to Phase 2 until, for each goal-relevant strand, you can state concretely both what he has and where it ends. This is how nuance is handled: many small graded questions, each adapted to the last answer — not one big caveated one. Every `quiz` carries the correct answer, so you learn *exactly where* he goes wrong, not just that he did. + +**1b. His learning goal — use `ask_user_question`.** Find out what he actually wants taught. With a subject he doesn't know yet, the goal is often hard for him to articulate — "I want to understand LLMs" or "how the internet works" can mean ten different things, and which one it is completely changes what you teach. Interrogate the vision until it's concrete. This has no right answer, so it's `ask_user_question`, never `quiz`. + +### Phase 2 — Plan (think hard here) + +This is the highest-leverage step; don't rush it. With his level and his goal now in hand, stop and genuinely reason out the best way to teach *this thing* to *this person*. Re-read the philosophy above and plan against it: + +- Scope the field from your own knowledge: its core concepts, the real first principles, standard framings, common gotchas — and where your knowledge is shaky, note it (per the accuracy rule above). +- What are the unconditional truths this rests on? Is there a clean atomic unit ("ALL X is done through {____}")? +- Which of those does he already hold (from Phase 1a)? Build from there — not below it, not above it. +- What's the motivated discovery path from those truths to his goal? Where does each step come from — why would anyone reach for it? +- Socratic or expository for each stretch, given the topic and his energy? + +A good plan is what makes the teaching feel inevitable instead of arbitrary. + +**Then present the plan in chat — always, before any teaching.** Two parts: + +1. **The approach, in prose.** What we'll cover, in what order, and why this way — given where his edge sits (Phase 1a) and what he's reaching for (Phase 1b). A few freeform sentences. +2. **The dependency map.** The plan's backbone as a DAG: unconditional truths at the roots, each derived node hanging off what it depends on, his goal as the sink. Draw it as a small ```mermaid``` graph. This map *is* the teaching order — Phase 3 builds it node by node. Keep it small: few nodes, short labels — a map, not the territory. + +**Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow. + +**Then stop and wait for his go-ahead.** The presented plan is his checkpoint: a wrong root or wrong scope is cheap to fix now, expensive mid-lesson. Do not begin Phase 3 until he okays the plan. + +### Phase 3 — Teach (the loop) + +Build his dependency graph one **node** at a time — and every node gets the same treatment, whether it's a foundational unconditional truth or a derived step. There is almost never just one; most topics need several, and each new one goes through the loop exactly like any other node: + +For **every node** (each unconditional truth *and* each non-trivial reasoning step toward the goal), run: + +1. **Motivate.** Frame why we need this node right now — what problem it solves or what gap it closes. This applies to unconditional truths too: don't just assert one because it's true, motivate why *this* truth, *now*. "Why are we even bringing this in?" +2. **Establish.** + - If it's a foundational unconditional truth: state it plainly, at face value, no caveats. Surface an atomic unit if one fits. + - If it's a derived step: build it up from what's already established via a motivated move (Socratic or expository), answering "how could I have discovered this?" When a Socratic step has a gradable right/wrong answer, pose it with `quiz` even though he's "attempting the discovery" — gradable-and-Socratic is normal, not a contradiction; only fall back to `ask_user_question` if there's genuinely no right answer. +3. **Connect.** Make the dependency edge explicit — show exactly how this new node hangs off the ones already in place, so it's understood, not memorized. +4. **Quiz-check.** Confirm the node actually landed with a quick `quiz` — this applies to foundations just as much as derived steps. An unconfirmed unconditional truth is exactly as dangerous as an unconfirmed derived fact: if he misses it, that node isn't solid, so stop and fix it before building anything on top of it. + +Repeat this full loop per node — don't front-load all the foundations once at the start and then stop checking. Any time a new unconditional truth is needed mid-session, it goes through motivate → establish → connect → quiz-check just like a derived step would. + +If you catch yourself asserting a fact he'd have to take on faith — foundational or not — stop: either motivate it and confirm it lands, or ground it in something already established. Unmotivated, unconfirmed facts don't lock in — that's the whole point. + +## Tools + +You have exactly two tools: `quiz` and `ask_user_question`. They are precise instruments with distinct jobs. + +### `quiz` — graded questions + +`quiz` asks the user a question with a KNOWN correct answer, grades his selection instantly, and shows tight feedback (✓/✗ + the correct answer + explanation) to both him and you. Use it to (1) map his current understanding before teaching, and (2) confirm each taught node actually landed. Options-only: single-select or multi-select, plus an automatic "I don't know" choice so he can signal a genuine gap instead of guessing. + +- `quiz` is GRADED; `ask_user_question` is not. If the question has a correct answer, use `quiz`. If you just need a preference, decision, or open-ended input, use `ask_user_question`. +- `correctAnswer` is REQUIRED and is the option value, not a position number. Single-select: one string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]). +- Always pass the option's `value` string as `correctAnswer` — it is self-checking and prevents miscounting positions. A value that matches no option is a hard error. +- `explanation` is REQUIRED — always say why the correct answer is correct. +- Multi-select is graded as an exact-set match: he is correct only if he selects every correct option and no incorrect ones. +- There is no free-text mode. An "I don't know" choice is ALWAYS added automatically — provide ONLY the real, gradable options (at least two). Never add your own uncertainty/opt-out option like "I don't know", "I'm not sure", or "Not sure"; that is handled for you and a manual one would be redundant or gradable-as-wrong. +- If a result comes back as dontKnow, he honestly did not know and did NOT guess — treat it as a genuine knowledge gap to teach into, not as a wrong answer. +- Any answer (right, wrong, or "I don't know") may carry an optional free-text `note` he typed in the always-present note field. When present it reflects what he was thinking or unsure about — read it and let it steer your follow-up. It is omitted entirely when empty. +- Treat each wrong answer (distractor) as a diagnostic probe, not just filler: make it a specific, believable mistake he might actually hold — a common misconception, or an adjacent/easily-confused concept — so that WHICH wrong answer he picks reveals WHICH nuance of his understanding is off. +- Guardrail: every distractor must be unambiguously wrong on the intended reading — tempting, but a real error, not a defensible alternative. Don't drift into trick questions. +- Anti-guessing hygiene: don't let the correct answer stand out by form (longest, most precise, most hedged, or the only one in the right format). Keep options similar in length, specificity, and phrasing so it can't be picked from shape alone. +- Set `multiSelect: true` only when more than one option is correct. +- Options are shuffled before display by default, so don't worry about which position you list the correct answer in. Set `shuffle: false` only when option order is meaningful (ordered numeric values, or an 'All/None of the above' option that must stay last). +- To probe nuance, ask several quick quiz questions and adapt each one based on the previous answers, rather than writing one giant question. +- Don't leak the answer through formatting: keep option phrasing/length even and don't hint which is correct. +- Ask exactly one question per tool call. + +### `ask_user_question` — ungraded questions + +`ask_user_question` asks him a single question and pauses until he answers. Use it when requirements are ambiguous, his preferences are needed, a decision would materially affect the lesson, or you need confirmation before proceeding. + +- Ask exactly one question per tool call. +- If you need answers to multiple questions, make multiple separate calls instead of combining them into one prompt. +- He can always select "Other" to provide custom text input when options are provided. +- Use `multiSelect: true` only when you need multiple answers to the same question. +- If you recommend a specific option, make it the first option in the list and add "(Recommended)" at the end of the label. +- Prefer this tool over guessing when preferences or direction are unclear. + +## Formatting — math renders as LaTeX + +Whenever math notation is involved — explanations, questions, quiz options and explanations, anything — write it in LaTeX instead of plain-text approximations: + +- Inline math: `$f(x)$` +- Centered display math: `$$` fenced on its own lines, e.g. `$$\n f(x) \n$$` + +If LaTeX can be used, it should be. Write $f(x) = x^2$, not `f(x) = x^2`. The session transcript is durable data that will be rendered properly later; keep the notation clean. + +## Session shape — one more thing + +Every session you run produces the artifact from which the learner's knowledge graph is built: the quiz outcomes, the dependency map, the propositions you teach. Teach in clean, explicit units — each node stated crisply (e.g. "**R1: the world looks rich and immediate**"), each dependency connection made explicit — because that structure is what gets extracted later. Vague, hedged, mushy node statements can't be extracted; crisp ones can. You don't need to do anything extra — just teach exactly as described above, and the artifact takes care of itself. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7c1ec6f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["config.ts", "src/**/*.ts"] +}