@interleavelove/keating 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +274 -0
  2. package/bin/keating.js +31 -0
  3. package/dist/src/cli/main.js +165 -0
  4. package/dist/src/core/animation.js +372 -0
  5. package/dist/src/core/benchmark.js +238 -0
  6. package/dist/src/core/config.js +81 -0
  7. package/dist/src/core/evolution.js +224 -0
  8. package/dist/src/core/learner-state.js +88 -0
  9. package/dist/src/core/lesson-plan.js +155 -0
  10. package/dist/src/core/map.js +89 -0
  11. package/dist/src/core/paths.js +69 -0
  12. package/dist/src/core/pi-agent.js +58 -0
  13. package/dist/src/core/policy.js +53 -0
  14. package/dist/src/core/project.js +189 -0
  15. package/dist/src/core/prompt-evolution.js +337 -0
  16. package/dist/src/core/random.js +19 -0
  17. package/dist/src/core/self-improve.js +419 -0
  18. package/dist/src/core/topics.js +620 -0
  19. package/dist/src/core/types.js +1 -0
  20. package/dist/src/core/util.js +28 -0
  21. package/dist/src/core/verification.js +162 -0
  22. package/dist/src/pi/hyperteacher-extension.js +180 -0
  23. package/dist/src/runtime/pi.js +118 -0
  24. package/dist/test/animation.test.js +43 -0
  25. package/dist/test/config.test.js +36 -0
  26. package/dist/test/evolution.test.js +39 -0
  27. package/dist/test/fuzz.test.js +37 -0
  28. package/dist/test/hyperteacher-extension.test.js +122 -0
  29. package/dist/test/lesson-plan.test.js +35 -0
  30. package/dist/test/pipeline.test.js +57 -0
  31. package/dist/test/prompt-evolution.test.js +89 -0
  32. package/package.json +58 -0
  33. package/pi/prompts/bridge.md +14 -0
  34. package/pi/prompts/diagnose.md +15 -0
  35. package/pi/prompts/improve.md +39 -0
  36. package/pi/prompts/learn.md +21 -0
  37. package/pi/prompts/quiz.md +14 -0
  38. package/pi/skills/adaptive-teaching/SKILL.md +33 -0
  39. package/scripts/install/install.sh +307 -0
