learn-loops 0.1.0 → 0.4.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.
@@ -1,12 +1,16 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { join } from "node:path";
3
3
  import { askJson, askText } from "../llm.js";
4
+ import { PERSONAS } from "../prompts.js";
4
5
  import { bigPicturePrompt, deepLessonPrompt, mapPrompt, outlinePrompt, reexplainPrompt, surveyLessonPrompt } from "../prompts.js";
5
- import { lessonMarkdown, mapMarkdown, writeIndex } from "../markdown.js";
6
- import { createTopic, listCategories, saveTopic, slugify, writeText } from "../store.js";
6
+ import { clean, lessonMarkdown, materializeFigures, mapMarkdown, mistakesMarkdown, nodeId, writeIndex } from "../markdown.js";
7
+ import { renderDiagram } from "../diagram.js";
8
+ import { addMistake, createTopic, listCategories, saveTopic, slugify, writeText } from "../store.js";
9
+ import { recordActivity } from "../stats.js";
7
10
  import { addCards, isDue } from "../srs.js";
8
- import { bar, must, renderMd, t, withSpinner } from "../ui.js";
11
+ import { bar, m, must, renderMd, t, withSpinner } from "../ui.js";
9
12
  import { reviewCards, runChecks, teachBack } from "./quiz.js";
13
+ import { applicationChallenge, diagnose, predictThenReveal, socraticWarmup, thinkDeeper } from "./think.js";
10
14
  const str = (v, name) => {
11
15
  if (typeof v !== "string" || !v.trim())
12
16
  throw new Error(`missing string "${name}"`);
@@ -24,7 +28,7 @@ function validateMap(x) {
24
28
  }));
25
29
  return { overview: String(x.overview ?? ""), areas };
26
30
  }
27
- function validateLesson(x) {
31
+ export function validateLesson(x) {
28
32
  const checks = (Array.isArray(x.checks) ? x.checks : []).map((c) => {
29
33
  if (!Array.isArray(c.options) || c.options.length < 2 || !Number.isInteger(c.answer) || c.answer < 0 || c.answer >= c.options.length)
30
34
  throw new Error("bad check options/answer");
@@ -37,6 +41,9 @@ function validateLesson(x) {
37
41
  teachBack: str(x.teachBack, "teachBack"),
38
42
  keyPoints: (Array.isArray(x.keyPoints) ? x.keyPoints : []).map(String),
39
43
  cards: (Array.isArray(x.cards) ? x.cards : []).filter((c) => c?.front && c?.back).map((c) => ({ front: String(c.front), back: String(c.back) })),
44
+ prediction: x.prediction?.scenario && x.prediction?.question && x.prediction?.outcome ? { scenario: String(x.prediction.scenario), question: String(x.prediction.question), outcome: String(x.prediction.outcome) } : undefined,
45
+ deeper: x.deeper?.question ? { question: String(x.deeper.question), considerations: (Array.isArray(x.deeper.considerations) ? x.deeper.considerations : []).map(String) } : undefined,
46
+ socratic: Array.isArray(x.socratic) ? x.socratic.filter((q) => q?.q).map((q) => ({ q: String(q.q), hint: String(q.hint ?? ""), idea: String(q.idea ?? "") })) : undefined,
40
47
  };
41
48
  }
42
49
  const validateOutline = (x) => {
@@ -58,7 +65,12 @@ export async function newTopic(profile, title) {
58
65
  message: `Brain dump: what do you already know (or think you know) about ${name}?`,
59
66
  placeholder: "anything — even guesses. Blank if nothing. This sets the starting point.",
60
67
  }));
68
+ const useCase = must(await p.text({
69
+ message: `Where do you hope to use ${name}? (a project, a job, a problem you have)`,
70
+ placeholder: "optional — examples will be drawn from this",
71
+ }));
61
72
  const topic = await createTopic(profile, name.trim(), category, prior.trim());
73
+ topic.state.useCase = useCase.trim() || undefined;
62
74
  await buildMap(profile, topic);
63
75
  return topic;
64
76
  }
@@ -81,6 +93,13 @@ export function printMap(topic) {
81
93
  a.concepts.forEach((c, j) => lines.push(`${t.dim(last ? " " : "│ ")} ${t.dim(j === a.concepts.length - 1 ? "└─" : "├─")} ${c.name}`));
82
94
  });
83
95
  p.note(lines.join("\n"), `${topic.state.title} — the map`);
96
+ const edges = map.areas.flatMap((a) => a.prereqs.filter((q) => map.areas.some((x) => x.id === q)).map((q) => ` ${nodeId(q)} --> ${nodeId(a.id)}`));
97
+ if (edges.length) {
98
+ const flow = ["flowchart LR", ...map.areas.map((a) => ` ${nodeId(a.id)}["${clean(a.title)}"]`), ...edges].join("\n");
99
+ const art = renderDiagram(flow);
100
+ if (art)
101
+ p.note(art, "Suggested order (arrow = learn first)");
102
+ }
84
103
  p.log.info(t.dim(`Saved with mermaid diagrams: ${join(topic.dir, "map.md")}`));
