v3: AI SDK pivot — pi removed, Next.js 16 + AI SDK 7 + AI Elements teacher app
The teaching agent no longer runs on pi. Stack: - ToolLoopAgent (ai@7) + OpenRouter (OPENROUTER_API_KEY), model via LH_MODEL, default anthropic/claude-sonnet-4.5 with reasoning tokens - quiz/ask_user_question = client-side tools (no server execute): the call lands as a typed tool part in input-available state; the card grades via src/core/question-core.ts; addToolOutput sends the verdict; the teacher's reaction continues the same assistant message - resume-mid-quiz is free: the pending tool part persists in the transcript - persistence: one JSON per chat (data/chats/), full UIMessage transcripts, saved onFinish (also on abort) - UI: AI Elements (owned copy-paste components, pruned to the five in use) + button-based QuizCard/AnsweredCard (ask answers never graded) - pi fully removed: TUI, extensions, Fastify+WS server, bridge, custom protocol all deleted (recoverable from git history) - tests: question-core units carry over; chat-flow integration drives handleChatRequest with MockLanguageModelV4 (48 tests, zero tokens); render smoke tests guard the cards (renderToStaticMarkup) Carried over unchanged: teacher system prompt, question-core, schema/ ledger/derive/extract pipeline, data model.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
# 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.
|
||||
|
||||
**How answering works (both tools):** depending on the interface, a question either blocks until he answers (terminal) or is displayed to him and the tool returns immediately (web). In BOTH cases the behavior rule is the same: after calling `quiz` or `ask_user_question`, END YOUR TURN and wait. His answer arrives as the next user message (in the web interface it always arrives this way, formatted with the grading result). Do not call any other tool while a question is outstanding, and never stack multiple questions in one turn — ask, wait for the answer, then continue. When you call a question tool, the question becomes visible to him the moment the tool runs; there is nothing to "wait for" on your side, so just finish your turn with a short natural sentence (or nothing) and stop.
|
||||
|
||||
### `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.
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as fs from "node:fs";
|
||||
import { ToolLoopAgent, stepCountIs, type LanguageModel } from "ai";
|
||||
import { openrouter } from "@openrouter/ai-sdk-provider";
|
||||
import { PATHS } from "../../config.ts";
|
||||
import { teacherTools } from "./tools.ts";
|
||||
|
||||
/**
|
||||
* The teacher agent. Everything pi used to provide (agent loop, tool calling,
|
||||
* streaming, multi-step) is these few lines now.
|
||||
*
|
||||
* The model is injectable so tests can pass a MockLanguageModelV4 and drive
|
||||
* deterministic scripted lessons (see tests/chat-flow.test.ts).
|
||||
*/
|
||||
|
||||
const DEFAULT_MODEL = process.env.LH_MODEL ?? "anthropic/claude-sonnet-4.5";
|
||||
|
||||
const instructions = fs.readFileSync(PATHS.teacherPrompt, "utf-8");
|
||||
|
||||
export function createTeacherAgent(model?: LanguageModel) {
|
||||
return new ToolLoopAgent({
|
||||
model:
|
||||
model ??
|
||||
openrouter(DEFAULT_MODEL, {
|
||||
// Ask OpenRouter for reasoning tokens when the model supports them;
|
||||
// they stream as reasoning parts into the UI's collapsible block.
|
||||
reasoning: { enabled: true, effort: "medium" },
|
||||
}),
|
||||
instructions,
|
||||
tools: teacherTools,
|
||||
// Both tools are client-side (no execute) — a tool call ends the server
|
||||
// turn automatically. stepCountIs is just the safety net.
|
||||
stopWhen: stepCountIs(20),
|
||||
});
|
||||
}
|
||||
|
||||
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The teacher's interaction tools — CLIENT-SIDE tools (no `execute`).
|
||||
*
|
||||
* The model's tool call is not executed on the server; it streams to the
|
||||
* browser as a typed tool part in `input-available` state. The learner
|
||||
* answers in the rendered card; the client grades (quiz) via
|
||||
* src/core/question-core.ts and sends the verdict back with addToolOutput.
|
||||
*
|
||||
* Parameter names mirror src/agent/system-prompt.md exactly — the prompt
|
||||
* documents these fields to the model. Keep them in sync.
|
||||
*/
|
||||
|
||||
const optionSchema = z.object({
|
||||
label: z.string().describe("Display label for the answer option"),
|
||||
value: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional machine-readable value returned for the option. Defaults to the label."),
|
||||
description: z.string().optional().describe("Optional extra detail shown below the option"),
|
||||
});
|
||||
|
||||
// Raw schemas stay exported: `tool()` wraps them into its internal Schema
|
||||
// type (erasing z.infer), so clients infer input types from these instead.
|
||||
export const quizInputSchema = z.object({
|
||||
question: z.string().describe("The single quiz question to ask"),
|
||||
options: z
|
||||
.array(optionSchema)
|
||||
.min(2)
|
||||
.describe("The answer options (2 or more). Options only — no free-text mode. Never add your own 'I don't know' option."),
|
||||
correctAnswer: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.describe(
|
||||
"The correct answer as the option value(s) — the `value` field of the option you intend. " +
|
||||
"Single-select: one string. Multi-select: an array; the learner is only correct if their selection matches this set exactly.",
|
||||
),
|
||||
explanation: z
|
||||
.string()
|
||||
.describe("Explanation revealed AFTER the learner answers. Always say why the correct answer is correct."),
|
||||
multiSelect: z.boolean().optional().describe("Set true only when more than one option is correct"),
|
||||
details: z.string().optional().describe("Optional extra context or instructions shown under the question"),
|
||||
});
|
||||
|
||||
export const askInputSchema = z.object({
|
||||
question: z.string().describe("The single question to ask"),
|
||||
mode: z
|
||||
.enum(["single-select", "multi-select", "text"])
|
||||
.optional()
|
||||
.describe("Answer mode. Omit for single-select when options are given, or text when they are not."),
|
||||
options: z
|
||||
.array(optionSchema)
|
||||
.min(2)
|
||||
.optional()
|
||||
.describe("Optional multiple-choice options. Omit for free-form text input. The learner can always choose 'Other' and type a custom answer."),
|
||||
multiSelect: z.boolean().optional().describe("Set true to allow multiple answers to be selected"),
|
||||
details: z.string().optional().describe("Optional extra context or instructions shown under the question"),
|
||||
});
|
||||
|
||||
export const quizTool = tool({
|
||||
description:
|
||||
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
||||
"Use it to (1) assess what the learner already understands before teaching, and (2) run tight practice/retrieval loops after explaining. " +
|
||||
"Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the learner can signal a genuine gap instead of guessing. " +
|
||||
"No free-text answers — for non-graded questions use ask_user_question instead. " +
|
||||
"Ask exactly one question per tool call; after calling, end your turn and wait for the graded result.",
|
||||
inputSchema: quizInputSchema,
|
||||
});
|
||||
|
||||
export const askUserQuestionTool = tool({
|
||||
description:
|
||||
"Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " +
|
||||
"Use when requirements are ambiguous, preferences are needed, a decision would materially affect the lesson, " +
|
||||
"or you need an open-ended response. Ask exactly one question per tool call; after calling, end your turn and wait.",
|
||||
inputSchema: askInputSchema,
|
||||
});
|
||||
|
||||
export const teacherTools = {
|
||||
quiz: quizTool,
|
||||
ask_user_question: askUserQuestionTool,
|
||||
};
|
||||
|
||||
export type QuizToolInput = z.infer<typeof quizInputSchema>;
|
||||
export type AskToolInput = z.infer<typeof askInputSchema>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { AskToolInput, QuizToolInput } from "./tools.ts";
|
||||
import type { QuestionAnswerData } from "../core/question-core.ts";
|
||||
|
||||
/**
|
||||
* The typed UI message for the teacher chat — gives the frontend typed tool
|
||||
* parts ("tool-quiz" / "tool-ask_user_question" with typed input/output)
|
||||
* without importing the server-side agent into the client bundle.
|
||||
*
|
||||
* Declared explicitly (not via InferUITools) because a client-side tool has
|
||||
* no execute function, so its OUTPUT type lives only here: the structured
|
||||
* answer record the client builds and sends via addToolOutput.
|
||||
*/
|
||||
export type TeacherUITools = {
|
||||
quiz: { input: QuizToolInput; output: QuestionAnswerData };
|
||||
ask_user_question: { input: AskToolInput; output: QuestionAnswerData };
|
||||
};
|
||||
|
||||
export type TeacherUIMessage = UIMessage<unknown, never, TeacherUITools>;
|
||||
+3
-15
@@ -2,18 +2,15 @@
|
||||
/**
|
||||
* learning-harness CLI
|
||||
*
|
||||
* teach [message] launch an interactive teaching session (full pi TUI)
|
||||
* doctor [--web] non-interactive smoke test of the teacher wiring
|
||||
* (--web checks the web-dispatch extension set)
|
||||
* serve start the web server (teacher agent + REST + WebSocket)
|
||||
* ingest <file> 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)
|
||||
*
|
||||
* The web app (Next.js + AI SDK): npm run dev
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -23,15 +20,6 @@ async function main() {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
|
||||
switch (command) {
|
||||
case "teach": {
|
||||
await runTeachingSession(rest.join(" ") || undefined);
|
||||
break;
|
||||
}
|
||||
case "doctor": {
|
||||
const mode = rest.includes("--web") ? "web" : "tui";
|
||||
await doctor(mode);
|
||||
break;
|
||||
}
|
||||
case "ingest": {
|
||||
const file = rest[0];
|
||||
if (!file) {
|
||||
@@ -69,7 +57,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
default: {
|
||||
console.log("Commands: teach [message] | doctor | ingest <file> | graph | what-next");
|
||||
console.log("Commands: ingest <file> | graph | what-next");
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isToolUIPart, type UIMessage } from "ai";
|
||||
|
||||
/**
|
||||
* The pending-question gate: an unanswered interaction tool part (quiz/ask)
|
||||
* blocks the composer — one question at a time, mirroring the teacher's
|
||||
* prompt contract. Derived client state; the server enforces nothing.
|
||||
*/
|
||||
export interface PendingToolCall {
|
||||
toolCallId: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null {
|
||||
for (let m = messages.length - 1; m >= 0; m--) {
|
||||
const msg = messages[m]!;
|
||||
for (let p = msg.parts.length - 1; p >= 0; p--) {
|
||||
const part = msg.parts[p]!;
|
||||
if (isToolUIPart(part) && part.state === "input-available") {
|
||||
return { toolCallId: part.toolCallId, tool: part.type.slice("tool-".length) };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* question-core — pure logic for quiz/ask questions. No framework imports,
|
||||
* no UI: everything here is deterministic and unit-testable.
|
||||
*
|
||||
* Used by:
|
||||
* - the TUI quiz extension (extensions/quiz.ts) — blocking terminal UI
|
||||
* - the web app (AI SDK client-side tools) — the browser validates,
|
||||
* shuffles, grades with these functions, then sends the verdict back
|
||||
* as the tool output via addToolOutput
|
||||
*
|
||||
* The web shape (AI SDK):
|
||||
* 1. The teacher model calls the `quiz` tool (question + options +
|
||||
* correctAnswer + explanation). No server execute → the call lands in
|
||||
* the UI as a typed tool part in `input-available` state.
|
||||
* 2. The learner answers in the card; the client grades with this module.
|
||||
* 3. addToolOutput sends the verdict (selection + correctness + note —
|
||||
* never the explanation, the model authored that) back to the model.
|
||||
*
|
||||
* Because the pending question IS a persisted message part, a session can be
|
||||
* exited and resumed mid-question for free.
|
||||
*/
|
||||
|
||||
// ─── shared types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface QuizOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** A real selected answer, in the order the options were displayed. */
|
||||
export interface OptionAnswer {
|
||||
label: string;
|
||||
value: string;
|
||||
index: number; // 1-based display index
|
||||
}
|
||||
|
||||
export type QuestionKind = "quiz" | "ask";
|
||||
export type SelectMode = "single-select" | "multi-select";
|
||||
export type QuestionMode = SelectMode | "text";
|
||||
|
||||
/** Full question data — stored in the `lh_question` custom entry. */
|
||||
export interface QuestionData {
|
||||
id: string;
|
||||
kind: QuestionKind;
|
||||
question: string;
|
||||
context?: string;
|
||||
mode: QuestionMode;
|
||||
options: QuizOption[]; // display order (post-shuffle)
|
||||
// quiz only. NEVER send to the client before answering:
|
||||
correctIndices?: number[];
|
||||
explanation?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The learner's answer — full structured record, stored in `lh_question_answered`. */
|
||||
export interface QuestionAnswerData {
|
||||
questionId: string;
|
||||
kind: QuestionKind;
|
||||
questionText: string;
|
||||
options?: { index: number; label: string }[]; // snapshot for rendering
|
||||
dontKnow: boolean;
|
||||
answers: OptionAnswer[];
|
||||
other?: string; // ask: "Other" free text
|
||||
text?: string; // ask: text-mode answer
|
||||
note?: string;
|
||||
correct?: boolean; // quiz only
|
||||
correctIndices?: number[]; // quiz only (rendering)
|
||||
explanation?: string; // quiz only (rendering)
|
||||
dismissed?: boolean;
|
||||
answeredAt: string;
|
||||
}
|
||||
|
||||
/** Client-facing question payload — deliberately omits the answer. */
|
||||
export interface ClientQuestion {
|
||||
id: string;
|
||||
kind: QuestionKind;
|
||||
question: string;
|
||||
context?: string;
|
||||
mode: QuestionMode;
|
||||
options: QuizOption[];
|
||||
}
|
||||
|
||||
/** What the server passes to the bridge when the learner answers. */
|
||||
export interface AnswerRequest {
|
||||
dontKnow?: boolean;
|
||||
values?: string[]; // option values
|
||||
other?: string; // ask "Other"
|
||||
text?: string; // ask text-mode
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const QUESTION_ENTRY = "lh_question";
|
||||
export const QUESTION_ANSWERED_ENTRY = "lh_question_answered";
|
||||
|
||||
// ─── options ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function normalizeOptions(
|
||||
options: Array<{ label: string; value?: string; description?: string }> | undefined,
|
||||
): QuizOption[] {
|
||||
const seen = new Set<string>();
|
||||
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 over a copy. correctAnswer is keyed by value, so display
|
||||
* order can be shuffled safely before resolving indices. */
|
||||
export 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));
|
||||
const a = out[i]!;
|
||||
const b = out[j]!;
|
||||
out[i] = b;
|
||||
out[j] = a;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── quiz grading ───────────────────────────────────────────────────────────
|
||||
|
||||
/** The harness sometimes delivers a multi-select correctAnswer array as a
|
||||
* JSON-stringified string because the schema union lists String first. */
|
||||
export 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 — treat as a single literal value
|
||||
}
|
||||
}
|
||||
return [correctAnswer];
|
||||
}
|
||||
|
||||
/** Resolve author-supplied value(s) to 1-based display indices. Keying by
|
||||
* value (not position) makes a typo a hard error instead of a silent wrong
|
||||
* grade. Must run AFTER shuffling. */
|
||||
export 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) };
|
||||
}
|
||||
|
||||
/** Exact-set match: same number of selections, same indices. */
|
||||
export 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]);
|
||||
}
|
||||
|
||||
// ─── arming + answering ─────────────────────────────────────────────────────
|
||||
|
||||
export function makeId(): string {
|
||||
return `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function toClientQuestion(qd: QuestionData): ClientQuestion {
|
||||
return {
|
||||
id: qd.id,
|
||||
kind: qd.kind,
|
||||
question: qd.question,
|
||||
context: qd.context,
|
||||
mode: qd.mode,
|
||||
options: qd.options,
|
||||
};
|
||||
}
|
||||
|
||||
function formatOptionRef(options: QuizOption[], index: number): string {
|
||||
const opt = options.find((o, i) => i + 1 === index);
|
||||
return `${index}. ${opt ? opt.label : "(unknown)"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an AnswerRequest into the full answer record + the user message the
|
||||
* agent will see. Quiz answers are graded here; ask answers are not.
|
||||
*/
|
||||
export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswerData {
|
||||
const base: QuestionAnswerData = {
|
||||
questionId: qd.id,
|
||||
kind: qd.kind,
|
||||
questionText: qd.question,
|
||||
options: qd.options.map((o, i) => ({ index: i + 1, label: o.label })),
|
||||
dontKnow: req.dontKnow === true,
|
||||
answers: [],
|
||||
note: req.note?.trim() || undefined,
|
||||
answeredAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// resolve selected option values → OptionAnswer (in display order)
|
||||
if (req.values && req.values.length > 0) {
|
||||
const byValue = new Map(qd.options.map((o, i) => [o.value, { ...o, index: i + 1 }]));
|
||||
for (const v of req.values) {
|
||||
const opt = byValue.get(v);
|
||||
if (!opt) continue; // unknown value — ignore
|
||||
base.answers.push({ label: opt.label, value: opt.value, index: opt.index });
|
||||
}
|
||||
base.answers.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
if (req.other?.trim()) base.other = req.other.trim();
|
||||
if (req.text?.trim()) base.text = req.text.trim();
|
||||
|
||||
if (qd.kind === "quiz") {
|
||||
base.correctIndices = qd.correctIndices ?? [];
|
||||
base.explanation = qd.explanation;
|
||||
base.correct = base.dontKnow ? false : isCorrect(base.answers.map((a) => a.index), qd.correctIndices ?? []);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** The user message that carries the answer into the agent's context. */
|
||||
export function formatAnswerMessage(ad: QuestionAnswerData): string {
|
||||
const label = ad.kind === "quiz" ? "Quiz answer" : "Answer to your question";
|
||||
const head = `[${label}]`;
|
||||
const lines: string[] = [head];
|
||||
|
||||
if (ad.dismissed) {
|
||||
lines.push("The learner dismissed this question without answering.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
if (ad.kind === "quiz") {
|
||||
const correctStr = (ad.correctIndices ?? []).map((i) => formatOptionRefSnapshot(ad, i)).join(", ");
|
||||
if (ad.dontKnow) {
|
||||
lines.push(
|
||||
`The learner selected "I don't know" — they did NOT attempt an answer (a genuine knowledge gap, not a wrong guess).`,
|
||||
);
|
||||
} else {
|
||||
const verdict = ad.correct ? "CORRECT" : "INCORRECT";
|
||||
const selectedStr = ad.answers.map((a) => `${a.index}. ${a.label}`).join(", ");
|
||||
lines.push(`Selected: ${selectedStr || "(none)"}`);
|
||||
lines.push(`Result: ${verdict}`);
|
||||
}
|
||||
lines.push(`Correct: ${correctStr}`);
|
||||
} else {
|
||||
if (ad.text) {
|
||||
lines.push(`The learner answered: ${ad.text}`);
|
||||
} else if (ad.other) {
|
||||
lines.push(`The learner answered (Other): ${ad.other}`);
|
||||
} else if (ad.answers.length > 0) {
|
||||
const selectedStr = ad.answers.map((a) => `${a.index}. ${a.label}`).join(", ");
|
||||
lines.push(`Selected: ${selectedStr}`);
|
||||
} else {
|
||||
lines.push("(no answer)");
|
||||
}
|
||||
}
|
||||
|
||||
if (ad.note) lines.push(`Note: ${ad.note}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatOptionRefSnapshot(ad: QuestionAnswerData, index: number): string {
|
||||
const opt = (ad.options ?? []).find((o) => o.index === index);
|
||||
return `${index}. ${opt ? opt.label : "(unknown)"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the pending (oldest unanswered) question from a session's entries.
|
||||
* Used on resume/backfill — pending state lives in the session file itself.
|
||||
*/
|
||||
export function findPendingQuestion(
|
||||
entries: Array<{ type: string; customType?: string; data?: unknown }>,
|
||||
): ClientQuestion | null {
|
||||
const answered = new Set<string>();
|
||||
for (const e of entries) {
|
||||
if (e.type === "custom" && e.customType === QUESTION_ANSWERED_ENTRY) {
|
||||
const d = e.data as QuestionAnswerData | undefined;
|
||||
if (d?.questionId) answered.add(d.questionId);
|
||||
}
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.type === "custom" && e.customType === QUESTION_ENTRY) {
|
||||
const d = e.data as QuestionData | undefined;
|
||||
if (d && !answered.has(d.id)) return toClientQuestion(d);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createAgentUIStreamResponse, type UIMessage } from "ai";
|
||||
import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts";
|
||||
import { getChat, saveMessages } from "../store/chats.ts";
|
||||
|
||||
/**
|
||||
* One chat turn: run the teacher agent over the client's message list and
|
||||
* stream the UI message stream back. Framework-free (plain Request/Response)
|
||||
* so tests can drive it directly with a scripted mock model — the Next route
|
||||
* handler in app/api/chat is a one-line wrapper.
|
||||
*
|
||||
* The client owns the message list between turns (useChat); the server
|
||||
* persists the canonical state (client messages + agent response) onFinish.
|
||||
*/
|
||||
export async function handleChatRequest(req: Request, agent?: TeacherAgent): Promise<Response> {
|
||||
let body: { chatId?: unknown; messages?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const chatId = typeof body.chatId === "string" ? body.chatId : "";
|
||||
if (!chatId || !getChat(chatId)) {
|
||||
return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||
}
|
||||
if (!Array.isArray(body.messages)) {
|
||||
return Response.json({ error: "messages must be an array" }, { status: 400 });
|
||||
}
|
||||
|
||||
return createAgentUIStreamResponse({
|
||||
agent: agent ?? createTeacherAgent(),
|
||||
uiMessages: body.messages as UIMessage[],
|
||||
onFinish: ({ messages, isAborted }) => {
|
||||
// Persist even when aborted: the partial transcript (including any
|
||||
// pending question part) is what makes resume work.
|
||||
try {
|
||||
saveMessages(chatId, messages as UIMessage[]);
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
/**
|
||||
* web server — Fastify + WebSocket server embedding the teacher agent via the
|
||||
* pi SDK (web dispatch mode: quiz/ask return immediately, answers arrive as
|
||||
* user messages — nothing blocks, so sessions can be exited/resumed mid-question).
|
||||
*
|
||||
* Delivery model (the part that must never double-send or drop):
|
||||
* - ONE append-only entry log per active session (`entryLog`), plus a cursor
|
||||
* (`syncedCount`) into the session manager's entries.
|
||||
* - Every entry enters the log exactly once — either at activation (seeded
|
||||
* from history, no broadcast) or live via syncEntries (broadcast to all
|
||||
* connected sockets).
|
||||
* - A socket connecting at ANY time receives the whole log as backfill.
|
||||
* There is no per-socket marking and no window in which an entry can be
|
||||
* both backfilled and broadcast — the duplication race from v1 is
|
||||
* structurally impossible now.
|
||||
*
|
||||
* - Streaming: thinking and text deltas are forwarded as they happen;
|
||||
* ephemeral (a socket that connects mid-turn gets `streaming: true` in its
|
||||
* snapshot and the entries when they land).
|
||||
*
|
||||
* v1 scope: one active session at a time; extraction pipeline NOT wired yet.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Fastify from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import {
|
||||
type AgentSessionEvent,
|
||||
type AgentSessionRuntimeDiagnostic,
|
||||
createAgentSessionRuntime,
|
||||
SessionManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { PATHS } from "../../config.ts";
|
||||
import { buildTeacherRuntimeFactory } from "../teacher/create-session.ts";
|
||||
import {
|
||||
QUESTION_ANSWERED_ENTRY,
|
||||
QUESTION_ENTRY,
|
||||
findPendingQuestion,
|
||||
type QuestionAnswerData,
|
||||
type QuestionData,
|
||||
toClientQuestion,
|
||||
} from "../../extensions/core/question-core.ts";
|
||||
import { getBridge } from "../../extensions/core/bridge.ts";
|
||||
import type { ClientEntry, ServerMessage, SessionSummary } from "../shared/protocol.ts";
|
||||
|
||||
/** What the server needs from a teacher runtime (real or scripted). */
|
||||
export interface RuntimeLike {
|
||||
session: {
|
||||
subscribe(listener: (evt: AgentSessionEvent) => void): () => void;
|
||||
dispose(): void;
|
||||
isStreaming: boolean;
|
||||
prompt(text: string): Promise<void>;
|
||||
abort(): unknown;
|
||||
setSessionName(name: string): unknown;
|
||||
sessionManager: SessionManager;
|
||||
};
|
||||
diagnostics: readonly AgentSessionRuntimeDiagnostic[];
|
||||
}
|
||||
|
||||
export type RuntimeFactory = (options: {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
sessionManager: SessionManager;
|
||||
}) => Promise<RuntimeLike>;
|
||||
|
||||
export interface ServerOptions {
|
||||
/** TCP port; 0 = pick a free port (tests). Default: LH_PORT or 8787. */
|
||||
port?: number;
|
||||
/** Teacher runtime factory — inject a scripted one in tests. */
|
||||
runtimeFactory?: RuntimeFactory;
|
||||
}
|
||||
|
||||
const defaultRuntimeFactory: RuntimeFactory = (options) =>
|
||||
createAgentSessionRuntime(buildTeacherRuntimeFactory("web"), options) as unknown as Promise<RuntimeLike>;
|
||||
|
||||
// ─── entry serialization ────────────────────────────────────────────────────
|
||||
|
||||
function extractText(content: unknown[]): string {
|
||||
return content
|
||||
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function extractThinking(content: unknown[]): string {
|
||||
return content
|
||||
.filter(
|
||||
(c): c is { type: "thinking"; thinking: string } => (c as { type?: string }).type === "thinking",
|
||||
)
|
||||
.map((c) => c.thinking)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function serializeEntry(entry: {
|
||||
type: string;
|
||||
id: string;
|
||||
customType?: string;
|
||||
data?: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
message?: any;
|
||||
}): ClientEntry | null {
|
||||
if (entry.type === "message" && entry.message) {
|
||||
const m = entry.message;
|
||||
if (m.role === "user") {
|
||||
return { type: "user", id: entry.id, text: extractText(m.content ?? []) };
|
||||
}
|
||||
if (m.role === "assistant") {
|
||||
const toolCalls = ((m.content ?? []) as unknown[])
|
||||
.filter(
|
||||
(c): c is { type: "toolCall"; name: string; arguments?: unknown } =>
|
||||
(c as { type?: string }).type === "toolCall",
|
||||
)
|
||||
.map((c) => ({ name: c.name, args: (c.arguments ?? {}) as Record<string, unknown> }));
|
||||
const thinking = extractThinking(m.content ?? []);
|
||||
return {
|
||||
type: "assistant",
|
||||
id: entry.id,
|
||||
text: extractText(m.content ?? []),
|
||||
thinking: thinking || undefined,
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
if (m.role === "toolResult") {
|
||||
return { type: "toolResult", id: entry.id, toolName: m.toolName ?? "", text: extractText(m.content ?? []) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (entry.type === "custom" && entry.customType === QUESTION_ENTRY) {
|
||||
const data = entry.data as QuestionData | undefined;
|
||||
if (!data) return null;
|
||||
return { type: "question", id: entry.id, data: toClientQuestion(data) };
|
||||
}
|
||||
if (entry.type === "custom" && entry.customType === QUESTION_ANSWERED_ENTRY) {
|
||||
const data = entry.data as QuestionAnswerData | undefined;
|
||||
if (!data) return null;
|
||||
return { type: "questionAnswered", id: entry.id, data };
|
||||
}
|
||||
return { type: "meta", id: entry.id, kind: entry.type };
|
||||
}
|
||||
|
||||
// ─── session listing (reads data/sessions/*.jsonl) ──────────────────────────
|
||||
|
||||
function listSessions(): SessionSummary[] {
|
||||
const dir = PATHS.sessions;
|
||||
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")) : [];
|
||||
const summaries: SessionSummary[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const lines = fs.readFileSync(path.join(dir, file), "utf8").split("\n").filter(Boolean);
|
||||
if (lines.length === 0) continue;
|
||||
const header = JSON.parse(lines[0] ?? "") as { id?: string; timestamp?: string };
|
||||
const id = header.id;
|
||||
if (!id) continue;
|
||||
let name: string | undefined;
|
||||
let preview: string | undefined;
|
||||
let updatedAt = header.timestamp ?? "";
|
||||
let messageCount = 0;
|
||||
for (const line of lines.slice(1)) {
|
||||
let entry: { type?: string; message?: { role?: string; content?: unknown[] }; timestamp?: string; name?: string };
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entry.timestamp) updatedAt = entry.timestamp;
|
||||
if (entry.type === "session_info" && entry.name) name = entry.name;
|
||||
if (entry.type === "message" && entry.message?.role === "user") {
|
||||
messageCount++;
|
||||
if (!preview) preview = extractText(entry.message.content ?? []).slice(0, 120);
|
||||
}
|
||||
}
|
||||
summaries.push({
|
||||
id,
|
||||
file,
|
||||
name,
|
||||
createdAt: header.timestamp ?? "",
|
||||
updatedAt,
|
||||
messageCount,
|
||||
preview,
|
||||
});
|
||||
} catch {
|
||||
// unreadable session file — skip
|
||||
}
|
||||
}
|
||||
summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return summaries;
|
||||
}
|
||||
|
||||
// ─── server ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function startServer(options: ServerOptions = {}): Promise<{
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const runtimeFactory = options.runtimeFactory ?? defaultRuntimeFactory;
|
||||
|
||||
// Active session state. `entryLog` is the single source of truth for what
|
||||
// clients have been able to see; `syncedCount` is how many session entries
|
||||
// it reflects. Both reset together on activation.
|
||||
interface ActiveSession {
|
||||
runtime: RuntimeLike;
|
||||
sessionId: string;
|
||||
file: string;
|
||||
}
|
||||
let active: ActiveSession | null = null;
|
||||
let unsubscribeEvents: (() => void) | null = null;
|
||||
let entryLog: ClientEntry[] = [];
|
||||
let syncedCount = 0;
|
||||
|
||||
const sockets = new Set<WebSocket>();
|
||||
|
||||
function broadcast(msg: ServerMessage): void {
|
||||
const raw = JSON.stringify(msg);
|
||||
for (const ws of sockets) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(raw);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastPending(): void {
|
||||
if (!active) return;
|
||||
const pending = findPendingQuestion(active.runtime.session.sessionManager.getEntries());
|
||||
broadcast({ type: "pending", question: pending });
|
||||
}
|
||||
|
||||
/** Append every not-yet-logged session entry to the log (broadcasting each). */
|
||||
function syncEntries(): void {
|
||||
if (!active) return;
|
||||
const entries = active.runtime.session.sessionManager.getEntries();
|
||||
for (; syncedCount < entries.length; syncedCount++) {
|
||||
const entry = entries[syncedCount]!;
|
||||
const client = serializeEntry(entry as never);
|
||||
if (client) {
|
||||
entryLog.push(client);
|
||||
broadcast({ type: "entry", entry: client });
|
||||
}
|
||||
if (
|
||||
entry.type === "custom" &&
|
||||
((entry as { customType?: string }).customType === QUESTION_ENTRY ||
|
||||
(entry as { customType?: string }).customType === QUESTION_ANSWERED_ENTRY)
|
||||
) {
|
||||
broadcastPending();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionEvent(evt: AgentSessionEvent): void {
|
||||
if (!active) return;
|
||||
switch (evt.type) {
|
||||
case "message_update": {
|
||||
const e = evt.assistantMessageEvent;
|
||||
if (e?.type === "text_delta") {
|
||||
broadcast({ type: "delta", kind: "text", delta: e.delta });
|
||||
} else if (e?.type === "thinking_delta") {
|
||||
broadcast({ type: "delta", kind: "thinking", delta: e.delta });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_start":
|
||||
broadcast({ type: "streaming", value: true });
|
||||
break;
|
||||
case "agent_settled":
|
||||
broadcast({ type: "streaming", value: false });
|
||||
break;
|
||||
}
|
||||
syncEntries();
|
||||
}
|
||||
|
||||
async function activate(sessionManager: SessionManager): Promise<ActiveSession> {
|
||||
if (unsubscribeEvents) {
|
||||
unsubscribeEvents();
|
||||
unsubscribeEvents = null;
|
||||
}
|
||||
if (active) {
|
||||
active.runtime.session.dispose();
|
||||
active = null;
|
||||
}
|
||||
const runtime = await runtimeFactory({
|
||||
cwd: PATHS.root,
|
||||
agentDir: PATHS.agentDir,
|
||||
sessionManager,
|
||||
});
|
||||
for (const d of runtime.diagnostics) {
|
||||
if (d.type === "error") console.error(`[teacher] ${d.message}`);
|
||||
}
|
||||
unsubscribeEvents = runtime.session.subscribe(handleSessionEvent);
|
||||
const file = path.basename(sessionManager.getSessionFile() ?? "");
|
||||
active = { runtime, sessionId: sessionManager.getSessionId(), file };
|
||||
|
||||
// Seed the log from history WITHOUT broadcasting: sockets that connect
|
||||
// later get it via their connect-backfill; sockets already connected get
|
||||
// it via the hello+backfill below. Either way, exactly once.
|
||||
entryLog = sessionManager
|
||||
.getEntries()
|
||||
.map((e) => serializeEntry(e as never))
|
||||
.filter((e): e is ClientEntry => e !== null);
|
||||
syncedCount = sessionManager.getEntries().length;
|
||||
|
||||
broadcast({ type: "hello", sessionId: active.sessionId });
|
||||
broadcast({ type: "backfill", entries: entryLog });
|
||||
broadcastPending();
|
||||
broadcast({ type: "streaming", value: runtime.session.isStreaming });
|
||||
return active;
|
||||
}
|
||||
|
||||
async function createSession(topic: string): Promise<ActiveSession> {
|
||||
const sessionManager = SessionManager.create(PATHS.root, PATHS.sessions);
|
||||
const result = await activate(sessionManager);
|
||||
// Name the session after the topic (shown in the session list).
|
||||
const name = topic.trim().slice(0, 60) || "New session";
|
||||
result.runtime.session.setSessionName(name);
|
||||
// Fire the first prompt (don't await — clients follow along via WS).
|
||||
void result.runtime.session.prompt(topic).catch((err) => {
|
||||
console.error("[teacher] prompt failed:", err);
|
||||
broadcast({ type: "error", message: String(err) });
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resumeSession(file: string): Promise<ActiveSession> {
|
||||
const sessionManager = SessionManager.open(path.join(PATHS.sessions, file), PATHS.sessions, PATHS.root);
|
||||
return activate(sessionManager);
|
||||
}
|
||||
|
||||
// ─── HTTP ────────────────────────────────────────────────────────────────
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
// Serve the built frontend (web/dist) when it exists.
|
||||
const distDir = path.join(PATHS.root, "web", "dist");
|
||||
if (fs.existsSync(distDir)) {
|
||||
await app.register(fastifyStatic, { root: distDir });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith("/api") || req.url.startsWith("/ws")) {
|
||||
reply.code(404).send({ error: "not found" });
|
||||
} else {
|
||||
reply.sendFile("index.html");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/api/sessions", async () => ({
|
||||
sessions: listSessions(),
|
||||
activeId: active?.sessionId ?? null,
|
||||
}));
|
||||
|
||||
app.post<{ Body: { topic?: string } }>("/api/sessions", async (req, reply) => {
|
||||
const topic = (req.body?.topic ?? "").trim();
|
||||
if (!topic) return reply.code(400).send({ error: "topic is required" });
|
||||
const result = await createSession(topic);
|
||||
return { id: result.sessionId, file: result.file };
|
||||
});
|
||||
|
||||
app.post<{ Params: { file: string } }>("/api/sessions/:file/resume", async (req, reply) => {
|
||||
const file = req.params.file;
|
||||
const full = path.join(PATHS.sessions, file);
|
||||
if (!file.endsWith(".jsonl") || !fs.existsSync(full)) {
|
||||
return reply.code(404).send({ error: "session not found" });
|
||||
}
|
||||
try {
|
||||
const result = await resumeSession(file);
|
||||
return { id: result.sessionId, file: result.file };
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── WebSocket ───────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/ws", { websocket: true }, (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
// Initial snapshot: everything the session has produced so far.
|
||||
socket.send(JSON.stringify({ type: "hello", sessionId: active?.sessionId ?? null } satisfies ServerMessage));
|
||||
socket.send(JSON.stringify({ type: "backfill", entries: entryLog } satisfies ServerMessage));
|
||||
if (active) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "pending",
|
||||
question: findPendingQuestion(active.runtime.session.sessionManager.getEntries()),
|
||||
} satisfies ServerMessage),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({ type: "streaming", value: active.runtime.session.isStreaming } satisfies ServerMessage),
|
||||
);
|
||||
}
|
||||
|
||||
socket.on("message", (raw: Buffer) => {
|
||||
let msg: { type?: string } & Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
socket.send(JSON.stringify({ type: "error", message: "no active session" } satisfies ServerMessage));
|
||||
return;
|
||||
}
|
||||
const session = active.runtime.session;
|
||||
switch (msg.type) {
|
||||
case "prompt": {
|
||||
const text = String(msg.text ?? "").trim();
|
||||
if (!text) return;
|
||||
// One question at a time: don't accept prompts while a question
|
||||
// is waiting to be answered.
|
||||
if (findPendingQuestion(session.sessionManager.getEntries())) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Answer the pending question first (or dismiss it).",
|
||||
} satisfies ServerMessage),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void session.prompt(text).catch((err) => {
|
||||
console.error("[teacher] prompt failed:", err);
|
||||
broadcast({ type: "error", message: String(err) });
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "answer": {
|
||||
const outcome = getBridge().answer(String(msg.questionId ?? ""), {
|
||||
dontKnow: Boolean(msg.dontKnow),
|
||||
values: Array.isArray(msg.values) ? msg.values.map(String) : undefined,
|
||||
other: msg.other ? String(msg.other) : undefined,
|
||||
text: msg.text ? String(msg.text) : undefined,
|
||||
note: msg.note ? String(msg.note) : undefined,
|
||||
});
|
||||
if (!outcome.ok) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: outcome.error ?? "answer failed" } satisfies ServerMessage),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "dismiss": {
|
||||
const outcome = getBridge().dismiss(String(msg.questionId ?? ""));
|
||||
if (!outcome.ok) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: outcome.error ?? "dismiss failed" } satisfies ServerMessage),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "abort": {
|
||||
void session.abort();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const port = options.port ?? Number(process.env.LH_PORT ?? 8787);
|
||||
await app.listen({ port, host: "127.0.0.1" });
|
||||
const actualPort = (app.server.address() as { port: number }).port;
|
||||
|
||||
return {
|
||||
port: actualPort,
|
||||
close: async () => {
|
||||
if (unsubscribeEvents) unsubscribeEvents();
|
||||
if (active) active.runtime.session.dispose();
|
||||
for (const ws of sockets) ws.close();
|
||||
sockets.clear();
|
||||
await app.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── entrypoint ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const server = await startServer();
|
||||
console.log(`learning-harness web server: http://127.0.0.1:${server.port}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith("server/index.ts")) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* protocol — shared types between the web server and the browser client.
|
||||
* One source of truth for the WS protocol and REST DTOs.
|
||||
*/
|
||||
|
||||
import type { ClientQuestion, QuestionAnswerData } from "../../extensions/core/question-core.ts";
|
||||
|
||||
export type { ClientQuestion, QuestionAnswerData } from "../../extensions/core/question-core.ts";
|
||||
|
||||
// ─── entries (session → client) ─────────────────────────────────────────────
|
||||
|
||||
export interface ClientToolCall {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ClientEntry =
|
||||
| { type: "user"; id: string; text: string }
|
||||
| { type: "assistant"; id: string; text: string; thinking?: string; toolCalls: ClientToolCall[] }
|
||||
| { type: "toolResult"; id: string; toolName: string; text: string }
|
||||
| { type: "question"; id: string; data: ClientQuestion }
|
||||
| { type: "questionAnswered"; id: string; data: QuestionAnswerData }
|
||||
| { type: "meta"; id: string; kind: string };
|
||||
|
||||
// ─── REST DTOs ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
file: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messageCount: number;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface NewSessionResponse {
|
||||
id: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
// ─── WS protocol ────────────────────────────────────────────────────────────
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "hello"; sessionId: string | null }
|
||||
| { type: "backfill"; entries: ClientEntry[] }
|
||||
| { type: "entry"; entry: ClientEntry }
|
||||
| { type: "pending"; question: ClientQuestion | null }
|
||||
| { type: "delta"; kind: "thinking" | "text"; delta: string }
|
||||
| { type: "streaming"; value: boolean }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "prompt"; text: string }
|
||||
| {
|
||||
type: "answer";
|
||||
questionId: string;
|
||||
dontKnow?: boolean;
|
||||
values?: string[];
|
||||
other?: string;
|
||||
text?: string;
|
||||
note?: string;
|
||||
}
|
||||
| { type: "dismiss"; questionId: string }
|
||||
| { type: "abort" };
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { UIMessage } from "ai";
|
||||
import { PATHS } from "../../config.ts";
|
||||
|
||||
/**
|
||||
* Chat store — one JSON file per chat in data/chats/.
|
||||
*
|
||||
* The stored messages are full AI SDK UIMessages (parts included). This is
|
||||
* the canonical session record: a pending quiz is a tool part in
|
||||
* `input-available` state, which is exactly what makes resume-mid-question
|
||||
* free. The extraction pipeline (src/extract) will read these later; old pi
|
||||
* sessions (data/sessions) remain bootstrap input only.
|
||||
*/
|
||||
|
||||
export interface ChatRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: UIMessage[];
|
||||
}
|
||||
|
||||
export interface ChatSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
function chatFile(id: string): string {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
|
||||
return path.join(PATHS.chats, `${id}.json`);
|
||||
}
|
||||
|
||||
export function createChat(title: string): ChatRecord {
|
||||
const now = new Date().toISOString();
|
||||
const record: ChatRecord = {
|
||||
id: `chat_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
||||
title: title.trim() || "Untitled session",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messages: [],
|
||||
};
|
||||
fs.mkdirSync(PATHS.chats, { recursive: true });
|
||||
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||
return record;
|
||||
}
|
||||
|
||||
export function getChat(id: string): ChatRecord | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(chatFile(id), "utf-8")) as ChatRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listChats(): ChatSummary[] {
|
||||
try {
|
||||
fs.mkdirSync(PATHS.chats, { recursive: true });
|
||||
return fs
|
||||
.readdirSync(PATHS.chats)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => getChat(f.slice(0, -5)))
|
||||
.filter((r): r is ChatRecord => r !== null)
|
||||
.map(({ messages, ...summary }) => ({ ...summary, messageCount: messages.length }))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the full message list (client state + new response) after a turn. */
|
||||
export function saveMessages(id: string, messages: UIMessage[]): void {
|
||||
const record = getChat(id);
|
||||
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||
record.messages = messages;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||
}
|
||||
|
||||
/** Derive a short title from the first user message. */
|
||||
export function titleFromPrompt(prompt: string): string {
|
||||
const oneLine = prompt.replace(/\s+/g, " ").trim();
|
||||
return oneLine.length > 72 ? `${oneLine.slice(0, 72)}…` : oneLine;
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* 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 type TeacherMode = "tui" | "web";
|
||||
|
||||
/** Test-only injection: replace the model + model runtime with a scripted fake. */
|
||||
export interface TeacherRuntimeOverrides {
|
||||
model?: unknown;
|
||||
modelRuntime?: unknown;
|
||||
}
|
||||
|
||||
export function buildTeacherRuntimeFactory(
|
||||
mode: TeacherMode = "tui",
|
||||
overrides?: TeacherRuntimeOverrides,
|
||||
): CreateAgentSessionRuntimeFactory {
|
||||
// Terminal mode: quiz/ask block on interactive pi-tui components.
|
||||
// Web mode: quiz/ask use the dispatch shape (return immediately; the graded
|
||||
// answer arrives as the next user message — see extensions/quiz-web.ts).
|
||||
const extensionFiles =
|
||||
mode === "web"
|
||||
? [path.join(PATHS.extensions, "quiz-web.ts"), path.join(PATHS.extensions, "ask-web.ts")]
|
||||
: [path.join(PATHS.extensions, "quiz.ts"), path.join(PATHS.extensions, "ask-user-question.ts")];
|
||||
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: extensionFiles,
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
// Test injection: swap the real model runtime for a scripted fake BEFORE
|
||||
// the session is constructed from services. Production never passes this.
|
||||
if (overrides?.modelRuntime) {
|
||||
(services as { modelRuntime: unknown }).modelRuntime = overrides.modelRuntime;
|
||||
}
|
||||
return {
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
// No read/bash/edit/write. quiz + ask_user_question (extension tools)
|
||||
// remain enabled.
|
||||
noTools: "builtin",
|
||||
model: overrides?.model as never,
|
||||
})),
|
||||
services,
|
||||
diagnostics: services.diagnostics,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Launch the interactive teaching session (full pi TUI). */
|
||||
export async function runTeachingSession(initialMessage?: string): Promise<void> {
|
||||
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(mode: TeacherMode = "tui"): Promise<void> {
|
||||
const sessionManager = SessionManager.inMemory(PATHS.root);
|
||||
const runtime = await buildTeacherRuntimeFactory(mode)({
|
||||
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();
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
/**
|
||||
* scripted-model — a deterministic fake LLM for integration tests.
|
||||
*
|
||||
* Replaces the real model runtime inside the teacher session: the FULL pi
|
||||
* agent loop runs (extensions, tool execution, session persistence, event
|
||||
* stream), but every assistant response comes from a fixed script. No network,
|
||||
* no tokens, no variance — the same script always produces the same session.
|
||||
*/
|
||||
|
||||
// AgentMessage comes from a transitive dep that is not directly importable;
|
||||
// a structural local type is enough for the helpers below.
|
||||
export interface AnyMessage {
|
||||
role?: string;
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
}
|
||||
|
||||
// ─── types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ScriptedToolCall {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One scripted assistant turn: reasoning, tool calls, and/or final text. */
|
||||
export interface ScriptedTurn {
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
toolCalls?: ScriptedToolCall[];
|
||||
}
|
||||
|
||||
interface FakeAssistantMessage {
|
||||
role: "assistant";
|
||||
content: Array<Record<string, unknown>>;
|
||||
api: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
usage: Record<string, unknown>;
|
||||
stopReason: string;
|
||||
}
|
||||
|
||||
// ─── fake event stream (what the agent loop consumes) ──────────────────────
|
||||
|
||||
class FakeEventStream {
|
||||
private queue: Array<Record<string, unknown>> = [];
|
||||
private done = false;
|
||||
private resolvers: Array<(r: IteratorResult<Record<string, unknown>>) => void> = [];
|
||||
private finalMessage: FakeAssistantMessage | null = null;
|
||||
private resultResolve!: (m: FakeAssistantMessage) => void;
|
||||
readonly resultPromise: Promise<FakeAssistantMessage>;
|
||||
|
||||
constructor() {
|
||||
this.resultPromise = new Promise((resolve) => {
|
||||
this.resultResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
push(event: Record<string, unknown>): void {
|
||||
if (this.done) return;
|
||||
const r = this.resolvers.shift();
|
||||
if (r) r({ value: event, done: false });
|
||||
else this.queue.push(event);
|
||||
}
|
||||
|
||||
end(message: FakeAssistantMessage): void {
|
||||
this.done = true;
|
||||
this.finalMessage = message;
|
||||
this.resultResolve(message);
|
||||
while (this.resolvers.length > 0) {
|
||||
this.resolvers.shift()!({ value: undefined as never, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
async result(): Promise<FakeAssistantMessage> {
|
||||
return this.resultPromise;
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
|
||||
return {
|
||||
next: (): Promise<IteratorResult<Record<string, unknown>>> => {
|
||||
const queued = this.queue.shift();
|
||||
if (queued !== undefined) return Promise.resolve({ value: queued, done: false });
|
||||
if (this.done) return Promise.resolve({ value: undefined as never, done: true });
|
||||
return new Promise((resolve) => this.resolvers.push(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── fake model + runtime ───────────────────────────────────────────────────
|
||||
|
||||
function baseMessage(turnIndex: number): FakeAssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "openai-completions",
|
||||
provider: "scripted",
|
||||
model: `scripted-turn-${turnIndex}`,
|
||||
usage: { input: 100, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 110, cost: { input: 0, output: 0, total: 0 } },
|
||||
stopReason: "stop",
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScriptedRuntime {
|
||||
/** The model object to pass as `model` when creating the session. */
|
||||
model: Record<string, unknown>;
|
||||
/** The ModelRuntime replacement to inject into services. */
|
||||
modelRuntime: {
|
||||
getModel: () => unknown;
|
||||
hasConfiguredAuth: () => boolean;
|
||||
streamSimple: (model: unknown, context: unknown, options?: unknown) => FakeEventStream;
|
||||
};
|
||||
/** All contexts the "model" was called with (for assertions). */
|
||||
contextsSeen: Array<{ systemPrompt?: string; messages: unknown[] }>;
|
||||
}
|
||||
|
||||
export function createScriptedRuntime(script: ScriptedTurn[]): ScriptedRuntime {
|
||||
let turnIndex = 0;
|
||||
const contextsSeen: Array<{ systemPrompt?: string; messages: unknown[] }> = [];
|
||||
|
||||
const model = {
|
||||
id: "scripted-model",
|
||||
name: "Scripted Test Model",
|
||||
api: "openai-completions",
|
||||
provider: "scripted",
|
||||
baseUrl: "http://localhost:0",
|
||||
reasoning: false,
|
||||
input: ["text"] as const,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
|
||||
function nextStream(): FakeEventStream {
|
||||
const stream = new FakeEventStream();
|
||||
const turn = script[turnIndex] ?? { text: "(script exhausted)" };
|
||||
turnIndex++;
|
||||
|
||||
const msg = baseMessage(turnIndex);
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
|
||||
// "start"
|
||||
stream.push({ type: "start", partial: { ...msg, content: [] } });
|
||||
|
||||
// thinking (reasoning) — split into two deltas so accumulation is tested
|
||||
if (turn.thinking !== undefined && turn.thinking.length > 0) {
|
||||
content.push({ type: "thinking", thinking: turn.thinking });
|
||||
const idx = content.length - 1;
|
||||
stream.push({ type: "thinking_start", contentIndex: idx, partial: { ...msg, content: [...content] } });
|
||||
const mid = Math.ceil(turn.thinking.length / 2);
|
||||
stream.push({ type: "thinking_delta", contentIndex: idx, delta: turn.thinking.slice(0, mid), partial: { ...msg, content: [...content] } });
|
||||
stream.push({ type: "thinking_delta", contentIndex: idx, delta: turn.thinking.slice(mid), partial: { ...msg, content: [...content] } });
|
||||
stream.push({ type: "thinking_end", contentIndex: idx, content: turn.thinking, partial: { ...msg, content: [...content] } });
|
||||
}
|
||||
|
||||
// tool calls
|
||||
for (const tc of turn.toolCalls ?? []) {
|
||||
const toolCall = {
|
||||
type: "toolCall",
|
||||
id: `call_${turnIndex}_${content.length}`,
|
||||
name: tc.name,
|
||||
arguments: tc.arguments,
|
||||
};
|
||||
content.push(toolCall);
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: content.length - 1,
|
||||
toolCall,
|
||||
partial: { ...msg, content: [...content] },
|
||||
});
|
||||
}
|
||||
|
||||
// text
|
||||
if (turn.text !== undefined && turn.text.length > 0) {
|
||||
content.push({ type: "text", text: turn.text });
|
||||
stream.push({
|
||||
type: "text_start",
|
||||
contentIndex: content.length - 1,
|
||||
partial: { ...msg, content: [...content] },
|
||||
});
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: content.length - 1,
|
||||
delta: turn.text,
|
||||
partial: { ...msg, content: [...content] },
|
||||
});
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: content.length - 1,
|
||||
content: turn.text,
|
||||
partial: { ...msg, content: [...content] },
|
||||
});
|
||||
}
|
||||
|
||||
// "done" + result
|
||||
const final = { ...msg, content, stopReason: turn.toolCalls?.length ? "toolUse" : "stop" };
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: turn.toolCalls?.length ? "toolUse" : "stop",
|
||||
message: final,
|
||||
});
|
||||
stream.end(final);
|
||||
return stream;
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
contextsSeen,
|
||||
modelRuntime: {
|
||||
getModel: () => model,
|
||||
hasConfiguredAuth: () => true,
|
||||
streamSimple: (_model: unknown, context: unknown) => {
|
||||
const ctx = context as { systemPrompt?: string; messages: unknown[] };
|
||||
contextsSeen.push(ctx);
|
||||
return nextStream();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── helpers for assertions on session messages ─────────────────────────────
|
||||
|
||||
export function messageText(m: AnyMessage): string {
|
||||
const content = (m as { content?: Array<{ type?: string; text?: string }> }).content ?? [];
|
||||
return content
|
||||
.filter((c) => c.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
Reference in New Issue
Block a user