@@ -0,0 +1,224 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { Prng } from "./random.js";
3
+ import { clamp } from "./util.js";
4
+ import { DEFAULT_POLICY, clampPolicy } from "./policy.js";
5
+ import { benchmarkToMarkdown, runBenchmarkSuite } from "./benchmark.js";
6
+ function diffPolicy(before, after) {
7
+ const keys = [
8
+ "analogyDensity",
9
+ "socraticRatio",
10
+ "formalism",
11
+ "retrievalPractice",
12
+ "exerciseCount",
13
+ "diagramBias",
14
+ "reflectionBias",
15
+ "interdisciplinaryBias",
16
+ "challengeRate"
17
+ ];
18
+ return keys
19
+ .map((field) => {
20
+ const previous = before[field];
21
+ const next = after[field];
22
+ const delta = typeof previous === "number" && typeof next === "number" ? next - previous : 0;
23
+ return { field, before: previous, after: next, delta };
24
+ })
25
+ .filter((entry) => entry.delta !== 0);
26
+ }
27
+ function mutateScalar(prng, value, amplitude = 0.18) {
28
+ return clamp(value + (prng.next() * 2 - 1) * amplitude);
29
+ }
30
+ function mutatePolicy(parent, prng, iteration) {
31
+ const mutated = clampPolicy({
32
+ ...parent,
33
+ name: `keating-candidate-${iteration}`,
34
+ analogyDensity: mutateScalar(prng, parent.analogyDensity),
35
+ socraticRatio: mutateScalar(prng, parent.socraticRatio),
36
+ formalism: mutateScalar(prng, parent.formalism),
37
+ retrievalPractice: mutateScalar(prng, parent.retrievalPractice),
38
+ exerciseCount: parent.exerciseCount + prng.int(-1, 1),
39
+ diagramBias: mutateScalar(prng, parent.diagramBias),
40
+ reflectionBias: mutateScalar(prng, parent.reflectionBias),
41
+ interdisciplinaryBias: mutateScalar(prng, parent.interdisciplinaryBias),
42
+ challengeRate: mutateScalar(prng, parent.challengeRate)
43
+ });
44
+ return mutated;
45
+ }
46
+ function policyVector(policy) {
47
+ return [
48
+ policy.analogyDensity,
49
+ policy.socraticRatio,
50
+ policy.formalism,
51
+ policy.retrievalPractice,
52
+ policy.exerciseCount / 5,
53
+ policy.diagramBias,
54
+ policy.reflectionBias,
55
+ policy.interdisciplinaryBias,
56
+ policy.challengeRate
57
+ ];
58
+ }
59
+ function euclideanDistance(a, b) {
60
+ let sum = 0;
61
+ for (let i = 0; i < a.length; i++) {
62
+ sum += (a[i] - b[i]) ** 2;
63
+ }
64
+ return Math.sqrt(sum / a.length);
65
+ }
66
+ export function noveltyScore(existingPolicies, candidate) {
67
+ if (existingPolicies.length === 0)
68
+ return 1;
69
+ const candidateVec = policyVector(candidate);
70
+ let minDist = Infinity;
71
+ for (const existing of existingPolicies) {
72
+ const dist = euclideanDistance(candidateVec, policyVector(existing));
73
+ minDist = Math.min(minDist, dist);
74
+ }
75
+ return minDist;
76
+ }
77
+ async function loadArchive(filePath) {
78
+ try {
79
+ const content = await readFile(filePath, "utf8");
80
+ return JSON.parse(content);
81
+ }
82
+ catch {
83
+ return {
84
+ currentPolicy: DEFAULT_POLICY,
85
+ bestScore: 0,
86
+ candidates: []
87
+ };
88
+ }
89
+ }
90
+ async function saveArchive(filePath, archive) {
91
+ await writeFile(filePath, `${JSON.stringify(archive, null, 2)}\n`, "utf8");
92
+ }
93
+ export async function evolvePolicy(archivePath, basePolicy, focusTopic, iterations = 24, seed = 20260401) {
94
+ const archive = await loadArchive(archivePath);
95
+ const baseline = runBenchmarkSuite(basePolicy, focusTopic, seed);
96
+ let best = baseline;
97
+ const acceptedCandidates = [];
98
+ const exploredCandidates = [];
99
+ const prng = new Prng(seed + 17);
100
+ const seen = [...archive.candidates.map((entry) => entry.policy), basePolicy];
101
+ for (let iteration = 1; iteration <= iterations; iteration += 1) {
102
+ const candidatePolicy = mutatePolicy(best.policy, prng, iteration);
103
+ const novelty = noveltyScore(seen, candidatePolicy);
104
+ const candidateBenchmark = runBenchmarkSuite(candidatePolicy, focusTopic, seed + iteration * 11);
105
+ const parameterDelta = diffPolicy(best.policy, candidatePolicy);
106
+ const candidate = {
107
+ policy: candidatePolicy,
108
+ benchmark: candidateBenchmark,
109
+ parentName: best.policy.name,
110
+ iteration,
111
+ novelty,
112
+ accepted: false,
113
+ parameterDelta,
114
+ decision: {
115
+ improves: false,
116
+ safe: false,
117
+ novelEnough: false,
118
+ scoreDelta: 0,
119
+ weakestTopicDelta: 0,
120
+ reasons: []
121
+ }
122
+ };
123
+ const bestWeakest = Math.min(...best.topicBenchmarks.map((entry) => entry.meanScore));
124
+ const candidateWeakest = Math.min(...candidateBenchmark.topicBenchmarks.map((entry) => entry.meanScore));
125
+ const improves = candidateBenchmark.overallScore > best.overallScore;
126
+ const safe = candidateWeakest >= bestWeakest - 1.5;
127
+ const novelEnough = novelty >= 0.05;
128
+ candidate.decision.improves = improves;
129
+ candidate.decision.safe = safe;
130
+ candidate.decision.novelEnough = novelEnough;
131
+ candidate.decision.scoreDelta = candidateBenchmark.overallScore - best.overallScore;
132
+ candidate.decision.weakestTopicDelta = candidateWeakest - bestWeakest;
133
+ if (improves) {
134
+ candidate.decision.reasons.push(`overall score improved by ${candidate.decision.scoreDelta.toFixed(2)}`);
135
+ }
136
+ else {
137
+ candidate.decision.reasons.push(`overall score regressed by ${Math.abs(candidate.decision.scoreDelta).toFixed(2)}`);
138
+ }
139
+ if (safe) {
140
+ candidate.decision.reasons.push(`weakest-topic score stayed within tolerance (${candidate.decision.weakestTopicDelta.toFixed(2)})`);
141
+ }
142
+ else {
143
+ candidate.decision.reasons.push(`weakest-topic score fell too far (${candidate.decision.weakestTopicDelta.toFixed(2)})`);
144
+ }
145
+ if (novelEnough) {
146
+ candidate.decision.reasons.push(`novelty ${novelty.toFixed(3)} cleared the 0.05 threshold`);
147
+ }
148
+ else {
149
+ candidate.decision.reasons.push(`novelty ${novelty.toFixed(3)} was too close to archived policies`);
150
+ }
151
+ if (improves && safe && novelEnough) {
152
+ candidate.accepted = true;
153
+ best = candidateBenchmark;
154
+ acceptedCandidates.push(candidate);
155
+ }
156
+ exploredCandidates.push(candidate);
157
+ seen.push(candidate.policy);
158
+ }
159
+ const nextArchive = {
160
+ currentPolicy: best.policy,
161
+ bestScore: best.overallScore,
162
+ candidates: [
163
+ ...archive.candidates,
164
+ ...exploredCandidates.map((entry) => ({
165
+ policy: entry.policy,
166
+ score: entry.benchmark.overallScore,
167
+ novelty: entry.novelty,
168
+ accepted: entry.accepted,
169
+ iteration: entry.iteration
170
+ }))
171
+ ]
172
+ };
173
+ await saveArchive(archivePath, nextArchive);
174
+ return {
175
+ baseline,
176
+ best,
177
+ acceptedCandidates,
178
+ exploredCandidates,
179
+ archive: nextArchive
180
+ };
181
+ }
182
+ export function evolutionToMarkdown(run) {
183
+ const lines = [
184
+ `# Evolution Report: ${run.best.policy.name}`,
185
+ "",
186
+ `- Baseline score: ${run.baseline.overallScore.toFixed(2)}`,
187
+ `- Best score: ${run.best.overallScore.toFixed(2)}`,
188
+ `- Accepted candidates: ${run.acceptedCandidates.length}`,
189
+ `- Explored candidates: ${run.exploredCandidates.length}`,
190
+ ""
191
+ ];
192
+ lines.push("## Accepted Candidates");
193
+ lines.push("");
194
+ if (run.acceptedCandidates.length === 0) {
195
+ lines.push("- No candidate cleared both the novelty and safety gates in this run.");
196
+ }
197
+ else {
198
+ for (const candidate of run.acceptedCandidates) {
199
+ lines.push(`- Iteration ${candidate.iteration}: ${candidate.policy.name} scored ${candidate.benchmark.overallScore.toFixed(2)} with novelty ${candidate.novelty.toFixed(3)}.`);
200
+ }
201
+ }
202
+ lines.push("");
203
+ lines.push("## Decision Ledger");
204
+ lines.push("");
205
+ for (const candidate of run.exploredCandidates) {
206
+ lines.push(`- Iteration ${candidate.iteration} ${candidate.policy.name}: ${candidate.accepted ? "accepted" : "rejected"}`);
207
+ lines.push(` - score delta: ${candidate.decision.scoreDelta.toFixed(2)}`);
208
+ lines.push(` - weakest-topic delta: ${candidate.decision.weakestTopicDelta.toFixed(2)}`);
209
+ lines.push(` - novelty: ${candidate.novelty.toFixed(3)}`);
210
+ lines.push(` - gates: improves=${candidate.decision.improves}, safe=${candidate.decision.safe}, novelEnough=${candidate.decision.novelEnough}`);
211
+ lines.push(` - reasons: ${candidate.decision.reasons.join("; ")}`);
212
+ if (candidate.parameterDelta.length > 0) {
213
+ lines.push(` - parameter delta: ${candidate.parameterDelta
214
+ .map((entry) => `${entry.field}:${entry.delta >= 0 ? "+" : ""}${entry.delta.toFixed(2)}`)
215
+ .join(", ")}`);
216
+ }
217
+ }
218
+ lines.push("");
219
+ lines.push("## Best Benchmark Snapshot");
220
+ lines.push("");
221
+ lines.push(benchmarkToMarkdown(run.best).trim());
222
+ lines.push("");
223
+ return `${lines.join("\n")}\n`;
224
+ }
@@ -0,0 +1,88 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { clamp } from "./util.js";
3
+ const DEFAULT_LEARNER_STATE = {
4
+ coveredTopics: [],
5
+ identifiedMisconceptions: [],
6
+ feedback: [],
7
+ profile: {
8
+ id: "default",
9
+ priorKnowledge: 0.5,
10
+ abstractionComfort: 0.5,
11
+ analogyNeed: 0.5,
12
+ dialoguePreference: 0.5,
13
+ diagramAffinity: 0.5,
14
+ persistence: 0.5,
15
+ transferDesire: 0.5,
16
+ anxiety: 0.3
17
+ }
18
+ };
19
+ export async function loadLearnerState(filePath) {
20
+ // Read JSON from filePath. If file doesn't exist or is invalid, return default with id "learner-1"
21
+ try {
22
+ const raw = await readFile(filePath, "utf8");
23
+ return JSON.parse(raw);
24
+ }
25
+ catch {
26
+ return { id: "learner-1", ...DEFAULT_LEARNER_STATE };
27
+ }
28
+ }
29
+ export async function saveLearnerState(filePath, state) {
30
+ await writeFile(filePath, JSON.stringify(state, null, 2), "utf8");
31
+ }
32
+ export function recordTopicCoverage(state, topic, masteryEstimate) {
33
+ const existing = state.coveredTopics.find(t => t.slug === topic.slug);
34
+ if (existing) {
35
+ existing.lastSeen = new Date().toISOString();
36
+ existing.masteryEstimate = clamp(masteryEstimate);
37
+ existing.sessionCount += 1;
38
+ }
39
+ else {
40
+ state.coveredTopics.push({
41
+ slug: topic.slug,
42
+ domain: topic.domain,
43
+ lastSeen: new Date().toISOString(),
44
+ masteryEstimate: clamp(masteryEstimate),
45
+ sessionCount: 1
46
+ });
47
+ }
48
+ return state;
49
+ }
50
+ export function recordMisconception(state, topic, misconception) {
51
+ const existing = state.identifiedMisconceptions.find(m => m.topic === topic && m.misconception === misconception);
52
+ if (!existing) {
53
+ state.identifiedMisconceptions.push({ topic, misconception, addressed: false });
54
+ }
55
+ return state;
56
+ }
57
+ export function recordFeedback(state, topic, signal) {
58
+ state.feedback.push({
59
+ topic,
60
+ timestamp: new Date().toISOString(),
61
+ signal
62
+ });
63
+ return state;
64
+ }
65
+ export function buildProfileFromFeedback(state) {
66
+ const profile = { ...state.profile };
67
+ if (state.feedback.length === 0)
68
+ return profile;
69
+ const total = state.feedback.length;
70
+ const confusedCount = state.feedback.filter(f => f.signal === "confused").length;
71
+ const positiveCount = state.feedback.filter(f => f.signal === "thumbs-up").length;
72
+ const negativeCount = state.feedback.filter(f => f.signal === "thumbs-down").length;
73
+ const confusionRate = confusedCount / total;
74
+ const satisfactionRate = positiveCount / total;
75
+ // High confusion suggests lower abstraction comfort and higher anxiety
76
+ profile.abstractionComfort = clamp(profile.abstractionComfort - confusionRate * 0.2);
77
+ profile.anxiety = clamp(profile.anxiety + confusionRate * 0.15);
78
+ // High satisfaction suggests the current teaching style works
79
+ profile.persistence = clamp(profile.persistence + satisfactionRate * 0.1);
80
+ // High negative feedback suggests teaching style mismatch
81
+ profile.dialoguePreference = clamp(profile.dialoguePreference + (negativeCount / total) * 0.1);
82
+ // Update prior knowledge from covered topics
83
+ if (state.coveredTopics.length > 0) {
84
+ const avgMastery = state.coveredTopics.reduce((sum, t) => sum + t.masteryEstimate, 0) / state.coveredTopics.length;
85
+ profile.priorKnowledge = clamp(avgMastery);
86
+ }
87
+ return profile;
88
+ }
@@ -0,0 +1,155 @@
1
+ import { resolveTopic } from "./topics.js";
2
+ function prerequisiteBullets(topic) {
3
+ return topic.prerequisites.map((item) => `Recall ${item} and connect it to ${topic.title}.`);
4
+ }
5
+ function misconceptionBullets(topic) {
6
+ return topic.misconceptions.map((item) => `Address misconception: ${item}`);
7
+ }
8
+ function practiceBullets(topic, exerciseCount) {
9
+ const bullets = [...topic.exercises];
10
+ while (bullets.length < exerciseCount) {
11
+ bullets.push(`Invent a new example that makes ${topic.title} easier to explain.`);
12
+ }
13
+ return bullets.slice(0, exerciseCount).map((item) => `Practice: ${item}`);
14
+ }
15
+ export function buildLessonPlan(topicName, policy) {
16
+ const topic = resolveTopic(topicName);
17
+ const phases = [
18
+ {
19
+ id: "orient",
20
+ title: "Orientation",
21
+ purpose: "Assess prerequisites and frame the core question.",
22
+ bullets: [
23
+ `State the big question: ${topic.summary}`,
24
+ ...prerequisiteBullets(topic)
25
+ ]
26
+ },
27
+ {
28
+ id: "intuition",
29
+ title: "Intuition",
30
+ purpose: "Teach the concept concretely before pushing notation or abstract framing.",
31
+ bullets: topic.intuition.map((item) => `Intuition: ${item}`)
32
+ },
33
+ {
34
+ id: "formal-core",
35
+ title: "Formal Core",
36
+ purpose: "Escalate into rigorous structure once intuition has traction.",
37
+ bullets: topic.formalCore.map((item) => `Formal: ${item}`)
38
+ },
39
+ {
40
+ id: "misconceptions",
41
+ title: "Misconception Repair",
42
+ purpose: "Anticipate predictable mistakes before they calcify.",
43
+ bullets: misconceptionBullets(topic)
44
+ },
45
+ {
46
+ id: "examples",
47
+ title: "Worked Examples",
48
+ purpose: "Move between examples so the learner sees the invariant structure.",
49
+ bullets: topic.examples.map((item) => `Example: ${item}`)
50
+ },
51
+ {
52
+ id: "practice",
53
+ title: "Guided Practice",
54
+ purpose: "Force retrieval and re-expression, not passive agreement.",
55
+ bullets: practiceBullets(topic, policy.exerciseCount)
56
+ },
57
+ {
58
+ id: "transfer",
59
+ title: "Transfer and Reflection",
60
+ purpose: "Bridge the concept across domains and make the learner summarize what changed.",
61
+ bullets: [
62
+ ...topic.reflections.map((item) => `Reflect: ${item}`),
63
+ `Bridge ${topic.title} into: ${topic.interdisciplinaryHooks.join(", ")}.`
64
+ ]
65
+ }
66
+ ];
67
+ if (policy.diagramBias >= 0.55) {
68
+ phases.splice(4, 0, {
69
+ id: "diagram",
70
+ title: "Diagram",
71
+ purpose: "Compress the concept into a visual structure before free recall.",
72
+ bullets: [
73
+ `Map the concept using nodes: ${topic.diagramNodes.join(" -> ")}.`,
74
+ `Ask the learner to narrate the diagram without reading from it.`
75
+ ]
76
+ });
77
+ }
78
+ if (policy.socraticRatio >= 0.6) {
79
+ phases[0].bullets.unshift(`Open with a diagnostic question instead of a lecture on ${topic.title}.`);
80
+ phases[5].bullets.unshift(`Pause after each practice step and ask the learner to predict the next move.`);
81
+ }
82
+ // Domain-specific phase customizations
83
+ if (topic.domain === "code") {
84
+ const exIdx = phases.findIndex((p) => p.id === "examples");
85
+ if (exIdx !== -1) {
86
+ phases.splice(exIdx + 1, 0, {
87
+ id: "live-code",
88
+ title: "Live Code",
89
+ purpose: "Write and trace runnable code so the learner sees the concept execute.",
90
+ bullets: [
91
+ `Write a minimal runnable example demonstrating ${topic.title}.`,
92
+ `Step through execution line by line, narrating state changes.`,
93
+ `Ask the learner to predict output before running.`
94
+ ]
95
+ });
96
+ }
97
+ }
98
+ if (topic.domain === "law") {
99
+ const examples = phases.find((p) => p.id === "examples");
100
+ if (examples) {
101
+ examples.bullets.push(`Cite at least one leading case or statute relevant to ${topic.title}.`, `Distinguish the ratio decidendi from obiter dicta.`);
102
+ }
103
+ }
104
+ if (topic.domain === "medicine") {
105
+ const formalCore = phases.find((p) => p.id === "formal-core");
106
+ if (formalCore) {
107
+ formalCore.bullets.push(`Reference the level of evidence (RCT, meta-analysis, observational) for key claims about ${topic.title}.`, `Distinguish mechanism-based reasoning from evidence-based conclusions.`);
108
+ }
109
+ }
110
+ if (topic.domain === "history") {
111
+ const examples = phases.find((p) => p.id === "examples");
112
+ if (examples) {
113
+ examples.bullets.push(`Place ${topic.title} on a timeline with at least two contextual events.`, `Distinguish primary sources from secondary interpretation.`);
114
+ }
115
+ }
116
+ if (topic.domain === "psychology") {
117
+ const misconceptions = phases.find((p) => p.id === "misconceptions");
118
+ if (misconceptions) {
119
+ misconceptions.bullets.push(`Flag the replication status of key studies related to ${topic.title}.`, `Distinguish folk-psychology usage from empirical findings.`);
120
+ }
121
+ }
122
+ if (topic.domain === "politics") {
123
+ const transfer = phases.find((p) => p.id === "transfer");
124
+ if (transfer) {
125
+ transfer.bullets.push(`Present at least two competing analytical frameworks for ${topic.title}.`, `Distinguish normative claims from descriptive ones.`);
126
+ }
127
+ }
128
+ if (topic.domain === "arts") {
129
+ const examples = phases.find((p) => p.id === "examples");
130
+ if (examples) {
131
+ examples.bullets.push(`Ground analysis in at least one specific work that exemplifies ${topic.title}.`, `Connect formal technique to expressive effect.`);
132
+ }
133
+ }
134
+ return { topic, policy, phases };
135
+ }
136
+ export function lessonPlanToMarkdown(plan) {
137
+ const lines = [
138
+ `# Lesson Plan: ${plan.topic.title}`,
139
+ "",
140
+ `- Domain: ${plan.topic.domain}`,
141
+ `- Policy: ${plan.policy.name}`,
142
+ `- Summary: ${plan.topic.summary}`,
143
+ ""
144
+ ];
145
+ for (const phase of plan.phases) {
146
+ lines.push(`## ${phase.title}`);
147
+ lines.push(phase.purpose);
148
+ lines.push("");
149
+ for (const bullet of phase.bullets) {
150
+ lines.push(`- ${bullet}`);
151
+ }
152
+ lines.push("");
153
+ }
154
+ return `${lines.join("\n").trim()}\n`;
155
+ }
@@ -0,0 +1,89 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { buildLessonPlan } from "./lesson-plan.js";
5
+ import { mapsDir } from "./paths.js";
6
+ import { slugify } from "./util.js";
7
+ function mermaidLabel(text) {
8
+ return text.replaceAll('"', "'").replaceAll("\n", " ");
9
+ }
10
+ export function lessonPlanToMermaid(topicName, policy) {
11
+ const plan = buildLessonPlan(topicName, policy);
12
+ const topicId = slugify(plan.topic.title);
13
+ const lines = [
14
+ "graph TD",
15
+ " classDef phase fill:#12263f,stroke:#8db5ff,color:#f8f5ec,stroke-width:1px;",
16
+ " classDef concept fill:#173122,stroke:#8fd7b6,color:#f8f5ec,stroke-width:1px;",
17
+ " classDef friction fill:#3a2317,stroke:#ff9b6b,color:#f8f5ec,stroke-width:1px;",
18
+ " classDef transfer fill:#2f2140,stroke:#d3a6ff,color:#f8f5ec,stroke-width:1px;",
19
+ ` learner(("Learner state"))`,
20
+ ` thesis["${mermaidLabel(plan.topic.title)}: ${mermaidLabel(plan.topic.summary)}"]`
21
+ ];
22
+ lines.push(' subgraph pedagogy["Teaching Loop"]');
23
+ for (const phase of plan.phases) {
24
+ lines.push(` ${phase.id}["${mermaidLabel(phase.title)}"]`);
25
+ }
26
+ for (let index = 0; index < plan.phases.length - 1; index += 1) {
27
+ lines.push(` ${plan.phases[index].id} --> ${plan.phases[index + 1].id}`);
28
+ }
29
+ lines.push(" end");
30
+ lines.push(' subgraph meaning["Meaning Map"]');
31
+ lines.push(` ${topicId}_core["${mermaidLabel(plan.topic.title)}"]`);
32
+ for (const [index, node] of plan.topic.diagramNodes.entries()) {
33
+ lines.push(` ${topicId}_concept_${index}["${mermaidLabel(node)}"]`);
34
+ lines.push(` ${topicId}_core --> ${topicId}_concept_${index}`);
35
+ }
36
+ for (const [index, prerequisite] of plan.topic.prerequisites.slice(0, 3).entries()) {
37
+ lines.push(` ${topicId}_prereq_${index}["Prereq: ${mermaidLabel(prerequisite)}"]`);
38
+ lines.push(` ${topicId}_prereq_${index} --> ${topicId}_core`);
39
+ }
40
+ lines.push(" end");
41
+ lines.push(' subgraph friction["Misconceptions And Practice"]');
42
+ for (const [index, misconception] of plan.topic.misconceptions.slice(0, 2).entries()) {
43
+ lines.push(` ${topicId}_mis_${index}["${mermaidLabel(misconception)}"]`);
44
+ lines.push(` ${topicId}_core --> ${topicId}_mis_${index}`);
45
+ }
46
+ for (const [index, exercise] of plan.topic.exercises.slice(0, 2).entries()) {
47
+ lines.push(` ${topicId}_exercise_${index}["Practice: ${mermaidLabel(exercise)}"]`);
48
+ lines.push(` ${topicId}_mis_${Math.min(index, Math.max(plan.topic.misconceptions.length - 1, 0))} --> ${topicId}_exercise_${index}`);
49
+ }
50
+ lines.push(" end");
51
+ lines.push(' subgraph transfer["Transfer Hooks"]');
52
+ for (const [index, hook] of plan.topic.interdisciplinaryHooks.entries()) {
53
+ lines.push(` ${topicId}_hook_${index}["${mermaidLabel(hook)}"]`);
54
+ lines.push(` ${topicId}_core --> ${topicId}_hook_${index}`);
55
+ }
56
+ lines.push(" end");
57
+ lines.push(" learner --> orient");
58
+ lines.push(" thesis --> orient");
59
+ lines.push(` ${plan.phases.at(-1).id} --> ${topicId}_hook_0`);
60
+ lines.push(` ${plan.phases.find((phase) => phase.id === "diagram")?.id ?? "examples"} --> ${topicId}_core`);
61
+ lines.push(` class ${plan.phases.map((phase) => phase.id).join(",")} phase;`);
62
+ lines.push(` class ${topicId}_core,${plan.topic.diagramNodes.map((_, index) => `${topicId}_concept_${index}`).join(",")},${plan.topic.prerequisites.slice(0, 3).map((_, index) => `${topicId}_prereq_${index}`).join(",")} concept;`);
63
+ const frictionNodes = [
64
+ ...plan.topic.misconceptions.slice(0, 2).map((_, index) => `${topicId}_mis_${index}`),
65
+ ...plan.topic.exercises.slice(0, 2).map((_, index) => `${topicId}_exercise_${index}`)
66
+ ];
67
+ if (frictionNodes.length > 0) {
68
+ lines.push(` class ${frictionNodes.join(",")} friction;`);
69
+ }
70
+ const transferNodes = plan.topic.interdisciplinaryHooks.map((_, index) => `${topicId}_hook_${index}`);
71
+ if (transferNodes.length > 0) {
72
+ lines.push(` class ${transferNodes.join(",")} transfer;`);
73
+ }
74
+ return `${lines.join("\n")}\n`;
75
+ }
76
+ export async function writeLessonMap(cwd, topicName, policy) {
77
+ const slug = slugify(topicName);
78
+ const mmdPath = join(mapsDir(cwd), `${slug}.mmd`);
79
+ const svgPath = join(mapsDir(cwd), `${slug}.svg`);
80
+ await writeFile(mmdPath, lessonPlanToMermaid(topicName, policy), "utf8");
81
+ const render = spawnSync("oxdraw", ["--input", mmdPath, "--output", svgPath], {
82
+ cwd,
83
+ encoding: "utf8"
84
+ });
85
+ if (render.status === 0) {
86
+ return { mmdPath, svgPath };
87
+ }
88
+ return { mmdPath, svgPath: null };
89
+ }
@@ -0,0 +1,69 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export function keatingRoot(cwd) {
4
+ return join(cwd, ".keating");
5
+ }
6
+ export function stateDir(cwd) {
7
+ return join(keatingRoot(cwd), "state");
8
+ }
9
+ export function outputsDir(cwd) {
10
+ return join(keatingRoot(cwd), "outputs");
11
+ }
12
+ export function plansDir(cwd) {
13
+ return join(outputsDir(cwd), "plans");
14
+ }
15
+ export function mapsDir(cwd) {
16
+ return join(outputsDir(cwd), "maps");
17
+ }
18
+ export function animationsDir(cwd) {
19
+ return join(outputsDir(cwd), "animations");
20
+ }
21
+ export function benchmarksDir(cwd) {
22
+ return join(outputsDir(cwd), "benchmarks");
23
+ }
24
+ export function evolutionDir(cwd) {
25
+ return join(outputsDir(cwd), "evolution");
26
+ }
27
+ export function promptEvolutionDir(cwd) {
28
+ return join(outputsDir(cwd), "prompt-evolution");
29
+ }
30
+ export function tracesDir(cwd) {
31
+ return join(outputsDir(cwd), "traces");
32
+ }
33
+ export function sessionsDir(cwd) {
34
+ return join(keatingRoot(cwd), "sessions");
35
+ }
36
+ export function currentPolicyPath(cwd) {
37
+ return join(stateDir(cwd), "current-policy.json");
38
+ }
39
+ export function policyArchivePath(cwd) {
40
+ return join(stateDir(cwd), "policy-archive.json");
41
+ }
42
+ export function promptEvolutionArchivePath(cwd) {
43
+ return join(stateDir(cwd), "prompt-evolution-archive.json");
44
+ }
45
+ export function learnerStatePath(cwd) {
46
+ return join(stateDir(cwd), "learner.json");
47
+ }
48
+ export function verificationsDir(cwd) {
49
+ return join(outputsDir(cwd), "verifications");
50
+ }
51
+ export function verificationCachePath(cwd) {
52
+ return join(stateDir(cwd), "verification-cache.json");
53
+ }
54
+ export async function ensureKeatingDirs(cwd) {
55
+ await Promise.all([
56
+ mkdir(stateDir(cwd), { recursive: true }),
57
+ mkdir(join(stateDir(cwd), "snapshots"), { recursive: true }),
58
+ mkdir(plansDir(cwd), { recursive: true }),
59
+ mkdir(mapsDir(cwd), { recursive: true }),
60
+ mkdir(animationsDir(cwd), { recursive: true }),
61
+ mkdir(benchmarksDir(cwd), { recursive: true }),
62
+ mkdir(evolutionDir(cwd), { recursive: true }),
63
+ mkdir(promptEvolutionDir(cwd), { recursive: true }),
64
+ mkdir(tracesDir(cwd), { recursive: true }),
65
+ mkdir(verificationsDir(cwd), { recursive: true }),
66
+ mkdir(join(outputsDir(cwd), "improvements"), { recursive: true }),
67
+ mkdir(sessionsDir(cwd), { recursive: true })
68
+ ]);
69
+ }
@@ -0,0 +1,58 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { loadKeatingConfig, mergePiDefaults } from "./config.js";
3
+ /**
4
+ * Programmatic interface to the Pi agent via CLI.
5
+ * This ensures we use the same provider, model, and thinking settings as the user's Pi install.
6
+ */
7
+ export async function piComplete(cwd, prompt, options = {}) {
8
+ const config = await loadKeatingConfig(cwd);
9
+ const args = ["-p", "--no-session", "--no-tools", "--no-extensions", "--no-skills"];
10
+ if (options.systemPrompt) {
11
+ args.push("--system-prompt", options.systemPrompt);
12
+ }
13
+ if (options.json) {
14
+ args.push("--mode", "json");
15
+ }
16
+ if (options.thinking) {
17
+ args.push("--thinking", options.thinking);
18
+ }
19
+ const finalArgs = mergePiDefaults(config, [...args, prompt]);
20
+ const result = spawnSync("pi", finalArgs, {
21
+ cwd,
22
+ encoding: "utf8",
23
+ env: {
24
+ ...process.env,
25
+ PI_SKIP_VERSION_CHECK: "1"
26
+ }
27
+ });
28
+ if (result.status !== 0) {
29
+ throw new Error(`Pi completion failed (exit ${result.status}): ${result.stderr || result.stdout}`);
30
+ }
31
+ return result.stdout.trim();
32
+ }
33
+ /**
34
+ * Specialized helper for JSON completions.
35
+ */
36
+ export async function piCompleteJson(cwd, prompt, options = {}) {
37
+ const response = await piComplete(cwd, prompt, { ...options, json: true });
38
+ try {
39
+ // Some models output reasoning BEFORE the JSON block.
40
+ // We try to find all JSON-like blocks and pick the one that parses successfully,
41
+ // prioritizing the last one.
42
+ const matches = response.match(/\{[\s\S]*?\}/g);
43
+ if (!matches)
44
+ return JSON.parse(response);
45
+ for (let i = matches.length - 1; i >= 0; i--) {
46
+ try {
47
+ return JSON.parse(matches[i]);
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ }
53
+ throw new Error("No valid JSON block found in response.");
54
+ }
55
+ catch (error) {
56
+ throw new Error(`Failed to parse Pi JSON response: ${response}\nError: ${error}`);
57
+ }
58
+ }