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.
package/dist/markdown.js CHANGED
@@ -1,8 +1,9 @@
1
- import { join } from "node:path";
1
+ import { dirname, join, relative } from "node:path";
2
2
  import { writeText } from "./store.js";
3
+ import { parsePlot, plotToSvg, sanitizeSvg, svgCaption } from "./figures.js";
3
4
  /** Strip characters that break mermaid mindmap / quoted labels. */
4
- const clean = (s) => s.replace(/["'`()\[\]{}:;#<>]/g, "").replace(/\s+/g, " ").trim();
5
- const nodeId = (s) => "n_" + s.replace(/[^a-zA-Z0-9]/g, "_");
5
+ export const clean = (s) => s.replace(/["'`()\[\]{}:;#<>]/g, "").replace(/\s+/g, " ").trim();
6
+ export const nodeId = (s) => "n_" + s.replace(/[^a-zA-Z0-9]/g, "_");
6
7
  export function mapMarkdown(topic) {
7
8
  const { title, map } = topic.state;
8
9
  if (!map)
@@ -41,19 +42,26 @@ ${flow}
41
42
  ${map.areas.map((a) => `### ${a.title}\n${a.summary}\n\n${a.concepts.map((c) => `- **${c.name}** — ${c.blurb}`).join("\n")}`).join("\n\n")}
42
43
  `;
43
44
  }
44
- export function lessonMarkdown(title, lesson, own, missed) {
45
+ export function lessonMarkdown(title, lesson, x = {}) {
46
+ const quote = (s) => s.split("\n").map((l) => "> " + l).join("\n");
45
47
  const checks = lesson.checks
46
48
  .map((c, i) => `${i + 1}. ${c.q}\n${c.options.map((o, j) => ` - ${j === c.answer ? "✅" : "▫️"} ${o}`).join("\n")}\n - *${c.explain}*`)
47
49
  .join("\n");
48
- const ownBlock = own?.length
49
- ? `\n## In my own words\n\n> ${lesson.teachBack}\n\n${own
50
- .map((o, i) => `**Attempt ${i + 1}** — ${o.grade.score}/3\n\n${o.answer.split("\n").map((l) => "> " + l).join("\n")}\n\n${o.grade.feedback}${o.grade.missing.length ? `\n\nMissing: ${o.grade.missing.join("; ")}` : ""}`)
51
- .join("\n\n")}\n`
50
+ const section = (heading, body) => (body.trim() ? `\n## ${heading}\n\n${body}\n` : "");
51
+ const socratic = x.socratic?.length ? section("Questions I worked through first", x.socratic.map((s) => `**${s.q}**\n\n${quote(s.answer || "(skipped)")}`).join("\n\n")) : "";
52
+ const prediction = lesson.prediction ? section("Predict, then reveal", `**${lesson.prediction.scenario}**\n\n${lesson.prediction.question}\n\n${quote(x.prediction || "(skipped)")}\n\n**What happened:** ${lesson.prediction.outcome}`) : "";
53
+ const own = x.attempts?.length
54
+ ? section("In my own words", `> ${lesson.teachBack}\n\n` +
55
+ x.attempts.map((o, i) => `**Attempt ${i + 1}** — ${o.grade.score}/3\n\n${quote(o.answer)}\n\n${o.grade.feedback}${o.grade.missing.length ? `\n\nMissing: ${o.grade.missing.join("; ")}` : ""}`).join("\n\n"))
52
56
  : "";
57
+ const deeper = x.deeper?.length
58
+ ? section("Thinking deeper", x.deeper.map((d) => `**${d.lens}: ${d.question}**\n\n${quote(d.answer)}\n\n${d.feedback}${d.blindSpots.length ? `\n\nBlind spots to revisit:\n${d.blindSpots.map((b) => `- ${b}`).join("\n")}` : ""}`).join("\n\n---\n\n"))
59
+ : "";
60
+ const connection = x.connection ? section("Where I could use this", quote(x.connection)) : "";
53
61
  return `# ${title}
54
62
 
55
63
  ${lesson.markdown}
56
-
64
+ ${socratic}${prediction}
57
65
  ## Key points
58
66
 
59
67
  ${lesson.keyPoints.map((k) => `- ${k}`).join("\n")}
@@ -61,15 +69,26 @@ ${lesson.keyPoints.map((k) => `- ${k}`).join("\n")}
61
69
  ## Check yourself
62
70
 
63
71
  ${checks}
64
- ${missed?.length ? `\n**Still shaky:** ${missed.join("; ")}\n` : ""}${ownBlock}
72
+ ${x.missed?.length ? `\n**Still shaky:** ${x.missed.join("; ")}\n` : ""}${own}${deeper}${connection}
65
73
  ## Flashcards
66
74
 
67
75
  ${lesson.cards.map((c) => `- **${c.front}** → ${c.back}`).join("\n")}
68
76
  `;
69
77
  }
78
+ export function mistakesMarkdown(topic) {
79
+ const list = topic.state.mistakes ?? [];
80
+ const kind = { quiz: "Missed a question", overconfident: "Sure, but wrong", explanation: "Gap in my explanation", diagnosis: "Misconception" };
81
+ const byLesson = new Map();
82
+ for (const m of list)
83
+ byLesson.set(m.lesson, [...(byLesson.get(m.lesson) ?? []), m]);
84
+ const body = [...byLesson.entries()]
85
+ .map(([lesson, ms]) => `## ${lesson}\n\n${ms.map((m) => `- **${kind[m.kind]}** (${m.date}): ${m.what}\n - Correct idea: ${m.correction}${m.inMyWords ? `\n - In my words: *${m.inMyWords}*` : ""}`).join("\n")}`)
86
+ .join("\n\n");
87
+ return `# Mistake journal — ${topic.state.title}\n\nThe fastest way to improve is to understand exactly what you got wrong.\n\n${body || "_Nothing yet._"}\n`;
88
+ }
70
89
  export async function writeIndex(topic) {
71
90
  const { state } = topic;
72
- const lines = [`# ${state.title}`, "", state.map?.overview ?? "", "", "- [Map of the territory](map.md)", ""];
91
+ const lines = [`# ${state.title}`, "", state.map?.overview ?? "", "", "- [Map of the territory](map.md)", ...(state.mistakes?.length ? ["- [Mistake journal](mistakes.md)"] : []), ""];
73
92
  if (state.map) {
74
93
  lines.push("## Progress", "");
75
94
  for (const a of state.map.areas) {
@@ -83,3 +102,35 @@ export async function writeIndex(topic) {
83
102
  lines.push("", `_${state.cards.length} flashcards tracked in topic.json_`, "");
84
103
  await writeText(join(topic.dir, "README.md"), lines.join("\n"));
85
104
  }
105
+ /**
106
+ * Lessons embed ```svg and ```plot blocks. In the saved markdown they become real .svg files under
107
+ * figures/ and standard image links, which GitHub, VS Code and Obsidian all display. Equations stay as
108
+ * $$ TeX $$ (rendered by those viewers); mermaid stays fenced.
109
+ */
110
+ export async function materializeFigures(md, topicDir, lessonFile) {
111
+ const base = lessonFile.replace(/\.md$/, "").replace(/[^a-z0-9]+/gi, "-");
112
+ const jobs = [];
113
+ let n = 0;
114
+ const out = md.replace(/```(svg|plot)\n([\s\S]*?)```/g, (_, kind, src) => {
115
+ let svg = null;
116
+ let caption = "";
117
+ if (kind === "svg") {
118
+ svg = sanitizeSvg(src);
119
+ caption = svg ? svgCaption(svg) : "";
120
+ }
121
+ else {
122
+ const spec = parsePlot(src);
123
+ svg = spec && plotToSvg(spec);
124
+ caption = spec?.title ?? "";
125
+ }
126
+ if (!svg)
127
+ return "> *(a figure here could not be rendered)*";
128
+ const file = `figures/${base}-${++n}.svg`;
129
+ jobs.push({ token: file, file, svg, caption });
130
+ const rel = relative(dirname(join(topicDir, lessonFile)), join(topicDir, file));
131
+ return `![${caption || "Figure " + n}](${rel})`;
132
+ });
133
+ for (const j of jobs)
134
+ await writeText(join(topicDir, j.file), j.svg);
135
+ return out;
136
+ }
package/dist/math.js ADDED
@@ -0,0 +1,307 @@
1
+ import { createRequire } from "node:module";
2
+ import { Resvg } from "@resvg/resvg-js";
3
+ const require = createRequire(import.meta.url);
4
+ let converter;
5
+ /** MathJax in pure Node (no DOM, no browser): TeX with physics, mhchem, ams, cancel. */
6
+ function mathjax() {
7
+ if (converter)
8
+ return converter;
9
+ const { mathjax } = require("mathjax-full/js/mathjax.js");
10
+ const { TeX } = require("mathjax-full/js/input/tex.js");
11
+ const { SVG } = require("mathjax-full/js/output/svg.js");
12
+ const { liteAdaptor } = require("mathjax-full/js/adaptors/liteAdaptor.js");
13
+ const { RegisterHTMLHandler } = require("mathjax-full/js/handlers/html.js");
14
+ // Each TeX package must be loaded (registered) before the TeX input jax can use it.
15
+ for (const cfg of ["ams/AmsConfiguration", "newcommand/NewcommandConfiguration", "physics/PhysicsConfiguration", "mhchem/MhchemConfiguration", "cancel/CancelConfiguration", "boldsymbol/BoldsymbolConfiguration", "noundefined/NoUndefinedConfiguration"]) {
16
+ require(`mathjax-full/js/input/tex/${cfg}.js`);
17
+ }
18
+ const adaptor = liteAdaptor();
19
+ RegisterHTMLHandler(adaptor);
20
+ const doc = mathjax.document("", {
21
+ InputJax: new TeX({ packages: ["base", "ams", "newcommand", "physics", "mhchem", "cancel", "boldsymbol", "noundefined"] }),
22
+ OutputJax: new SVG({ fontCache: "none" }),
23
+ });
24
+ converter = (tex, display) => {
25
+ const html = adaptor.innerHTML(doc.convert(tex, { display }));
26
+ if (/data-mjx-error|<merror/.test(html))
27
+ throw new Error("TeX error");
28
+ return html.replace(/^<mjx-container[^>]*>/, "").replace(/<\/mjx-container>$/, "");
29
+ };
30
+ return converter;
31
+ }
32
+ const PX_PER_EX = 9;
33
+ /** TeX → standalone SVG with pixel sizes (resvg doesn't understand ex), padded on white. */
34
+ export function texToSvg(tex, display = true) {
35
+ const inner = mathjax()(tex, display);
36
+ const w = parseFloat(inner.match(/width="([\d.]+)ex"/)?.[1] ?? "10") * PX_PER_EX;
37
+ const h = parseFloat(inner.match(/height="([\d.]+)ex"/)?.[1] ?? "2") * PX_PER_EX;
38
+ const body = inner
39
+ .replace(/ style="[^"]*"/, "")
40
+ .replace(/width="[\d.]+ex"/, `width="${w}"`)
41
+ .replace(/height="[\d.]+ex"/, `height="${h}"`)
42
+ .replace("<svg ", '<svg color="#1a1a2e" ');
43
+ const pad = 10;
44
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w + 2 * pad}" height="${h + 2 * pad}" viewBox="0 0 ${w + 2 * pad} ${h + 2 * pad}"><rect width="100%" height="100%" rx="8" fill="#ffffff"/><g transform="translate(${pad} ${pad})">${body}</g></svg>`;
45
+ }
46
+ export function svgToPng(svg, zoom = 2.5) {
47
+ const r = new Resvg(svg, {
48
+ background: "#ffffff",
49
+ fitTo: { mode: "zoom", value: zoom },
50
+ font: { loadSystemFonts: true, defaultFontFamily: "Helvetica" },
51
+ });
52
+ const out = r.render();
53
+ return { png: Buffer.from(out.asPng()), width: Math.round(out.width / zoom) };
54
+ }
55
+ export const texToPng = (tex, display = true) => svgToPng(texToSvg(tex, display), 3);
56
+ // ---------------------------------------------------------------------------------------------
57
+ // Unicode fallback: a small TeX layout engine for terminals without image support.
58
+ // ---------------------------------------------------------------------------------------------
59
+ const unicodeit = require("unicodeit");
60
+ const width = (s) => [...s.replace(/\p{M}/gu, "")].length;
61
+ const pad = (s, n, side = "r") => {
62
+ const gap = Math.max(0, n - width(s));
63
+ return side === "r" ? s + " ".repeat(gap) : side === "l" ? " ".repeat(gap) + s : " ".repeat(Math.floor(gap / 2)) + s + " ".repeat(Math.ceil(gap / 2));
64
+ };
65
+ const txt = (s) => ({ l: [s], b: 0 });
66
+ const boxW = (b) => Math.max(...b.l.map(width));
67
+ function hcat(boxes) {
68
+ const live = boxes.filter((b) => b.l.length);
69
+ if (!live.length)
70
+ return txt("");
71
+ const above = Math.max(...live.map((b) => b.b));
72
+ const below = Math.max(...live.map((b) => b.l.length - b.b - 1));
73
+ const rows = [];
74
+ for (let r = 0; r < above + below + 1; r++) {
75
+ rows.push(live.map((b) => pad(b.l[r - (above - b.b)] ?? "", boxW(b))).join(""));
76
+ }
77
+ return { l: rows, b: above };
78
+ }
79
+ function frac(n, d) {
80
+ const w = Math.max(boxW(n), boxW(d)) + 2;
81
+ return { l: [...n.l.map((s) => pad(s, w, "c")), "─".repeat(w), ...d.l.map((s) => pad(s, w, "c"))], b: n.l.length };
82
+ }
83
+ function sqrtBox(x) {
84
+ const w = boxW(x);
85
+ const rows = x.l.map((s, i) => (i === 0 ? "√" : " ") + pad(s, w));
86
+ return { l: [" " + "‾".repeat(w), ...rows.map((r, i) => (i === 0 ? "√" + r.slice(1) : r))], b: x.b + 1 };
87
+ }
88
+ const SUP = "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ";
89
+ const SUB = "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₒₓᵢⱼₙₖₗₘₚₛₜ";
90
+ const RELATIONS = new Set(["=", "≈", "≠", "≤", "≥", "→", "←", "↔", "⇒", "∝", "∼", "≡", "∈", "⊂", "⊆"]);
91
+ const BIG_OPS = { sum: "∑", prod: "∏", int: "∫", oint: "∮", lim: "lim" };
92
+ /** Try to write `s` entirely as unicode super/subscript characters. */
93
+ function script(kind, s) {
94
+ const out = unicodeit.replace(`${kind}{${s}}`);
95
+ return out && !/[\^_{}\\]/.test(out) ? out : null;
96
+ }
97
+ class Parser {
98
+ s;
99
+ inline;
100
+ i = 0;
101
+ constructor(s, inline) {
102
+ this.s = s;
103
+ this.inline = inline;
104
+ }
105
+ group() {
106
+ // consumes one TeX argument: {…} or a single token
107
+ this.ws();
108
+ if (this.s[this.i] === "{") {
109
+ this.i++;
110
+ const b = this.seq("}");
111
+ this.i++;
112
+ return b;
113
+ }
114
+ if (this.s[this.i] === "\\")
115
+ return this.token();
116
+ return txt(this.s[this.i++] ?? "");
117
+ }
118
+ raw() {
119
+ // raw text of one argument (for \text, \mathrm, accents …)
120
+ this.ws();
121
+ if (this.s[this.i] !== "{")
122
+ return this.s[this.i++] ?? "";
123
+ let depth = 0;
124
+ const start = this.i + 1;
125
+ for (; this.i < this.s.length; this.i++) {
126
+ if (this.s[this.i] === "{")
127
+ depth++;
128
+ else if (this.s[this.i] === "}" && --depth === 0)
129
+ break;
130
+ }
131
+ return this.s.slice(start, this.i++);
132
+ }
133
+ /** Raw TeX of one argument: {balanced}, a \\command, or a single character. */
134
+ rawArg() {
135
+ this.ws();
136
+ if (this.s[this.i] === "{")
137
+ return this.raw();
138
+ const m = this.s.slice(this.i).match(/^\\(?:[a-zA-Z]+|.)/);
139
+ if (m) {
140
+ this.i += m[0].length;
141
+ return m[0];
142
+ }
143
+ return this.s[this.i++] ?? "";
144
+ }
145
+ sub(tex) {
146
+ return new Parser(tex, this.inline).seq();
147
+ }
148
+ ws() {
149
+ while (/\s/.test(this.s[this.i] ?? ""))
150
+ this.i++;
151
+ }
152
+ token() {
153
+ const m = this.s.slice(this.i).match(/^\\([a-zA-Z]+|.)/);
154
+ if (!m)
155
+ return txt(this.s[this.i++]);
156
+ this.i += m[0].length;
157
+ const name = m[1];
158
+ if (name === "frac" || name === "dfrac" || name === "tfrac") {
159
+ const n = this.group();
160
+ const d = this.group();
161
+ return this.inline ? txt(`${flat(n)}/${flat(d)}`.replace(/^(.{2,})\/(.+)$/, (_, a, b) => `${a.length > 1 && /[+\-−]/.test(a) ? `(${a})` : a}/${/[+\-−]/.test(b) ? `(${b})` : b}`)) : frac(n, d);
162
+ }
163
+ if (name === "sqrt") {
164
+ const x = this.group();
165
+ return this.inline ? txt(`√(${flat(x)})`) : sqrtBox(x);
166
+ }
167
+ if (name === "left" || name === "right") {
168
+ const ch = this.s[this.i++];
169
+ return txt(ch === "." ? "" : ch === "\\" ? this.s[this.i++] : ch);
170
+ }
171
+ if (["text", "mathrm", "operatorname", "textbf", "mathbf", "boldsymbol", "mathbb", "mathcal", "vec", "hat", "bar", "dot", "ddot", "tilde", "overline", "mathit", "mathsf", "mathscr", "mathfrak"].includes(name)) {
172
+ return txt(unicodeit.replace(`\\${name}{${this.raw()}}`).replace(/^\\(?:text|mathrm|operatorname|overline)\{(.*)\}$/, "$1").replace(/^\\\w+\{(.*)\}$/, "$1"));
173
+ }
174
+ const phys = this.physics(name);
175
+ if (phys)
176
+ return phys;
177
+ if (name in BIG_OPS)
178
+ return this.bigOp(BIG_OPS[name], name);
179
+ if (name === "\\" || name === "," || name === ";" || name === ":" || name === "!" || name === "quad" || name === "qquad")
180
+ return txt(name === "quad" ? " " : name === "qquad" ? " " : name === "," || name === ";" || name === ":" ? " " : "");
181
+ if (name === "begin" || name === "end") {
182
+ this.raw();
183
+ return txt(name === "begin" ? "" : "");
184
+ }
185
+ if (name === "&")
186
+ return txt(" ");
187
+ const u = unicodeit.replace(`\\${name}`);
188
+ return txt(u.startsWith("\\") ? name : u);
189
+ }
190
+ physics(name) {
191
+ const bold = (x) => unicodeit.replace(`\\mathbf{${x}}`);
192
+ switch (name) {
193
+ case "ket": return txt(`|${flat(this.sub(this.rawArg()))}⟩`);
194
+ case "bra": return txt(`⟨${flat(this.sub(this.rawArg()))}|`);
195
+ case "braket": {
196
+ const a = flat(this.sub(this.rawArg()));
197
+ const b = flat(this.sub(this.rawArg()));
198
+ return txt(`⟨${a}|${b}⟩`);
199
+ }
200
+ case "expval": return txt(`⟨${flat(this.sub(this.rawArg()))}⟩`);
201
+ case "abs": return txt(`|${flat(this.sub(this.rawArg()))}|`);
202
+ case "norm": return txt(`‖${flat(this.sub(this.rawArg()))}‖`);
203
+ case "vb":
204
+ case "vu": return txt(name === "vb" ? bold(this.rawArg()) : unicodeit.replace(`\\hat{${this.rawArg()}}`));
205
+ case "grad": return txt("∇");
206
+ case "curl": return txt("∇×");
207
+ case "div": return txt("∇·");
208
+ case "laplacian": return txt("∇²");
209
+ case "qty": return txt("");
210
+ case "pdv":
211
+ case "dv": {
212
+ const d = name === "pdv" ? "∂" : "d";
213
+ const f = this.rawArg();
214
+ this.ws();
215
+ const g = this.s[this.i] === "{" || this.s[this.i] === "\\" ? this.rawArg() : "";
216
+ return this.inline ? txt(g ? `${d}${flat(this.sub(f))}/${d}${flat(this.sub(g))}` : `${d}/${d}${flat(this.sub(f))}`) : g ? frac(txt(`${d}${flat(this.sub(f))}`), txt(`${d}${flat(this.sub(g))}`)) : frac(txt(d), txt(`${d}${flat(this.sub(f))}`));
217
+ }
218
+ case "ce": return txt(chem(this.raw()));
219
+ }
220
+ return null;
221
+ }
222
+ bigOp(sym, name) {
223
+ let lo;
224
+ let hi;
225
+ for (let k = 0; k < 2; k++) {
226
+ this.ws();
227
+ const c = this.s[this.i];
228
+ if (c === "_" && !lo)
229
+ (this.i++, (lo = this.group()));
230
+ else if (c === "^" && !hi)
231
+ (this.i++, (hi = this.group()));
232
+ }
233
+ if (this.inline) {
234
+ const lim = lo || hi ? `[${lo ? flat(lo) : ""}${hi ? `→${flat(hi)}` : ""}]` : "";
235
+ return txt(sym + lim);
236
+ }
237
+ const w = Math.max(width(sym), lo ? boxW(lo) : 0, hi ? boxW(hi) : 0);
238
+ const rows = [...(hi ? hi.l.map((s) => pad(s, w, "c")) : []), pad(sym, w, "c"), ...(lo ? lo.l.map((s) => pad(s, w, "c")) : [])];
239
+ return { l: rows, b: hi ? hi.l.length : 0 };
240
+ }
241
+ seq(stop) {
242
+ const parts = [];
243
+ while (this.i < this.s.length && this.s[this.i] !== stop) {
244
+ const c = this.s[this.i];
245
+ if (/\s/.test(c)) {
246
+ this.i++;
247
+ }
248
+ else if (c === "{") {
249
+ parts.push(this.group());
250
+ }
251
+ else if (c === "^" || c === "_") {
252
+ this.i++;
253
+ const rawTex = this.rawArg();
254
+ const mapped = script(c, rawTex);
255
+ const inner = flat(this.sub(rawTex));
256
+ parts.push(txt(mapped ?? (/^[A-Za-z0-9]+$/.test(inner) ? `${c}${inner}` : `${c}(${inner})`)));
257
+ }
258
+ else if (c === "\\") {
259
+ parts.push(this.token());
260
+ }
261
+ else {
262
+ this.i++;
263
+ const prev = parts[parts.length - 1];
264
+ const before = prev ? flat(prev) : "";
265
+ const binary = (c === "+" || c === "-") && before !== "" && !/[=≈≠≤≥→←(,\[]$/.test(before);
266
+ const rel = RELATIONS.has(c);
267
+ parts.push(txt(rel || binary ? ` ${c === "-" ? "−" : c} ` : c === "," ? ", " : c));
268
+ }
269
+ }
270
+ // relations produced by commands (\approx, \to …) also get spacing
271
+ const spaced = parts.map((p) => (p.l.length === 1 && RELATIONS.has(p.l[0]) ? txt(` ${p.l[0]} `) : p));
272
+ return hcat(spaced);
273
+ }
274
+ }
275
+ /** \\ce{2H2 + O2 -> 2H2O} → 2H₂ + O₂ → 2H₂O */
276
+ const chem = (s) => s
277
+ .replace(/<->|<=>/g, "⇌")
278
+ .replace(/->/g, "→")
279
+ .replace(/<-/g, "←")
280
+ .replace(/([A-Za-z)])(\d+)/g, (_, a, n) => a + [...n].map((d) => SUB[+d]).join(""))
281
+ .replace(/\^\{?([+-]?\d*[+-]?)\}?/g, (_, c) => script("^", c) ?? c);
282
+ const flat = (b) => b.l.join(" ").replace(/\s+/g, " ").trim();
283
+ /** Display equation as multi-line Unicode art. */
284
+ export function texToLines(tex) {
285
+ const clean = tex.replace(/\\(?:label|tag)\{[^}]*\}/g, "").trim();
286
+ const box = new Parser(clean, false).seq();
287
+ return box.l.map((l) => l.replace(/\s+$/, ""));
288
+ }
289
+ /** Inline equation as one line of Unicode. */
290
+ export function texToInline(tex) {
291
+ return flat(new Parser(tex.trim(), true).seq());
292
+ }
293
+ /** Replace $…$, $$…$$, \(…\) in a plain string with Unicode (for prompts, options, flashcards). */
294
+ export function mathToText(s) {
295
+ const conv = (tex) => {
296
+ try {
297
+ return texToInline(tex);
298
+ }
299
+ catch {
300
+ return tex;
301
+ }
302
+ };
303
+ return s
304
+ .replace(/\$\$([\s\S]+?)\$\$/g, (_, t) => conv(t))
305
+ .replace(/\\\(([\s\S]+?)\\\)/g, (_, t) => conv(t))
306
+ .replace(/\$(?!\s)([^$\n]+?)(?<!\s)\$(?!\d)/g, (m, t) => (/^\d/.test(t) && !/[\\^_{}]/.test(t) ? m : conv(t)));
307
+ }
package/dist/prompts.js CHANGED
@@ -3,10 +3,23 @@ export function learnerBlurb(p) {
3
3
  `Learner: ${p.name}. Goal: ${p.goal}. Level: ${p.level}.`,
4
4
  `Likes: ${p.styles.join(", ") || "no strong preference"}. Prefers ${p.order.replace(/-/g, " ")} ordering.`,
5
5
  `Diagram appetite: ${p.diagrams}. Challenge: ${p.challenge}. Feedback tone: ${p.tone}.`,
6
- ].join(" ");
6
+ p.context ? `They are working on / will use this for: ${p.context}.` : "",
7
+ ]
8
+ .filter(Boolean)
9
+ .join(" ");
10
+ }
11
+ /** Topic-specific personalisation: where they said they'd use it, and their own connections so far. */
12
+ export function useBlurb(s) {
13
+ const bits = [s.useCase ? `For this topic they want to use it for: ${s.useCase}.` : "", s.connections?.length ? `Uses they have named so far: ${s.connections.slice(-3).join(" | ")}. Draw examples from these when natural.` : ""];
14
+ return bits.filter(Boolean).join(" ");
7
15
  }
8
16
  const MERMAID_RULES = `Mermaid rules (diagrams MUST render): use only flowchart TD/LR, sequenceDiagram, stateDiagram-v2, classDiagram, or mindmap.
9
- Always wrap flowchart node labels in double quotes, e.g. A["Label text"]. Never put parentheses, quotes or colons inside unquoted labels. Keep diagrams under 12 nodes. Fence them as \`\`\`mermaid.`;
17
+ Always wrap flowchart node labels in double quotes, e.g. A["Label text"]. Never put parentheses, quotes or colons inside unquoted labels. Diagrams are drawn in a terminal about 100 columns wide: prefer flowchart/sequenceDiagram/stateDiagram-v2/classDiagram, keep labels to 1-4 words, and keep diagrams under 10 nodes. Fence them as \`\`\`mermaid.`;
18
+ const STEM_RULES = `Math and science content:
19
+ - Write equations in LaTeX: $...$ inline and $$...$$ for display. The physics, ams, mhchem and cancel packages work (\\ket{}, \\pdv{f}{x}, \\vb{E}, \\ce{2H2 + O2 -> 2H2O}). Inside the JSON, double every backslash (write \\\\frac).
20
+ - Physical or geometric setups (free-body diagrams, circuits, ray/optics diagrams, fields, waves, geometry) go in a fenced svg block holding one complete <svg viewBox='0 0 640 360'>. Use SINGLE quotes for every attribute, include a <title>, dark strokes on a transparent background, labelled parts via <text>, arrowheads via <marker>, and no scripts, images or external references. Keep it uncluttered and physically correct.
21
+ - Graphs of functions (waveforms, potentials, distributions, trade-off curves) go in a fenced plot block holding JSON: {"title":"...","xlabel":"...","ylabel":"...","xmin":0,"xmax":10,"series":[{"label":"...","expr":"exp(-0.3*x)*cos(3*x)"}]}. The expr is a mathjs expression in x (use *, ^, sin, cos, exp, sqrt, pi), never LaTeX.
22
+ - Use mermaid only for processes, hierarchies and state/flow structure. Add equations or figures only where they genuinely clarify; never decorate.`;
10
23
  const diagramRule = (p) => p.diagrams === "lots" ? "Include 1-2 mermaid diagrams." : p.diagrams === "some" ? "Include one mermaid diagram if it genuinely helps." : "Only include a diagram if essential.";
11
24
  export function mapPrompt(p, topic, prior) {
12
25
  return `${learnerBlurb(p)}
@@ -16,18 +29,24 @@ Return ONLY JSON:
16
29
  {"overview":"2-3 sentence orientation: what this field is and why it matters",
17
30
  "areas":[{"id":"short-kebab-id","title":"...","summary":"1-2 sentences","prereqs":["ids of areas that should come first"],"concepts":[{"name":"...","blurb":"one line"}]}]}`;
18
31
  }
19
- const lessonShape = `{"primer":"one open question that makes the learner GUESS before reading (no jargon they haven't seen)",
32
+ const lessonShapeBase = `{"primer":"one open question that makes the learner GUESS before reading (no jargon they haven't seen)",
20
33
  "markdown":"the lesson body in markdown",
21
34
  "checks":[{"q":"...","options":["A","B","C","D"],"answer":0,"explain":"why the answer is right and the best distractor is wrong"}],
22
35
  "teachBack":"a prompt asking the learner to explain the idea in their own words, ideally to a specific imagined person or via a small scenario",
23
36
  "keyPoints":["3-5 points a correct own-words explanation should contain"],
24
- "cards":[{"front":"recall question","back":"concise answer"}]}`;
37
+ "cards":[{"front":"recall question","back":"concise answer"}]`;
38
+ const lessonShape = `${lessonShapeBase}}`;
39
+ const deepExtras = (socratic) => `,
40
+ "prediction":{"scenario":"a concrete situation in 2-3 sentences, stopping just before the outcome","question":"what do you expect happens next / what would you do?","outcome":"what actually happens and why, 2-3 sentences"},
41
+ "deeper":{"question":"an open question that forces thinking about TRADE-OFFS, failure modes, scale, or second-order effects (not recall)","considerations":["4-5 things a thoughtful answer would raise"]}${socratic ? `,
42
+ "socratic":[{"q":"a leading question the learner can partly answer from what they already know","hint":"a nudge if they are stuck","idea":"the insight the question points to"}] /* exactly 3, ordered so each builds on the last */` : ""}}`;
25
43
  export function surveyLessonPrompt(p, s, area) {
26
44
  return `${learnerBlurb(p)}
27
- Topic: "${s.title}". This is the BROAD SURVEY pass: give the learner the basic idea of the area "${area.title}" (${area.summary}).
45
+ Topic: "${s.title}". ${useBlurb(s)} This is the BROAD SURVEY pass: give the learner the basic idea of the area "${area.title}" (${area.summary}).
28
46
  Concepts to touch: ${area.concepts.map((c) => c.name).join(", ")}.
29
47
  Rules: max ~250 words in markdown. ${p.order === "examples-first" ? "Open with a concrete example, then name the idea." : p.order === "theory-first" ? "State the core principle first, then illustrate." : "Open with where it fits in the whole field, then the idea, then an example."}
30
48
  Use the learner's preferred styles (${p.styles.join(", ") || "any"}). ${diagramRule(p)} ${MERMAID_RULES}
49
+ ${STEM_RULES}
31
50
  Exactly 2 multiple-choice checks (4 options each, "answer" is the 0-based index, plausible distractors based on real misconceptions; vary the position of the right answer). 2-3 flashcards.
32
51
  Return ONLY JSON of this shape:
33
52
  ${lessonShape}`;
@@ -40,17 +59,20 @@ Return ONLY JSON: {"lessons":[{"id":"short-kebab-id","title":"...","objective":"
40
59
  }
41
60
  export function deepLessonPrompt(p, s, area, lesson, doneTitles) {
42
61
  return `${learnerBlurb(p)}
43
- Topic: "${s.title}", area "${area.title}". DEEP lesson: "${lesson.title}" — objective: ${lesson.objective}.
62
+ Topic: "${s.title}", area "${area.title}". ${useBlurb(s)} DEEP lesson: "${lesson.title}" — objective: ${lesson.objective}.
44
63
  Already covered in this area: ${doneTitles.join("; ") || "nothing yet"}. Build on that; connect back to it explicitly.
45
64
  Rules: 400-650 words of markdown with ## subheadings. ${p.order === "examples-first" ? "Start from a worked example, then abstract." : p.order === "theory-first" ? "Start from first principles, then apply." : "Start from the big picture, zoom in, then work an example."}
46
- Include a worked example, one common misconception to avoid, and ${p.diagrams === "minimal" ? "at least one" : "1-2"} mermaid diagram(s) showing the real mechanism/structure (not decoration). ${MERMAID_RULES}
65
+ Include a worked example, one common misconception to avoid, and ${p.diagrams === "minimal" ? "at least one" : "1-2"} figure(s) (mermaid, svg or plot as fits the subject) showing the real mechanism/structure (not decoration). ${MERMAID_RULES}
66
+ ${STEM_RULES}
47
67
  Challenge level: ${p.challenge}. Exactly ${p.challenge === "tough" ? 4 : 3} multiple-choice checks that test understanding/application, not trivia (4 options, "answer" is 0-based). 3-4 flashcards phrased as recall questions.
48
- Return ONLY JSON of this shape:
49
- ${lessonShape}`;
68
+ ${p.socratic ? "The learner prefers a SOCRATIC style: the markdown is the consolidation shown AFTER they work through your leading questions, so it should name and tie together the ideas those questions lead to." : ""}
69
+ Also include the "prediction" (a situation where a wrong intuition is likely, so they learn from guessing wrong), the "deeper" trade-off question${p.socratic ? ", and exactly 3 \"socratic\" questions" : ""}.
70
+ Return ONLY JSON of this shape (omit the /* */ comment):
71
+ ${lessonShapeBase}${deepExtras(!!p.socratic)}`;
50
72
  }
51
73
  export function reexplainPrompt(p, topic, lessonTitle, original) {
52
74
  return `${learnerBlurb(p)}
53
- The learner is stuck on "${lessonTitle}" (topic: ${topic}). Re-explain the SAME idea differently: new analogy, simpler words, different example. Do not repeat the original framing. Max 200 words, markdown, optionally one mermaid diagram. ${MERMAID_RULES}
75
+ The learner is stuck on "${lessonTitle}" (topic: ${topic}). Re-explain the SAME idea differently: new analogy, simpler words, different example. Do not repeat the original framing. Max 200 words, markdown, optionally one figure or equation. ${MERMAID_RULES}\n${STEM_RULES.replace(/Inside the JSON, double every backslash \(write \\\\frac\)\./, '')}
54
76
  Original explanation for reference:
55
77
  ${original}
56
78
  Return only the markdown.`;
@@ -71,3 +93,57 @@ export function bigPicturePrompt(s) {
71
93
  };
72
94
  }
73
95
  export const gradeLabel = (g) => ["Not yet", "Getting there", "Mostly there", "Solid"][g.score];
96
+ export const PERSONAS = [
97
+ { label: "a curious 12-year-old", voice: "asks 'but why?', gets lost at jargon, loves concrete pictures" },
98
+ { label: "a skeptical expert from a neighbouring field", voice: "challenges hand-waving, asks for precision, probes edge cases" },
99
+ { label: "a new teammate on their first day", voice: "practical: asks how it applies in real work and what to watch out for" },
100
+ { label: "a smart friend who has never heard of this", voice: "bright but zero background; spots any step that was skipped" },
101
+ ];
102
+ export function personaPrompt(p, persona, title, keyPoints, answer) {
103
+ return `Role-play ${persona.label} (${persona.voice}) who just heard the learner explain "${title}". Their explanation: """${answer}"""
104
+ A complete explanation would cover: ${keyPoints.join("; ")}.
105
+ React in character in 1-2 sentences (say honestly what confused you or what clicked), then ask ONE follow-up question aimed at the biggest gap or shaky claim in what they said. Stay in character; never reveal the answer.
106
+ Return ONLY JSON: {"reaction":"...","question":"..."}`;
107
+ }
108
+ export function diagnoseQuestionsPrompt(p, title, missed, explanation) {
109
+ return `${learnerBlurb(p)}
110
+ The learner struggled with "${title}". They missed: ${missed.map((m) => `"${m}"`).join("; ") || "none (their own-words explanation was weak)"}. ${explanation ? `Feedback on their explanation: ${explanation}` : ""}
111
+ Write exactly 2 short diagnostic questions (not quiz questions) that reveal WHAT mental model they hold, e.g. "what do you picture happens when ...?". Do not teach yet.
112
+ Return ONLY JSON: {"questions":["...","..."]}`;
113
+ }
114
+ export function diagnoseResultPrompt(p, title, qa, missed) {
115
+ return `${learnerBlurb(p)}
116
+ Lesson "${title}". Missed questions: ${missed.join("; ") || "n/a"}.
117
+ Diagnostic Q&A:
118
+ ${qa.map((x) => `Q: ${x.q}\nA: ${x.a || "(no answer)"}`).join("\n")}
119
+ Identify the single most likely misconception or gap, re-teach exactly that with a NEW angle (max 150 words, markdown, optional tiny mermaid), and write one fresh multiple-choice check that would fail if the misconception persists (4 options, "answer" 0-based index).
120
+ Return ONLY JSON: {"misconception":"one sentence","reteach":"markdown","retest":{"q":"...","options":["","","",""],"answer":0,"explain":"..."}}`;
121
+ }
122
+ export const LENSES = [
123
+ { id: "tradeoffs", label: "Trade-offs", ask: "what is given up by this approach, and under what conditions would the alternative win?" },
124
+ { id: "failure", label: "What could go wrong", ask: "how could this fail or be misused, and what early warning signs would show it?" },
125
+ { id: "scale", label: "Scale & time", ask: "what changes if this grows 100x or has to last 10 years?" },
126
+ { id: "second-order", label: "Second-order effects", ask: "what knock-on effects follow that nobody planned for?" },
127
+ { id: "inversion", label: "Invert it", ask: "if you wanted this to fail on purpose, or the opposite assumption held, what would happen?" },
128
+ { id: "edge", label: "Edge cases", ask: "which unusual situations break the neat explanation?" },
129
+ ];
130
+ export function deeperPrompt(p, s, title, lens, earlier) {
131
+ return `${learnerBlurb(p)} ${useBlurb(s)}
132
+ Topic "${s.title}", lesson "${title}". Pose ONE open-ended "think deeper" question through the lens "${lens.label}": ${lens.ask}
133
+ Make it concrete (a specific situation, not abstract). Do not repeat earlier questions: ${earlier.join(" | ") || "none"}.
134
+ Return ONLY JSON: {"question":"...","considerations":["4-5 things a thoughtful answer would raise"]}`;
135
+ }
136
+ export function critiquePrompt(p, title, question, considerations, answer) {
137
+ const tone = { encouraging: "warm and specific", direct: "blunt and efficient", socratic: "mostly questions" }[p.tone];
138
+ return `The learner is thinking beyond the basics on "${title}". Question: "${question}"
139
+ A thoughtful answer would raise: ${considerations.map((c) => `- ${c}`).join("\n")}
140
+ Learner's answer: """${answer}"""
141
+ Judge reasoning quality, not length: did they weigh both sides, name concrete risks, and notice second-order effects? Credit valid points that are NOT in the list. Be ${tone}.
142
+ Return ONLY JSON: {"strong":["good points they made"],"blindSpots":["important angles they did not consider, each with one sentence of why it matters"],"pushback":"one hard follow-up that challenges their weakest assumption","feedback":"2 sentences"}`;
143
+ }
144
+ export function challengePrompt(p, s, area, covered) {
145
+ return `${learnerBlurb(p)} ${useBlurb(s)}
146
+ The learner just finished a deep dive into "${area.title}" (topic "${s.title}"), covering: ${covered.join("; ")}.
147
+ Write ONE realistic application challenge: a specific scenario with constraints and a decision to make, where there is no single right answer and the learner must weigh trade-offs and anticipate what could go wrong. Make it ${p.challenge === "tough" ? "hard and ambiguous" : p.challenge === "gentle" ? "approachable" : "moderately demanding"}.
148
+ Return ONLY JSON: {"scenario":"3-5 sentences","task":"what they must decide or design, and justify","considerations":["5-6 things a strong answer weighs"]}`;
149
+ }
package/dist/stats.js ADDED
@@ -0,0 +1,48 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { configDir } from "./store.js";
4
+ const statsPath = () => join(configDir(), "stats.json");
5
+ export const dayKey = (d = new Date()) => d.toLocaleDateString("en-CA");
6
+ const yesterdayKey = (d = new Date()) => dayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1));
7
+ export async function loadStats() {
8
+ try {
9
+ return { streak: 0, longest: 0, lastDay: "", totalDays: 0, ...JSON.parse(await readFile(statsPath(), "utf8")) };
10
+ }
11
+ catch {
12
+ return { streak: 0, longest: 0, lastDay: "", totalDays: 0 };
13
+ }
14
+ }
15
+ export async function saveStats(s) {
16
+ await mkdir(configDir(), { recursive: true });
17
+ await writeFile(statsPath(), JSON.stringify(s, null, 2));
18
+ }
19
+ /** The streak you could still lose: alive if you studied today or yesterday. */
20
+ export function currentStreak(s, now = new Date()) {
21
+ return s.lastDay === dayKey(now) || s.lastDay === yesterdayKey(now) ? s.streak : 0;
22
+ }
23
+ export const doneToday = (s, now = new Date()) => s.lastDay === dayKey(now);
24
+ /** Pure update; returns the new stats and whether today's activity just extended the streak. */
25
+ export function applyActivity(s, now = new Date()) {
26
+ const today = dayKey(now);
27
+ if (s.lastDay === today)
28
+ return { stats: s, extended: false };
29
+ const streak = s.lastDay === yesterdayKey(now) ? s.streak + 1 : 1;
30
+ return { stats: { ...s, streak, longest: Math.max(s.longest, streak), lastDay: today, totalDays: s.totalDays + 1 }, extended: true };
31
+ }
32
+ export async function recordActivity(now = new Date()) {
33
+ const { stats, extended } = applyActivity(await loadStats(), now);
34
+ if (extended)
35
+ await saveStats(stats);
36
+ const milestone = [3, 7, 14, 30, 60, 100].includes(stats.streak);
37
+ const message = extended
38
+ ? stats.streak === 1
39
+ ? "Day 1 done. Come back tomorrow to start a streak."
40
+ : `${stats.streak}-day streak!${milestone ? " That's a real habit forming." : ""}${stats.streak === stats.longest && stats.streak > 1 ? " Personal best." : ""}`
41
+ : undefined;
42
+ return { stats, extended, message };
43
+ }
44
+ export async function saveCheckin(s, energy, minutes) {
45
+ const next = { ...s, checkin: { day: dayKey(), energy, minutes } };
46
+ await saveStats(next);
47
+ return next;
48
+ }
package/dist/store.js CHANGED
@@ -73,8 +73,16 @@ export async function createTopic(profile, title, category, priorKnowledge) {
73
73
  progress: {},
74
74
  bigPictureDone: false,
75
75
  cards: [],
76
+ mistakes: [],
77
+ connections: [],
76
78
  };
77
79
  const topic = { dir, state };
78
80
  await saveTopic(topic);
79
81
  return topic;
80
82
  }
83
+ export function addMistake(topic, m) {
84
+ const list = (topic.state.mistakes ??= []);
85
+ if (list.some((x) => x.lesson === m.lesson && x.what === m.what))
86
+ return;
87
+ list.push({ date: new Date().toISOString().slice(0, 10), ...m });
88
+ }