85
104
  }
86
105
  /** Warm-up retrieval of older material before new learning (spacing + interleaving). */
@@ -96,23 +115,30 @@ async function warmUp(topic) {
96
115
  ],
97
116
  }));
98
117
  if (go)
99
- await reviewCards([topic], 5);
118
+ await reviewCards([topic], 5, "due");
100
119
  }
101
120
  async function teach(ctx) {
102
121
  const { profile, topic } = ctx;
103
122
  const lesson = await withSpinner(`Preparing "${ctx.title}"...`, ctx.load);
104
123
  p.log.step(t.bold(t.accent(ctx.title)));
124
+ const extras = {};
125
+ // 0. Socratic mode: work toward the idea through leading questions first.
126
+ const socratic = ctx.deep && profile.socratic && lesson.socratic?.length ? lesson.socratic : undefined;
127
+ if (socratic)
128
+ extras.socratic = await socraticWarmup(socratic);
105
129
  // 1. Pretest: guessing before reading primes attention even when wrong.
106
- if (lesson.primer) {
107
- must(await p.text({ message: `${t.pink("Guess first")} — ${lesson.primer}`, placeholder: "a rough guess is fine (enter to skip)" }));
130
+ if (lesson.primer && !socratic) {
131
+ must(await p.text({ message: `${t.pink("Guess first")} — ${m(lesson.primer)}`, placeholder: "a rough guess is fine (enter to skip)" }));
108
132
  }
109
133
  // 2. The chunk, with an escape hatch.
134
+ if (socratic)
135
+ p.log.message(t.dim("Here's how the pieces fit together:"));
110
136
  console.log(renderMd(lesson.markdown) + "\n");
111
137
  while (true) {
112
138
  const next = must(await p.select({
113
139
  message: "Does it make sense?",
114
140
  options: [
115
- { value: "go", label: "Yes — quiz me" },
141
+ { value: "go", label: "Yes — continue" },
116
142
  { value: "again", label: "Not really — explain it differently" },
117
143
  ],
118
144
  }));
@@ -122,13 +148,66 @@ async function teach(ctx) {
122
148
  console.log(renderMd(alt) + "\n");
123
149
  lesson.markdown += `\n\n---\n\n### Another angle\n\n${alt}`;
124
150
  }
125
- // 3. Retrieval practice, then 4. teach-back.
126
- const missed = await runChecks(lesson.checks);
127
- const attempts = await teachBack(profile, ctx.title, lesson.teachBack, lesson.keyPoints);
128
- const cards = [...lesson.cards, ...missed.map((m) => ({ front: m.q, back: `${m.options[m.answer]} — ${m.explain}` }))];
151
+ // 3. Predict-then-reveal.
152
+ if (ctx.deep && lesson.prediction)
153
+ extras.prediction = await predictThenReveal(lesson.prediction);
154
+ // 4. Retrieval practice with confidence.
155
+ const result = await runChecks(lesson.checks);
156
+ extras.missed = result.missed.map((m) => m.q);
157
+ for (const m of result.missed) {
158
+ const over = result.overconfident.includes(m);
159
+ addMistake(topic, { lesson: ctx.title, kind: over ? "overconfident" : "quiz", what: m.q, correction: `${m.options[m.answer]} — ${m.explain}` });
160
+ }
161
+ // 5. Teach-back, to a persona on deep lessons.
162
+ const persona = ctx.deep ? PERSONAS[Math.floor(Math.random() * PERSONAS.length)] : undefined;
163
+ extras.attempts = await teachBack(profile, ctx.title, lesson.teachBack, lesson.keyPoints, persona);
164
+ const last = extras.attempts.at(-1)?.grade;
165
+ if (last && last.score <= 1) {
166
+ for (const g of last.misconceptions.length ? last.misconceptions : last.missing.slice(0, 2)) {
167
+ addMistake(topic, { lesson: ctx.title, kind: "explanation", what: g, correction: last.feedback });
168
+ }
169
+ }
170
+ // 6. Stuck? Diagnose the mental model instead of repeating the lesson.
171
+ if (result.missed.length >= 2 || (last && last.score <= 1)) {
172
+ const go = must(await p.select({
173
+ message: "This one's tripping you up. Want me to find out why?",
174
+ options: [
175
+ { value: true, label: "Yes — diagnose it", hint: "2 quick questions" },
176
+ { value: false, label: "Skip" },
177
+ ],
178
+ }));
179
+ if (go) {
180
+ const m = await diagnose(profile, ctx.title, result.missed, last?.feedback ?? "");
181
+ if (m)
182
+ addMistake(topic, m);
183
+ }
184
+ }
185
+ // 7. Think deeper: trade-offs, failure modes, second-order effects.
186
+ if (ctx.deep) {
187
+ const rounds = await thinkDeeper(profile, topic.state, ctx.title, lesson.deeper);
188
+ if (rounds.length)
189
+ extras.deeper = rounds;
190
+ // 8. Personal connection.
191
+ const conn = must(await p.text({ message: `${t.pink("Where could you use this?")}`, placeholder: "a project, a decision, something you've seen (blank to skip)" })).trim();
192
+ if (conn) {
193
+ extras.connection = conn;
194
+ topic.state.connections = [...(topic.state.connections ?? []), conn].slice(-20);
195
+ }
196
+ }
197
+ // Cards: lesson cards plus everything missed; confident mistakes are flagged to resurface first.
198
+ const cards = [...lesson.cards, ...result.missed.map((m) => ({ front: m.q, back: `${m.options[m.answer]} — ${m.explain}` }))];
129
199
  const added = addCards(topic.state.cards, cards, ctx.title);
130
- await writeText(join(topic.dir, ctx.file), lessonMarkdown(ctx.title, lesson, attempts, missed.map((m) => m.q)));
200
+ for (const m of result.overconfident) {
201
+ const c = topic.state.cards.find((x) => x.front.trim().toLowerCase() === m.q.trim().toLowerCase());
202
+ if (c)
203
+ c.flag = "overconfident";
204
+ }
205
+ await writeText(join(topic.dir, ctx.file), await materializeFigures(lessonMarkdown(ctx.title, lesson, extras), topic.dir, ctx.file));
206
+ await writeText(join(topic.dir, "mistakes.md"), mistakesMarkdown(topic));
131
207
  p.log.success(`${added} flashcard${added === 1 ? "" : "s"} added to your review queue ${t.dim("→ " + ctx.file)}`);
208
+ const streak = await recordActivity();
209
+ if (streak.message)
210
+ p.log.success(t.warn("🔥 ") + t.bold(streak.message));
132
211
  }
133
212
  const overTime = (start, profile) => (Date.now() - start) / 60_000 >= profile.minutesPerSession;
134
213
  export async function continueTopic(profile, topic) {
@@ -160,6 +239,7 @@ export async function continueTopic(profile, topic) {
160
239
  profile,
161
240
  topic,
162
241
  title: next.title,
242
+ deep: false,
163
243
  file: `survey/${next.id}.md`,
164
244
  load: () => askJson(profile, surveyLessonPrompt(profile, s, next), validateLesson),
165
245
  });
@@ -175,6 +255,10 @@ export async function continueTopic(profile, topic) {
175
255
  if (!area)
176
256
  return;
177
257
  const pr = s.progress[area.id];
258
+ if (pr.outline && pr.done.length >= pr.outline.length) {
259
+ p.log.info(t.dim(`${area.title} is already complete — pick another.`));
260
+ continue;
261
+ }
178
262
  if (!pr.outline) {
179
263
  pr.outline = await withSpinner(`Planning your deep dive into ${area.title}...`, () => askJson(profile, outlinePrompt(profile, s, area), validateOutline));
180
264
  await saveTopic(topic);
@@ -189,6 +273,7 @@ export async function continueTopic(profile, topic) {
189
273
  profile,
190
274
  topic,
191
275
  title: l.title,
276
+ deep: true,
192
277
  file: `deep/${area.id}/${String(i + 1).padStart(2, "0")}-${l.id}.md`,
193
278
  load: () => askJson(profile, deepLessonPrompt(profile, s, area, l, doneTitles), validateLesson),
194
279
  });
@@ -199,6 +284,22 @@ export async function continueTopic(profile, topic) {
199
284
  return;
200
285
  }
201
286
  p.log.success(t.ok(`You finished the deep dive on ${area.title}. 🎉`));
287
+ const take = must(await p.select({
288
+ message: "Test it on a real-world scenario with trade-offs?",
289
+ options: [
290
+ { value: true, label: "Yes — application challenge" },
291
+ { value: false, label: "Skip" },
292
+ ],
293
+ }));
294
+ if (take) {
295
+ const res = await applicationChallenge(profile, s, area, pr.outline.map((l) => l.title));
296
+ if (res) {
297
+ await writeText(join(topic.dir, "deep", area.id, "challenge.md"), `# Challenge — ${area.title}\n\n${res.scenario}\n\n**Task:** ${res.task}\n\n## My answer\n\n${res.answer.split("\n").map((l) => "> " + l).join("\n")}\n\n## Feedback\n\n${res.critique.feedback}\n\n${res.critique.blindSpots.map((b) => `- Overlooked: ${b}`).join("\n")}\n`);
298
+ const r = await recordActivity();
299
+ if (r.message)
300
+ p.log.success(t.warn("🔥 ") + t.bold(r.message));
301
+ }
302
+ }
202
303
  const more = must(await p.select({ message: "Next?", options: [{ value: true, label: "Pick another area to go deep on" }, { value: false, label: "Stop here" }] }));
203
304
  if (!more)
204
305
  return;
@@ -224,7 +325,7 @@ async function pickArea(topic) {
224
325
  p.log.success("You've gone deep on every area. Review your cards to keep it fresh with `loops review`.");
225
326
  return null;
226
327
  }
227
- const started = open.find((a) => s.progress[a.id].done.length > 0);
328
+ const started = open.find((a) => s.progress[a.id].done.length > 0) ?? open[0];
228
329
  const choice = must(await p.select({
229
330
  message: "Which area do you want to go deep on?",
230
331
  initialValue: started?.id,
@@ -98,13 +98,26 @@ export async function onboard(existing) {
98
98
  { value: "socratic", label: "Socratic — ask me questions" },
99
99
  ],
100
100
  }));
101
+ const socratic = must(await p.select({
102
+ message: "How should new ideas be introduced?",
103
+ initialValue: d?.socratic ?? false,
104
+ options: [
105
+ { value: false, label: "Explain, then quiz me", hint: "classic" },
106
+ { value: true, label: "Socratic — ask me leading questions first", hint: "slower, sticks better" },
107
+ ],
108
+ }));
109
+ const context = must(await p.text({
110
+ message: "What are you working on or hoping to use this for? (job, project, problem)",
111
+ initialValue: d?.context,
112
+ placeholder: "optional — examples will be drawn from this",
113
+ }));
101
114
  const minutesPerSession = must(await p.select({
102
115
  message: "How long is a typical study session?",
103
116
  initialValue: d?.minutesPerSession ?? 25,
104
117
  options: [10, 15, 25, 45, 60].map((m) => ({ value: m, label: `${m} min` })),
105
118
  }));
106
119
  const rootDir = must(await p.text({ message: "Where should your learning folders live?", initialValue: d?.rootDir ?? join(homedir(), "Learning").replace(homedir(), "~") }));
107
- const profile = { name: name.trim(), provider, goal, level, minutesPerSession, styles, order, diagrams, challenge, tone, rootDir };
120
+ const profile = { name: name.trim(), provider, goal, level, minutesPerSession, styles, order, diagrams, challenge, tone, rootDir, socratic, context: context.trim() || undefined };
108
121
  await saveProfile(profile);
109
122
  p.outro(t.ok(`Profile saved. Nice to meet you, ${profile.name}!`));
110
123
  return profile;
@@ -1,8 +1,8 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { askJson } from "../llm.js";
3
- import { gradeLabel, gradePrompt } from "../prompts.js";
3
+ import { gradeLabel, gradePrompt, personaPrompt } from "../prompts.js";
4
4
  import { schedule, isDue } from "../srs.js";
5
- import { must, t, withSpinner } from "../ui.js";
5
+ import { m, must, t, withSpinner } from "../ui.js";
6
6
  import { saveTopic } from "../store.js";
7
7
  export function validateGrade(x) {
8
8
  const score = Number(x.score);
@@ -11,42 +11,93 @@ export function validateGrade(x) {
11
11
  const arr = (v) => (Array.isArray(v) ? v.map(String) : []);
12
12
  return { score: score, right: arr(x.right), missing: arr(x.missing), misconceptions: arr(x.misconceptions), feedback: String(x.feedback ?? ""), followUp: String(x.followUp ?? "") };
13
13
  }
14
- /** Multiple-choice checks. Returns the questions the learner got wrong. */
14
+ /** Multiple-choice checks with a confidence rating, so confident mistakes are caught and flagged. */
15
15
  export async function runChecks(checks) {
16
- const missed = [];
16
+ const res = { missed: [], overconfident: [], correct: 0, total: checks.length };
17
+ let sure = 0;
18
+ let sureRight = 0;
17
19
  for (const [i, c] of checks.entries()) {
18
- const picked = must(await p.select({
19
- message: `${t.accent(`Q${i + 1}/${checks.length}`)} ${c.q}`,
20
- options: c.options.map((o, idx) => ({ value: idx, label: o })),
20
+ // Models over-favour option A: shuffle what's shown, keep `c` untouched for logging.
21
+ const order = c.options.map((_, idx) => idx).sort(() => Math.random() - 0.5);
22
+ const shown = must(await p.select({
23
+ message: `${t.accent(`Q${i + 1}/${checks.length}`)} ${m(c.q)}`,
24
+ options: order.map((orig, pos) => ({ value: pos, label: m(c.options[orig]) })),
21
25
  }));
22
- if (picked === c.answer)
23
- p.log.success(t.ok("Correct. ") + t.dim(c.explain));
26
+ const picked = order[shown];
27
+ const confidence = must(await p.select({
28
+ message: "How sure are you?",
29
+ options: [
30
+ { value: 1, label: "Guessing" },
31
+ { value: 2, label: "Not sure" },
32
+ { value: 3, label: "Pretty sure" },
33
+ { value: 4, label: "Certain" },
34
+ ],
35
+ }));
36
+ const right = picked === c.answer;
37
+ if (confidence >= 3) {
38
+ sure++;
39
+ if (right)
40
+ sureRight++;
41
+ }
42
+ if (right) {
43
+ res.correct++;
44
+ p.log.success(t.ok("Correct. ") + t.dim(m(c.explain)));
45
+ }
24
46
  else {
25
- missed.push(c);
26
- p.log.error(t.bad("Not quite. ") + `Answer: ${c.options[c.answer]}\n ${t.dim(c.explain)}`);
47
+ res.missed.push(c);
48
+ if (confidence >= 3) {
49
+ res.overconfident.push(c);
50
+ p.log.error(t.bad("Wrong — and you were sure. ") + t.warn("Those are the most valuable mistakes to catch.") + `\n Answer: ${m(c.options[c.answer])}\n ${t.dim(m(c.explain))}`);
51
+ }
52
+ else
53
+ p.log.error(t.bad("Not quite. ") + `Answer: ${m(c.options[c.answer])}\n ${t.dim(m(c.explain))}`);
27
54
  }
28
55
  }
29
- return missed;
56
+ if (sure > 0 && checks.length > 1)
57
+ p.log.info(t.dim(`Calibration: right on ${sureRight} of ${sure} answers you were sure about.`));
58
+ return res;
30
59
  }
31
60
  export function showGrade(g) {
32
61
  const color = g.score >= 3 ? t.ok : g.score === 2 ? t.cyan : g.score === 1 ? t.warn : t.bad;
33
62
  const lines = [
34
63
  `${color(`${"●".repeat(g.score)}${"○".repeat(3 - g.score)} ${gradeLabel(g)}`)}`,
35
64
  "",
36
- g.feedback,
37
- ...g.right.map((r) => `${t.ok("✓")} ${r}`),
38
- ...g.missing.map((r) => `${t.warn("△")} missing: ${r}`),
39
- ...g.misconceptions.map((r) => `${t.bad("✗")} ${r}`),
65
+ m(g.feedback),
66
+ ...g.right.map((r) => `${t.ok("✓")} ${m(r)}`),
67
+ ...g.missing.map((r) => `${t.warn("△")} missing: ${m(r)}`),
68
+ ...g.misconceptions.map((r) => `${t.bad("✗")} ${m(r)}`),
40
69
  ];
41
70
  p.note(lines.join("\n"), "Feedback");
42
71
  }
43
- /** Feynman-style teach-back with one optional retry. */
44
- export async function teachBack(profile, title, prompt, keyPoints) {
72
+ /**
73
+ * Feynman-style teach-back. With a persona, you explain to a character who then asks a follow-up
74
+ * question about your biggest gap; the whole exchange is graded. Without, you get one graded retry.
75
+ */
76
+ export async function teachBack(profile, title, prompt, keyPoints, persona) {
77
+ if (persona) {
78
+ const first = must(await p.text({ message: `${t.pink("Explain it to " + persona.label)} — ${m(prompt)}`, placeholder: "in your own words (enter to submit, blank to skip)" })).trim();
79
+ if (!first)
80
+ return [];
81
+ let transcript = first;
82
+ try {
83
+ const turn = await withSpinner(`${persona.label[0].toUpperCase() + persona.label.slice(1)} is thinking...`, () => askJson(profile, personaPrompt(profile, persona, title, keyPoints, first), (x) => ({ reaction: String(x.reaction ?? ""), question: String(x.question ?? "") })));
84
+ p.log.message(`${t.pink(persona.label)}: ${m(turn.reaction)}\n${t.bold(m(turn.question))}`);
85
+ const second = must(await p.text({ message: "Your reply", placeholder: "answer their question (blank to skip)" })).trim();
86
+ if (second)
87
+ transcript += `\n\n[Follow-up: ${turn.question}]\n${second}`;
88
+ }
89
+ catch {
90
+ /* persona is a bonus; fall through to grading the first answer */
91
+ }
92
+ const grade = await withSpinner("Reading your explanation...", () => askJson(profile, gradePrompt(profile, title, prompt, keyPoints, transcript), validateGrade));
93
+ showGrade(grade);
94
+ return [{ answer: transcript, grade }];
95
+ }
45
96
  const attempts = [];
46
97
  let ask = prompt;
47
98
  for (let i = 0; i < 2; i++) {
48
99
  const answer = must(await p.text({
49
- message: `${t.pink("In your own words")} — ${ask}`,
100
+ message: `${t.pink("In your own words")} — ${m(ask)}`,
50
101
  placeholder: "type your explanation (enter to submit, leave blank to skip)",
51
102
  })).trim();
52
103
  if (!answer)
@@ -69,14 +120,37 @@ export async function teachBack(profile, title, prompt, keyPoints) {
69
120
  }
70
121
  return attempts;
71
122
  }
72
- /** Spaced-repetition review: attempt recall, reveal, self-rate. */
73
- export async function reviewCards(topics, limit = 20) {
74
- const due = topics.flatMap((tp) => tp.state.cards.filter((c) => isDue(c)).map((card) => ({ tp, card }))).sort(() => Math.random() - 0.5).slice(0, limit);
123
+ /** Round-robin across sources so related cards aren't back-to-back (interleaving). Stable per source order. */
124
+ export function interleave(items) {
125
+ const groups = new Map();
126
+ for (const it of items)
127
+ groups.set(it.card.source, [...(groups.get(it.card.source) ?? []), it]);
128
+ const queues = [...groups.values()].sort(() => Math.random() - 0.5);
129
+ const out = [];
130
+ while (queues.some((q) => q.length))
131
+ for (const q of queues)
132
+ if (q.length)
133
+ out.push(q.shift());
134
+ return out;
135
+ }
136
+ /** Weak spots first: confident mistakes, lapses, then overdue. */
137
+ const weakness = (c) => (c.flag === "overconfident" ? 4 : 0) + c.lapses * 1.5 + (isDue(c) ? 2 : 0) + Math.random();
138
+ export function pickCards(topics, limit, mode) {
139
+ const all = topics.flatMap((tp) => tp.state.cards.map((card) => ({ tp, card })));
140
+ const pool = mode === "due" ? all.filter((e) => isDue(e.card)) : all;
141
+ const flagged = pool.filter((e) => e.card.flag === "overconfident").sort(() => Math.random() - 0.5);
142
+ const rest = pool.filter((e) => e.card.flag !== "overconfident").sort((a, b) => weakness(b.card) - weakness(a.card));
143
+ const chosen = [...flagged, ...rest].slice(0, limit);
144
+ return [...chosen.filter((e) => e.card.flag === "overconfident"), ...interleave(chosen.filter((e) => e.card.flag !== "overconfident"))];
145
+ }
146
+ /** Spaced-repetition review (due cards) or an interleaved mixed quiz (any cards, weakest first). */
147
+ export async function reviewCards(topics, limit = 20, mode = "due") {
148
+ const queue = pickCards(topics, limit, mode);
75
149
  let n = 0;
76
- for (const { tp, card } of due) {
77
- console.log(t.dim(`\n ${tp.state.title} · ${card.source}`));
78
- must(await p.text({ message: `${t.accent("Recall")} ${card.front}`, placeholder: "type what you remember (enter to just think)" }));
79
- p.log.info(`${t.bold("Answer:")} ${card.back}`);
150
+ for (const { tp, card } of queue) {
151
+ console.log(t.dim(`\n ${tp.state.title} · ${card.source}${card.flag ? " · " + t.warn("you were sure and wrong here before") : ""}`));
152
+ must(await p.text({ message: `${t.accent("Recall")} ${m(card.front)}`, placeholder: "type what you remember (enter to just think)" }));
153
+ p.log.info(`${t.bold("Answer:")} ${m(card.back)}`);
80
154
  const rating = must(await p.select({
81
155
  message: "How did that go?",
82
156
  options: [
@@ -90,7 +164,10 @@ export async function reviewCards(topics, limit = 20) {
90
164
  if (rating === "stop")
91
165
  break;
92
166
  const idx = tp.state.cards.findIndex((c) => c.id === card.id);
93
- tp.state.cards[idx] = schedule(card, rating);
167
+ const next = schedule(card, rating);
168
+ if (card.flag && (rating === "good" || rating === "easy"))
169
+ delete next.flag; // earned its way out
170
+ tp.state.cards[idx] = next;
94
171
  await saveTopic(tp);
95
172
  n++;
96
173
  }
@@ -0,0 +1,120 @@
1
+ import * as p from "@clack/prompts";
2
+ import { askJson } from "../llm.js";
3
+ import { LENSES, challengePrompt, critiquePrompt, deeperPrompt, diagnoseQuestionsPrompt, diagnoseResultPrompt } from "../prompts.js";
4
+ import { m, must, renderMd, t, withSpinner } from "../ui.js";
5
+ import { runChecks } from "./quiz.js";
6
+ const arr = (v) => (Array.isArray(v) ? v.map(String) : []);
7
+ const validateCritique = (x) => ({ strong: arr(x.strong), blindSpots: arr(x.blindSpots), pushback: String(x.pushback ?? ""), feedback: String(x.feedback ?? "") });
8
+ function showCritique(c) {
9
+ p.note([m(c.feedback), "", ...c.strong.map((s) => `${t.ok("✓")} ${m(s)}`), ...c.blindSpots.map((s) => `${t.warn("△ overlooked:")} ${m(s)}`), ...(c.pushback ? ["", `${t.pink("Push back:")} ${m(c.pushback)}`] : [])].join("\n"), "Thinking check");
10
+ }
11
+ /**
12
+ * Trade-offs, failure modes, scale and second-order effects. Round one uses the lesson's own question;
13
+ * further rounds rotate through different lenses so the learner practises seeing a topic from many sides.
14
+ */
15
+ export async function thinkDeeper(profile, s, title, seed, maxRounds = 3) {
16
+ const rounds = [];
17
+ let lensIdx = Math.floor(Math.random() * LENSES.length);
18
+ let current = seed ? { lens: "Think deeper", ...seed } : undefined;
19
+ for (let r = 0; r < maxRounds; r++) {
20
+ if (!current) {
21
+ const lens = LENSES[lensIdx % LENSES.length];
22
+ const q = await withSpinner(`Finding a new angle (${lens.label.toLowerCase()})...`, () => askJson(profile, deeperPrompt(profile, s, title, lens, rounds.map((x) => x.question)), (x) => ({ question: String(x.question), considerations: arr(x.considerations) })));
23
+ current = { lens: lens.label, ...q };
24
+ }
25
+ p.log.step(t.bold(t.accent(`Think deeper · ${current.lens}`)));
26
+ const answer = must(await p.text({ message: m(current.question), placeholder: "no single right answer: weigh both sides, name what could go wrong (blank to skip)" })).trim();
27
+ if (!answer)
28
+ break;
29
+ const c = await withSpinner("Weighing your reasoning...", () => askJson(profile, critiquePrompt(profile, title, current.question, current.considerations, answer), validateCritique));
30
+ showCritique(c);
31
+ rounds.push({ lens: current.lens, question: current.question, answer, feedback: c.feedback, blindSpots: c.blindSpots });
32
+ // Let them answer the pushback, which is the real second-order step.
33
+ if (c.pushback) {
34
+ const reply = must(await p.text({ message: `${t.pink("Your response to the push back?")}`, placeholder: "optional (blank to skip)" })).trim();
35
+ if (reply)
36
+ rounds[rounds.length - 1].answer += `\n\n[Push back: ${c.pushback}]\n${reply}`;
37
+ }
38
+ if (r === maxRounds - 1)
39
+ break;
40
+ lensIdx++;
41
+ const next = LENSES[lensIdx % LENSES.length];
42
+ const more = must(await p.select({
43
+ message: "Keep stretching your thinking?",
44
+ options: [
45
+ { value: true, label: `Another angle: ${next.label}` },
46
+ { value: false, label: "That's enough" },
47
+ ],
48
+ }));
49
+ if (!more)
50
+ break;
51
+ current = undefined;
52
+ }
53
+ return rounds;
54
+ }
55
+ /** End-of-area scenario with no single right answer: apply what you learned and weigh trade-offs. */
56
+ export async function applicationChallenge(profile, s, area, covered) {
57
+ const ch = await withSpinner("Designing a real-world challenge...", () => askJson(profile, challengePrompt(profile, s, area, covered), (x) => ({ scenario: String(x.scenario), task: String(x.task), considerations: arr(x.considerations) })));
58
+ p.note(`${m(ch.scenario)}\n\n${t.bold("Your task:")} ${m(ch.task)}`, `Challenge · ${area.title}`);
59
+ const answer = must(await p.text({ message: "What would you do, and why? What could go wrong?", placeholder: "reason it through (blank to skip)" })).trim();
60
+ if (!answer)
61
+ return null;
62
+ const c = await withSpinner("Reviewing your reasoning...", () => askJson(profile, critiquePrompt(profile, area.title, ch.task, ch.considerations, answer), validateCritique));
63
+ showCritique(c);
64
+ return { ...ch, answer, critique: c };
65
+ }
66
+ /**
67
+ * When a learner keeps missing, find the mental model that's wrong instead of repeating the lesson.
68
+ * Returns the logged mistake (with the learner's corrected idea in their own words).
69
+ */
70
+ export async function diagnose(profile, title, missed, explanationFeedback) {
71
+ const questions = await withSpinner("Working out where the confusion is...", () => askJson(profile, diagnoseQuestionsPrompt(profile, title, missed.map((m) => m.q), explanationFeedback), (x) => arr(x.questions).slice(0, 2)));
72
+ const qa = [];
73
+ for (const q of questions) {
74
+ const a = must(await p.text({ message: `${t.pink("Quick question")} — ${m(q)}`, placeholder: "just say what you picture or believe (blank to skip)" })).trim();
75
+ qa.push({ q, a });
76
+ }
77
+ const res = await withSpinner("Finding the misconception...", () => askJson(profile, diagnoseResultPrompt(profile, title, qa, missed.map((m) => m.q)), (x) => {
78
+ const rt = x.retest;
79
+ if (!rt || !Array.isArray(rt.options) || !Number.isInteger(rt.answer) || rt.answer < 0 || rt.answer >= rt.options.length)
80
+ throw new Error("bad retest");
81
+ return {
82
+ misconception: String(x.misconception),
83
+ reteach: String(x.reteach),
84
+ retest: { q: String(rt.q), options: rt.options.map(String), answer: rt.answer, explain: String(rt.explain ?? "") },
85
+ };
86
+ }));
87
+ p.note(`${t.warn("Likely snag:")} ${m(res.misconception)}`, "Diagnosis");
88
+ console.log(renderMd(res.reteach) + "\n");
89
+ await runChecks([res.retest]);
90
+ const inMyWords = must(await p.text({ message: `${t.pink("Say the corrected idea in your own words")}`, placeholder: "one or two sentences (blank to skip)" })).trim();
91
+ return {
92
+ lesson: title,
93
+ kind: "diagnosis",
94
+ what: res.misconception,
95
+ correction: res.reteach.split("\n").find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("`")) ?? res.misconception,
96
+ inMyWords: inMyWords || undefined,
97
+ };
98
+ }
99
+ /** Socratic opener: leading questions the learner works through before the explanation. */
100
+ export async function socraticWarmup(items) {
101
+ p.log.message(t.dim("Socratic mode: work toward the idea yourself first. Answers needn't be right."));
102
+ const out = [];
103
+ for (const [i, item] of items.entries()) {
104
+ let answer = must(await p.text({ message: `${t.pink(`${i + 1}/${items.length}`)} ${m(item.q)}`, placeholder: "think out loud (type ? for a hint, blank to skip)" })).trim();
105
+ if (answer === "?") {
106
+ p.log.info(`${t.warn("Hint:")} ${m(item.hint)}`);
107
+ answer = must(await p.text({ message: item.q, placeholder: "try again with the hint (blank to skip)" })).trim();
108
+ }
109
+ p.log.message(`${t.accent("→")} ${m(item.idea)}`);
110
+ out.push({ q: item.q, answer });
111
+ }
112
+ return out;
113
+ }
114
+ /** Predict-then-reveal: a wrong guess followed by the truth is memorable. */
115
+ export async function predictThenReveal(pred) {
116
+ p.note(m(pred.scenario), t.pink("Predict"));
117
+ const guess = must(await p.text({ message: m(pred.question), placeholder: "commit to a guess before the reveal (blank to skip)" })).trim();
118
+ p.log.message(`${t.accent("Reveal:")} ${m(pred.outcome)}`);
119
+ return guess;
120
+ }
package/dist/llm.js CHANGED
@@ -47,13 +47,24 @@ export async function complete(provider, prompt, timeoutMs = 240_000) {
47
47
  await rm(dir, { recursive: true, force: true });
48
48
  }
49
49
  }
50
+ /**
51
+ * Models often forget to double LaTeX backslashes inside JSON strings. `\theta` would silently parse as a
52
+ * tab + "heta", `\nabla` as newline + "abla", `\frac` as form feed + "rac". Double the backslash of any
53
+ * known command that collides with a JSON escape, and any lone backslash that isn't a valid escape at all.
54
+ */
55
+ const COLLIDING = "theta|tau|times|text|textbf|textit|textrm|tan|tanh|tilde|to|top|triangle|therefore|tfrac|thinspace|frac|forall|flat|beta|bar|begin|boldsymbol|bot|binom|big|bigg|Big|Bigg|bmod|bullet|bigcup|bigcap|bigoplus|bf|rho|right|rightarrow|rangle|rbrace|rceil|rfloor|rm|nabla|neq|nu|ne|not|notin|newcommand|ni|neg|nleq|ngeq|nmid|nearrow";
56
+ export function repairLatexEscapes(json) {
57
+ const re = new RegExp(`\\\\\\\\|\\\\(?:(?=(?:${COLLIDING})(?![a-zA-Z]))|u(?![0-9a-fA-F]{4})|(?![\\\\"/bfnrtu]))`, "g");
58
+ return json.replace(re, (m) => (m === "\\\\" ? m : "\\\\" + m.slice(1)));
59
+ }
50
60
  export function extractJson(text) {
51
- const cleaned = text.replace(/```(?:json)?/gi, "");
52
- const start = cleaned.indexOf("{");
53
- const end = cleaned.lastIndexOf("}");
61
+ // Slice between the outermost braces; an outer ```json fence falls outside them. Don't strip
62
+ // backticks globally: lesson markdown inside the JSON contains ```mermaid fences.
63
+ const start = text.indexOf("{");
64
+ const end = text.lastIndexOf("}");
54
65
  if (start === -1 || end <= start)
55
66
  throw new Error("no JSON object in model output");
56
- return JSON.parse(cleaned.slice(start, end + 1));
67
+ return JSON.parse(repairLatexEscapes(text.slice(start, end + 1)));
57
68
  }
58
69
  /** Ask for JSON, validate it, and retry once telling the model what was wrong. */
59
70
  export async function askJson(profile, prompt, validate) {