learn-loops 0.0.0-stage → 0.4.1

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.
@@ -0,0 +1,344 @@
1
+ import * as p from "@clack/prompts";
2
+ import { join } from "node:path";
3
+ import { askJson, askText } from "../llm.js";
4
+ import { PERSONAS } from "../prompts.js";
5
+ import { bigPicturePrompt, deepLessonPrompt, mapPrompt, outlinePrompt, reexplainPrompt, surveyLessonPrompt } from "../prompts.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";
10
+ import { addCards, isDue } from "../srs.js";
11
+ import { bar, m, must, renderMd, t, withSpinner } from "../ui.js";
12
+ import { reviewCards, runChecks, teachBack } from "./quiz.js";
13
+ import { applicationChallenge, diagnose, predictThenReveal, socraticWarmup, thinkDeeper } from "./think.js";
14
+ const str = (v, name) => {
15
+ if (typeof v !== "string" || !v.trim())
16
+ throw new Error(`missing string "${name}"`);
17
+ return v;
18
+ };
19
+ function validateMap(x) {
20
+ if (!Array.isArray(x.areas) || x.areas.length < 3)
21
+ throw new Error("need at least 3 areas");
22
+ const areas = x.areas.map((a, i) => ({
23
+ id: slugify(a.id ?? a.title ?? `area-${i}`),
24
+ title: str(a.title, "title"),
25
+ summary: String(a.summary ?? ""),
26
+ prereqs: Array.isArray(a.prereqs) ? a.prereqs.map((s) => slugify(String(s))) : [],
27
+ concepts: (Array.isArray(a.concepts) ? a.concepts : []).map((c) => ({ name: str(c.name, "concept"), blurb: String(c.blurb ?? "") })),
28
+ }));
29
+ return { overview: String(x.overview ?? ""), areas };
30
+ }
31
+ export function validateLesson(x) {
32
+ const checks = (Array.isArray(x.checks) ? x.checks : []).map((c) => {
33
+ if (!Array.isArray(c.options) || c.options.length < 2 || !Number.isInteger(c.answer) || c.answer < 0 || c.answer >= c.options.length)
34
+ throw new Error("bad check options/answer");
35
+ return { q: str(c.q, "q"), options: c.options.map(String), answer: c.answer, explain: String(c.explain ?? "") };
36
+ });
37
+ return {
38
+ primer: String(x.primer ?? ""),
39
+ markdown: str(x.markdown, "markdown"),
40
+ checks,
41
+ teachBack: str(x.teachBack, "teachBack"),
42
+ keyPoints: (Array.isArray(x.keyPoints) ? x.keyPoints : []).map(String),
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,
47
+ };
48
+ }
49
+ const validateOutline = (x) => {
50
+ if (!Array.isArray(x.lessons) || !x.lessons.length)
51
+ throw new Error("no lessons");
52
+ return x.lessons.map((l, i) => ({ id: slugify(l.id ?? l.title ?? `l${i}`), title: str(l.title, "title"), objective: String(l.objective ?? "") }));
53
+ };
54
+ export async function newTopic(profile, title) {
55
+ p.intro(t.bold(t.accent(" New topic ")));
56
+ const name = title ?? must(await p.text({ message: "What do you want to learn?", placeholder: "e.g. How databases work, Music theory, Game theory", validate: (v) => (v?.trim() ? undefined : "Tell me something!") }));
57
+ const cats = await listCategories(profile);
58
+ let category = "general";
59
+ const choice = must(await p.select({
60
+ message: "Which folder should this live in?",
61
+ options: [...cats.map((c) => ({ value: c, label: c })), { value: "__new", label: t.accent("+ New folder") }],
62
+ }));
63
+ category = choice === "__new" ? must(await p.text({ message: "Folder name", placeholder: "e.g. programming, science, languages", validate: (v) => (v?.trim() ? undefined : "Required") })) : choice;
64
+ const prior = must(await p.text({
65
+ message: `Brain dump: what do you already know (or think you know) about ${name}?`,
66
+ placeholder: "anything — even guesses. Blank if nothing. This sets the starting point.",
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
+ }));
72
+ const topic = await createTopic(profile, name.trim(), category, prior.trim());
73
+ topic.state.useCase = useCase.trim() || undefined;
74
+ await buildMap(profile, topic);
75
+ return topic;
76
+ }
77
+ async function buildMap(profile, topic) {
78
+ topic.state.map = await withSpinner("Mapping the territory...", () => askJson(profile, mapPrompt(profile, topic.state.title, topic.state.priorKnowledge), validateMap));
79
+ topic.state.phase = "survey";
80
+ for (const a of topic.state.map.areas)
81
+ topic.state.progress[a.id] ??= { surveyed: false, done: [] };
82
+ await writeText(join(topic.dir, "map.md"), mapMarkdown(topic));
83
+ await writeIndex(topic);
84
+ await saveTopic(topic);
85
+ printMap(topic);
86
+ }
87
+ export function printMap(topic) {
88
+ const map = topic.state.map;
89
+ const lines = [t.italic(map.overview), ""];
90
+ map.areas.forEach((a, i) => {
91
+ const last = i === map.areas.length - 1;
92
+ lines.push(`${t.accent(last ? "└─" : "├─")} ${t.bold(a.title)} ${t.dim(a.summary)}`);
93
+ a.concepts.forEach((c, j) => lines.push(`${t.dim(last ? " " : "│ ")} ${t.dim(j === a.concepts.length - 1 ? "└─" : "├─")} ${c.name}`));
94
+ });
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
+ }
103
+ p.log.info(t.dim(`Saved with mermaid diagrams: ${join(topic.dir, "map.md")}`));
104
+ }
105
+ /** Warm-up retrieval of older material before new learning (spacing + interleaving). */
106
+ async function warmUp(topic) {
107
+ const due = topic.state.cards.filter((c) => isDue(c)).length;
108
+ if (!due)
109
+ return;
110
+ const go = must(await p.select({
111
+ message: `${due} card${due > 1 ? "s are" : " is"} due. Start with a quick warm-up? (recalling old stuff first makes new stuff stick)`,
112
+ options: [
113
+ { value: true, label: "Warm up (5 cards)" },
114
+ { value: false, label: "Skip" },
115
+ ],
116
+ }));
117
+ if (go)
118
+ await reviewCards([topic], 5, "due");
119
+ }
120
+ async function teach(ctx) {
121
+ const { profile, topic } = ctx;
122
+ const lesson = await withSpinner(`Preparing "${ctx.title}"...`, ctx.load);
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);
129
+ // 1. Pretest: guessing before reading primes attention even when wrong.
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)" }));
132
+ }
133
+ // 2. The chunk, with an escape hatch.
134
+ if (socratic)
135
+ p.log.message(t.dim("Here's how the pieces fit together:"));
136
+ console.log(renderMd(lesson.markdown) + "\n");
137
+ while (true) {
138
+ const next = must(await p.select({
139
+ message: "Does it make sense?",
140
+ options: [
141
+ { value: "go", label: "Yes — continue" },
142
+ { value: "again", label: "Not really — explain it differently" },
143
+ ],
144
+ }));
145
+ if (next === "go")
146
+ break;
147
+ const alt = await withSpinner("Finding another angle...", () => askText(profile, reexplainPrompt(profile, topic.state.title, ctx.title, lesson.markdown)));
148
+ console.log(renderMd(alt) + "\n");
149
+ lesson.markdown += `\n\n---\n\n### Another angle\n\n${alt}`;
150
+ }
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}` }))];
199
+ const added = addCards(topic.state.cards, cards, ctx.title);
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));
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));
211
+ }
212
+ const overTime = (start, profile) => (Date.now() - start) / 60_000 >= profile.minutesPerSession;
213
+ export async function continueTopic(profile, topic) {
214
+ const s = topic.state;
215
+ const started = Date.now();
216
+ if (!s.map)
217
+ await buildMap(profile, topic);
218
+ await warmUp(topic);
219
+ // Survey phase: breadth first.
220
+ const areas = s.map.areas;
221
+ while (s.phase === "survey") {
222
+ const next = areas.find((a) => !s.progress[a.id].surveyed);
223
+ if (!next) {
224
+ if (!s.bigPictureDone) {
225
+ const bp = bigPicturePrompt(s);
226
+ p.log.step(t.bold(t.accent("Survey complete — zoom out")));
227
+ const attempts = await teachBack(profile, `${s.title}: big picture`, bp.prompt, bp.keyPoints);
228
+ s.bigPictureDone = true;
229
+ if (attempts.length)
230
+ await writeText(join(topic.dir, "survey", "big-picture.md"), `# Big picture\n\n> ${bp.prompt}\n\n${attempts.map((a) => `${a.answer}\n\n*${a.grade.feedback}*`).join("\n\n")}\n`);
231
+ }
232
+ s.phase = "deep";
233
+ await saveTopic(topic);
234
+ break;
235
+ }
236
+ const idx = areas.indexOf(next);
237
+ console.log(`\n ${t.dim("Survey")} ${bar(areas.filter((a) => s.progress[a.id].surveyed).length, areas.length)}`);
238
+ await teach({
239
+ profile,
240
+ topic,
241
+ title: next.title,
242
+ deep: false,
243
+ file: `survey/${next.id}.md`,
244
+ load: () => askJson(profile, surveyLessonPrompt(profile, s, next), validateLesson),
245
+ });
246
+ s.progress[next.id].surveyed = true;
247
+ await writeIndex(topic);
248
+ await saveTopic(topic);
249
+ if (idx < areas.length - 1 && !(await keepGoing(profile, started)))
250
+ return;
251
+ }
252
+ // Deep phase: pick where to go deep.
253
+ while (true) {
254
+ const area = await pickArea(topic);
255
+ if (!area)
256
+ return;
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
+ }
262
+ if (!pr.outline) {
263
+ pr.outline = await withSpinner(`Planning your deep dive into ${area.title}...`, () => askJson(profile, outlinePrompt(profile, s, area), validateOutline));
264
+ await saveTopic(topic);
265
+ }
266
+ for (const [i, l] of pr.outline.entries()) {
267
+ if (pr.done.includes(l.id))
268
+ continue;
269
+ console.log(`\n ${t.dim(area.title)} ${bar(pr.done.length, pr.outline.length)}`);
270
+ p.log.message(t.dim(`Goal: ${l.objective}`));
271
+ const doneTitles = pr.outline.filter((x) => pr.done.includes(x.id)).map((x) => x.title);
272
+ await teach({
273
+ profile,
274
+ topic,
275
+ title: l.title,
276
+ deep: true,
277
+ file: `deep/${area.id}/${String(i + 1).padStart(2, "0")}-${l.id}.md`,
278
+ load: () => askJson(profile, deepLessonPrompt(profile, s, area, l, doneTitles), validateLesson),
279
+ });
280
+ pr.done.push(l.id);
281
+ await writeIndex(topic);
282
+ await saveTopic(topic);
283
+ if (pr.done.length < pr.outline.length && !(await keepGoing(profile, started)))
284
+ return;
285
+ }
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
+ }
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" }] }));
304
+ if (!more)
305
+ return;
306
+ }
307
+ }
308
+ async function keepGoing(profile, started) {
309
+ const over = overTime(started, profile);
310
+ const mins = Math.round((Date.now() - started) / 60_000);
311
+ return must(await p.select({
312
+ message: over ? `${t.warn(`${mins} min in`)} — a good place to stop. Spacing sessions beats cramming.` : "Continue?",
313
+ initialValue: !over,
314
+ options: [
315
+ { value: true, label: "Keep going" },
316
+ { value: false, label: "Stop here (progress is saved)" },
317
+ ],
318
+ }));
319
+ }
320
+ async function pickArea(topic) {
321
+ const s = topic.state;
322
+ const areas = s.map.areas;
323
+ const open = areas.filter((a) => (s.progress[a.id].outline?.length ?? 1) > s.progress[a.id].done.length);
324
+ if (!open.length) {
325
+ p.log.success("You've gone deep on every area. Review your cards to keep it fresh with `loops review`.");
326
+ return null;
327
+ }
328
+ const started = open.find((a) => s.progress[a.id].done.length > 0) ?? open[0];
329
+ const choice = must(await p.select({
330
+ message: "Which area do you want to go deep on?",
331
+ initialValue: started?.id,
332
+ options: areas.map((a) => {
333
+ const pr = s.progress[a.id];
334
+ const total = pr.outline?.length;
335
+ const finished = total !== undefined && pr.done.length >= total;
336
+ return {
337
+ value: a.id,
338
+ label: `${finished ? t.ok("✓ ") : ""}${a.title}`,
339
+ hint: finished ? "done" : total ? `${pr.done.length}/${total} lessons` : a.prereqs.length ? `after: ${a.prereqs.join(", ")}` : a.summary.slice(0, 50),
340
+ };
341
+ }),
342
+ }));
343
+ return areas.find((a) => a.id === choice);
344
+ }
@@ -0,0 +1,124 @@
1
+ import * as p from "@clack/prompts";
2
+ import { execFileSync } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { saveProfile } from "../store.js";
6
+ import { must, t } from "../ui.js";
7
+ const has = (bin) => {
8
+ try {
9
+ execFileSync("which", [bin], { stdio: "ignore" });
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ };
16
+ export async function onboard(existing) {
17
+ p.intro(t.bold(t.accent(existing ? " Edit your learner profile " : " Let's set up how YOU learn ")));
18
+ if (!existing)
19
+ p.note("Answer a few questions so every lesson, quiz and diagram fits you.\nYou can change any of this later with `loops profile`.", "Welcome");
20
+ const d = existing;
21
+ const name = must(await p.text({ message: "What should I call you?", initialValue: d?.name, validate: (v) => (v?.trim() ? undefined : "Required") }));
22
+ const installed = ["claude", "codex"].filter(has);
23
+ const provider = must(await p.select({
24
+ message: "Which AI tutor should power your lessons?",
25
+ initialValue: d?.provider ?? installed[0] ?? "claude",
26
+ options: [
27
+ { value: "claude", label: "Claude Code", hint: has("claude") ? "detected" : "not found on PATH" },
28
+ { value: "codex", label: "Codex CLI", hint: has("codex") ? "detected" : "not found on PATH" },
29
+ ],
30
+ }));
31
+ const goal = must(await p.select({
32
+ message: "Why do you learn things, mostly?",
33
+ initialValue: d?.goal,
34
+ options: [
35
+ { value: "curiosity — understand it for its own sake", label: "Curiosity" },
36
+ { value: "career — apply it at work", label: "Career / work" },
37
+ { value: "exam or certification", label: "Exam / certification" },
38
+ { value: "building a specific project", label: "Build a project" },
39
+ { value: "teach it to others", label: "Teach others" },
40
+ ],
41
+ }));
42
+ const level = must(await p.select({
43
+ message: "When you start something new, you usually are…",
44
+ initialValue: d?.level ?? "beginner",
45
+ options: [
46
+ { value: "beginner", label: "A true beginner", hint: "assume nothing" },
47
+ { value: "some-background", label: "Some background", hint: "skip the very basics" },
48
+ { value: "experienced", label: "Experienced in adjacent areas", hint: "move fast, draw parallels" },
49
+ ],
50
+ }));
51
+ const styles = must(await p.multiselect({
52
+ message: "What helps ideas stick for you? (space to toggle)",
53
+ initialValues: d?.styles ?? ["concrete examples"],
54
+ required: false,
55
+ options: [
56
+ { value: "concrete examples", label: "Concrete examples" },
57
+ { value: "analogies to things I know", label: "Analogies" },
58
+ { value: "diagrams and visuals", label: "Diagrams & visuals" },
59
+ { value: "first-principles reasoning", label: "First principles" },
60
+ { value: "history and stories", label: "History & stories" },
61
+ { value: "small hands-on exercises", label: "Hands-on exercises" },
62
+ { value: "step-by-step walkthroughs", label: "Step-by-step walkthroughs" },
63
+ ],
64
+ }));
65
+ const order = must(await p.select({
66
+ message: "How should explanations be ordered?",
67
+ initialValue: d?.order ?? "big-picture-first",
68
+ options: [
69
+ { value: "examples-first", label: "Example first, then the idea" },
70
+ { value: "theory-first", label: "Principle first, then examples" },
71
+ { value: "big-picture-first", label: "Big picture first, then zoom in" },
72
+ ],
73
+ }));
74
+ const diagrams = must(await p.select({
75
+ message: "Mermaid diagrams in your notes?",
76
+ initialValue: d?.diagrams ?? "some",
77
+ options: [
78
+ { value: "lots", label: "Lots — I think visually" },
79
+ { value: "some", label: "Some — when they help" },
80
+ { value: "minimal", label: "Minimal" },
81
+ ],
82
+ }));
83
+ const challenge = must(await p.select({
84
+ message: "How hard should quizzes be?",
85
+ initialValue: d?.challenge ?? "balanced",
86
+ options: [
87
+ { value: "gentle", label: "Gentle", hint: "build confidence" },
88
+ { value: "balanced", label: "Balanced" },
89
+ { value: "tough", label: "Tough", hint: "desirable difficulty" },
90
+ ],
91
+ }));
92
+ const tone = must(await p.select({
93
+ message: "How should I give feedback on your own-words answers?",
94
+ initialValue: d?.tone ?? "encouraging",
95
+ options: [
96
+ { value: "encouraging", label: "Encouraging & specific" },
97
+ { value: "direct", label: "Direct — tell me what's wrong" },
98
+ { value: "socratic", label: "Socratic — ask me questions" },
99
+ ],
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
+ }));
114
+ const minutesPerSession = must(await p.select({
115
+ message: "How long is a typical study session?",
116
+ initialValue: d?.minutesPerSession ?? 25,
117
+ options: [10, 15, 25, 45, 60].map((m) => ({ value: m, label: `${m} min` })),
118
+ }));
119
+ const rootDir = must(await p.text({ message: "Where should your learning folders live?", initialValue: d?.rootDir ?? join(homedir(), "Learning").replace(homedir(), "~") }));
120
+ const profile = { name: name.trim(), provider, goal, level, minutesPerSession, styles, order, diagrams, challenge, tone, rootDir, socratic, context: context.trim() || undefined };
121
+ await saveProfile(profile);
122
+ p.outro(t.ok(`Profile saved. Nice to meet you, ${profile.name}!`));
123
+ return profile;
124
+ }
@@ -0,0 +1,175 @@
1
+ import * as p from "@clack/prompts";
2
+ import { askJson } from "../llm.js";
3
+ import { gradeLabel, gradePrompt, personaPrompt } from "../prompts.js";
4
+ import { schedule, isDue } from "../srs.js";
5
+ import { m, must, t, withSpinner } from "../ui.js";
6
+ import { saveTopic } from "../store.js";
7
+ export function validateGrade(x) {
8
+ const score = Number(x.score);
9
+ if (![0, 1, 2, 3].includes(score))
10
+ throw new Error("score must be 0-3");
11
+ const arr = (v) => (Array.isArray(v) ? v.map(String) : []);
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
+ }
14
+ /** Multiple-choice checks with a confidence rating, so confident mistakes are caught and flagged. */
15
+ export async function runChecks(checks) {
16
+ const res = { missed: [], overconfident: [], correct: 0, total: checks.length };
17
+ let sure = 0;
18
+ let sureRight = 0;
19
+ for (const [i, c] of checks.entries()) {
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]) })),
25
+ }));
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
+ }
46
+ else {
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))}`);
54
+ }
55
+ }
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;
59
+ }
60
+ export function showGrade(g) {
61
+ const color = g.score >= 3 ? t.ok : g.score === 2 ? t.cyan : g.score === 1 ? t.warn : t.bad;
62
+ const lines = [
63
+ `${color(`${"●".repeat(g.score)}${"○".repeat(3 - g.score)} ${gradeLabel(g)}`)}`,
64
+ "",
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)}`),
69
+ ];
70
+ p.note(lines.join("\n"), "Feedback");
71
+ }
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
+ }
96
+ const attempts = [];
97
+ let ask = prompt;
98
+ for (let i = 0; i < 2; i++) {
99
+ const answer = must(await p.text({
100
+ message: `${t.pink("In your own words")} — ${m(ask)}`,
101
+ placeholder: "type your explanation (enter to submit, leave blank to skip)",
102
+ })).trim();
103
+ if (!answer)
104
+ break;
105
+ const grade = await withSpinner("Reading your explanation...", () => askJson(profile, gradePrompt(profile, title, prompt, keyPoints, answer), validateGrade));
106
+ attempts.push({ answer, grade });
107
+ showGrade(grade);
108
+ if (grade.score >= 2 || i === 1)
109
+ break;
110
+ const again = must(await p.select({
111
+ message: "Want another try with the feedback in mind? (struggling first is how it sticks)",
112
+ options: [
113
+ { value: true, label: "Try again" },
114
+ { value: false, label: "Move on" },
115
+ ],
116
+ }));
117
+ if (!again)
118
+ break;
119
+ ask = grade.followUp || prompt;
120
+ }
121
+ return attempts;
122
+ }
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);
149
+ let n = 0;
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)}`);
154
+ const rating = must(await p.select({
155
+ message: "How did that go?",
156
+ options: [
157
+ { value: "again", label: t.bad("Again"), hint: "forgot — see it in 10 min" },
158
+ { value: "hard", label: t.warn("Hard"), hint: "got it with effort" },
159
+ { value: "good", label: t.ok("Good") },
160
+ { value: "easy", label: t.cyan("Easy"), hint: "push it further out" },
161
+ { value: "stop", label: "Stop for now" },
162
+ ],
163
+ }));
164
+ if (rating === "stop")
165
+ break;
166
+ const idx = tp.state.cards.findIndex((c) => c.id === card.id);
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;
171
+ await saveTopic(tp);
172
+ n++;
173
+ }
174
+ return n;
175
+ }