@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.
- package/README.md +274 -0
- package/bin/keating.js +31 -0
- package/dist/src/cli/main.js +165 -0
- package/dist/src/core/animation.js +372 -0
- package/dist/src/core/benchmark.js +238 -0
- package/dist/src/core/config.js +81 -0
- package/dist/src/core/evolution.js +224 -0
- package/dist/src/core/learner-state.js +88 -0
- package/dist/src/core/lesson-plan.js +155 -0
- package/dist/src/core/map.js +89 -0
- package/dist/src/core/paths.js +69 -0
- package/dist/src/core/pi-agent.js +58 -0
- package/dist/src/core/policy.js +53 -0
- package/dist/src/core/project.js +189 -0
- package/dist/src/core/prompt-evolution.js +337 -0
- package/dist/src/core/random.js +19 -0
- package/dist/src/core/self-improve.js +419 -0
- package/dist/src/core/topics.js +620 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/core/util.js +28 -0
- package/dist/src/core/verification.js +162 -0
- package/dist/src/pi/hyperteacher-extension.js +180 -0
- package/dist/src/runtime/pi.js +118 -0
- package/dist/test/animation.test.js +43 -0
- package/dist/test/config.test.js +36 -0
- package/dist/test/evolution.test.js +39 -0
- package/dist/test/fuzz.test.js +37 -0
- package/dist/test/hyperteacher-extension.test.js +122 -0
- package/dist/test/lesson-plan.test.js +35 -0
- package/dist/test/pipeline.test.js +57 -0
- package/dist/test/prompt-evolution.test.js +89 -0
- package/package.json +58 -0
- package/pi/prompts/bridge.md +14 -0
- package/pi/prompts/diagnose.md +15 -0
- package/pi/prompts/improve.md +39 -0
- package/pi/prompts/learn.md +21 -0
- package/pi/prompts/quiz.md +14 -0
- package/pi/skills/adaptive-teaching/SKILL.md +33 -0
- 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
|
+
}
|