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.
- package/README.md +63 -2
- package/dist/cli.js +250 -0
- package/dist/diagram.js +49 -0
- package/dist/figures.js +200 -0
- package/dist/flows/learn.js +344 -0
- package/dist/flows/onboard.js +124 -0
- package/dist/flows/quiz.js +175 -0
- package/dist/flows/think.js +120 -0
- package/dist/llm.js +87 -0
- package/dist/markdown.js +136 -0
- package/dist/math.js +307 -0
- package/dist/prompts.js +149 -0
- package/dist/srs.js +50 -0
- package/dist/stats.js +48 -0
- package/dist/store.js +88 -0
- package/dist/termimg.js +27 -0
- package/dist/types.js +1 -0
- package/dist/ui.js +185 -0
- package/package.json +45 -4
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export function learnerBlurb(p) {
|
|
2
|
+
return [
|
|
3
|
+
`Learner: ${p.name}. Goal: ${p.goal}. Level: ${p.level}.`,
|
|
4
|
+
`Likes: ${p.styles.join(", ") || "no strong preference"}. Prefers ${p.order.replace(/-/g, " ")} ordering.`,
|
|
5
|
+
`Diagram appetite: ${p.diagrams}. Challenge: ${p.challenge}. Feedback tone: ${p.tone}.`,
|
|
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(" ");
|
|
15
|
+
}
|
|
16
|
+
const MERMAID_RULES = `Mermaid rules (diagrams MUST render): use only flowchart TD/LR, sequenceDiagram, stateDiagram-v2, classDiagram, or mindmap.
|
|
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.`;
|
|
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.";
|
|
24
|
+
export function mapPrompt(p, topic, prior) {
|
|
25
|
+
return `${learnerBlurb(p)}
|
|
26
|
+
Create a map of the whole territory of "${topic}" for this learner. Their stated prior knowledge: ${prior || "none stated"}.
|
|
27
|
+
Cover the field broadly: 5-9 areas, ordered so earlier areas support later ones. Each area has 3-6 concepts.
|
|
28
|
+
Return ONLY JSON:
|
|
29
|
+
{"overview":"2-3 sentence orientation: what this field is and why it matters",
|
|
30
|
+
"areas":[{"id":"short-kebab-id","title":"...","summary":"1-2 sentences","prereqs":["ids of areas that should come first"],"concepts":[{"name":"...","blurb":"one line"}]}]}`;
|
|
31
|
+
}
|
|
32
|
+
const lessonShapeBase = `{"primer":"one open question that makes the learner GUESS before reading (no jargon they haven't seen)",
|
|
33
|
+
"markdown":"the lesson body in markdown",
|
|
34
|
+
"checks":[{"q":"...","options":["A","B","C","D"],"answer":0,"explain":"why the answer is right and the best distractor is wrong"}],
|
|
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",
|
|
36
|
+
"keyPoints":["3-5 points a correct own-words explanation should contain"],
|
|
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 */` : ""}}`;
|
|
43
|
+
export function surveyLessonPrompt(p, s, area) {
|
|
44
|
+
return `${learnerBlurb(p)}
|
|
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}).
|
|
46
|
+
Concepts to touch: ${area.concepts.map((c) => c.name).join(", ")}.
|
|
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."}
|
|
48
|
+
Use the learner's preferred styles (${p.styles.join(", ") || "any"}). ${diagramRule(p)} ${MERMAID_RULES}
|
|
49
|
+
${STEM_RULES}
|
|
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.
|
|
51
|
+
Return ONLY JSON of this shape:
|
|
52
|
+
${lessonShape}`;
|
|
53
|
+
}
|
|
54
|
+
export function outlinePrompt(p, s, area) {
|
|
55
|
+
return `${learnerBlurb(p)}
|
|
56
|
+
Topic: "${s.title}". Plan a DEEP DIVE into "${area.title}" (${area.summary}); concepts: ${area.concepts.map((c) => c.name).join(", ")}.
|
|
57
|
+
Design 4-7 lessons that build on each other (simple -> complex, each introduces ONE main idea), ending with a lesson that integrates everything in a realistic scenario.
|
|
58
|
+
Return ONLY JSON: {"lessons":[{"id":"short-kebab-id","title":"...","objective":"By the end you can ..."}]}`;
|
|
59
|
+
}
|
|
60
|
+
export function deepLessonPrompt(p, s, area, lesson, doneTitles) {
|
|
61
|
+
return `${learnerBlurb(p)}
|
|
62
|
+
Topic: "${s.title}", area "${area.title}". ${useBlurb(s)} DEEP lesson: "${lesson.title}" — objective: ${lesson.objective}.
|
|
63
|
+
Already covered in this area: ${doneTitles.join("; ") || "nothing yet"}. Build on that; connect back to it explicitly.
|
|
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."}
|
|
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}
|
|
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.
|
|
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)}`;
|
|
72
|
+
}
|
|
73
|
+
export function reexplainPrompt(p, topic, lessonTitle, original) {
|
|
74
|
+
return `${learnerBlurb(p)}
|
|
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\)\./, '')}
|
|
76
|
+
Original explanation for reference:
|
|
77
|
+
${original}
|
|
78
|
+
Return only the markdown.`;
|
|
79
|
+
}
|
|
80
|
+
export function gradePrompt(p, lessonTitle, prompt, keyPoints, answer) {
|
|
81
|
+
const tone = { encouraging: "warm and specific", direct: "blunt and efficient", socratic: "mostly questions that lead them to see gaps themselves" }[p.tone];
|
|
82
|
+
return `Grade a learner's own-words explanation. Lesson: "${lessonTitle}". Prompt given: "${prompt}".
|
|
83
|
+
A strong answer includes: ${keyPoints.map((k) => `- ${k}`).join("\n")}
|
|
84
|
+
Learner's answer: """${answer}"""
|
|
85
|
+
Be ${tone}. Judge understanding, not wording; reward correct paraphrase, penalise parroting and errors. score: 0 = off/blank, 1 = fragments, 2 = mostly right with gaps, 3 = solid.
|
|
86
|
+
Return ONLY JSON: {"score":0-3,"right":["what they got right"],"missing":["key ideas absent"],"misconceptions":["things they said that are wrong, with the correction"],"feedback":"2-3 sentences","followUp":"one probing question that would deepen their understanding"}`;
|
|
87
|
+
}
|
|
88
|
+
export function bigPicturePrompt(s) {
|
|
89
|
+
const areas = s.map.areas;
|
|
90
|
+
return {
|
|
91
|
+
prompt: `Without looking back: how do the main areas of ${s.title} (${areas.map((a) => a.title).join(", ")}) fit together? Explain it as if briefing a smart friend.`,
|
|
92
|
+
keyPoints: areas.map((a) => `${a.title}: ${a.summary}`),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
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/srs.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const DAY = 86_400_000;
|
|
2
|
+
export function newCard(front, back, source, now = new Date()) {
|
|
3
|
+
return {
|
|
4
|
+
id: Math.random().toString(36).slice(2, 10),
|
|
5
|
+
front,
|
|
6
|
+
back,
|
|
7
|
+
source,
|
|
8
|
+
ease: 2.5,
|
|
9
|
+
interval: 0,
|
|
10
|
+
reps: 0,
|
|
11
|
+
lapses: 0,
|
|
12
|
+
due: now.toISOString(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Simplified SM-2: failed cards come back soon, successful ones expand the gap. */
|
|
16
|
+
export function schedule(card, rating, now = new Date()) {
|
|
17
|
+
const c = { ...card };
|
|
18
|
+
if (rating === "again") {
|
|
19
|
+
c.lapses++;
|
|
20
|
+
c.reps = 0;
|
|
21
|
+
c.ease = Math.max(1.3, c.ease - 0.2);
|
|
22
|
+
c.interval = 0;
|
|
23
|
+
c.due = new Date(now.getTime() + 10 * 60_000).toISOString();
|
|
24
|
+
return c;
|
|
25
|
+
}
|
|
26
|
+
c.reps++;
|
|
27
|
+
if (rating === "hard")
|
|
28
|
+
c.ease = Math.max(1.3, c.ease - 0.15);
|
|
29
|
+
if (rating === "easy")
|
|
30
|
+
c.ease += 0.15;
|
|
31
|
+
if (c.reps === 1)
|
|
32
|
+
c.interval = rating === "easy" ? 3 : 1;
|
|
33
|
+
else if (c.reps === 2)
|
|
34
|
+
c.interval = rating === "hard" ? 2 : 4;
|
|
35
|
+
else
|
|
36
|
+
c.interval = Math.max(1, Math.round(c.interval * (rating === "hard" ? 1.2 : rating === "easy" ? c.ease * 1.3 : c.ease)));
|
|
37
|
+
c.due = new Date(now.getTime() + c.interval * DAY).toISOString();
|
|
38
|
+
return c;
|
|
39
|
+
}
|
|
40
|
+
export const isDue = (c, now = new Date()) => new Date(c.due).getTime() <= now.getTime();
|
|
41
|
+
export function addCards(existing, fresh, source) {
|
|
42
|
+
let added = 0;
|
|
43
|
+
for (const f of fresh) {
|
|
44
|
+
if (existing.some((c) => c.front.trim().toLowerCase() === f.front.trim().toLowerCase()))
|
|
45
|
+
continue;
|
|
46
|
+
existing.push(newCard(f.front, f.back, source));
|
|
47
|
+
added++;
|
|
48
|
+
}
|
|
49
|
+
return added;
|
|
50
|
+
}
|
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
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
export const configDir = () => process.env.LOOPS_HOME ?? join(homedir(), ".config", "loops");
|
|
6
|
+
const profilePath = () => join(configDir(), "profile.json");
|
|
7
|
+
export const slugify = (s) => s
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.normalize("NFKD")
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
11
|
+
.replace(/^-+|-+$/g, "")
|
|
12
|
+
.slice(0, 48) || "untitled";
|
|
13
|
+
export const expandHome = (p) => (p.startsWith("~") ? join(homedir(), p.slice(1)) : p);
|
|
14
|
+
export async function loadProfile() {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(await readFile(profilePath(), "utf8"));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function saveProfile(profile) {
|
|
23
|
+
await mkdir(configDir(), { recursive: true });
|
|
24
|
+
await writeFile(profilePath(), JSON.stringify(profile, null, 2));
|
|
25
|
+
}
|
|
26
|
+
export async function writeText(path, content) {
|
|
27
|
+
await mkdir(dirname(path), { recursive: true });
|
|
28
|
+
await writeFile(path, content);
|
|
29
|
+
}
|
|
30
|
+
export async function saveTopic(topic) {
|
|
31
|
+
await writeText(join(topic.dir, "topic.json"), JSON.stringify(topic.state, null, 2));
|
|
32
|
+
}
|
|
33
|
+
/** Topics live at <root>/<category>/<slug>/topic.json. */
|
|
34
|
+
export async function listTopics(profile) {
|
|
35
|
+
const root = expandHome(profile.rootDir);
|
|
36
|
+
if (!existsSync(root))
|
|
37
|
+
return [];
|
|
38
|
+
const topics = [];
|
|
39
|
+
for (const cat of await readdir(root, { withFileTypes: true })) {
|
|
40
|
+
if (!cat.isDirectory())
|
|
41
|
+
continue;
|
|
42
|
+
for (const sub of await readdir(join(root, cat.name), { withFileTypes: true })) {
|
|
43
|
+
const file = join(root, cat.name, sub.name, "topic.json");
|
|
44
|
+
if (!sub.isDirectory() || !existsSync(file))
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
topics.push({ dir: join(root, cat.name, sub.name), state: JSON.parse(await readFile(file, "utf8")) });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* skip unreadable topic */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return topics.sort((a, b) => lastTouched(b).localeCompare(lastTouched(a)));
|
|
55
|
+
}
|
|
56
|
+
const lastTouched = (t) => t.state.cards.map((c) => c.due).sort().pop() ?? t.state.createdAt;
|
|
57
|
+
export async function listCategories(profile) {
|
|
58
|
+
const root = expandHome(profile.rootDir);
|
|
59
|
+
if (!existsSync(root))
|
|
60
|
+
return [];
|
|
61
|
+
return (await readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
62
|
+
}
|
|
63
|
+
export async function createTopic(profile, title, category, priorKnowledge) {
|
|
64
|
+
const slug = slugify(title);
|
|
65
|
+
const dir = join(expandHome(profile.rootDir), slugify(category), slug);
|
|
66
|
+
const state = {
|
|
67
|
+
title,
|
|
68
|
+
slug,
|
|
69
|
+
category: slugify(category),
|
|
70
|
+
createdAt: new Date().toISOString(),
|
|
71
|
+
priorKnowledge,
|
|
72
|
+
phase: "map",
|
|
73
|
+
progress: {},
|
|
74
|
+
bigPictureDone: false,
|
|
75
|
+
cards: [],
|
|
76
|
+
mistakes: [],
|
|
77
|
+
connections: [],
|
|
78
|
+
};
|
|
79
|
+
const topic = { dir, state };
|
|
80
|
+
await saveTopic(topic);
|
|
81
|
+
return topic;
|
|
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
|
+
}
|
package/dist/termimg.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function imageProtocol(env = process.env, isTTY = !!process.stdout.isTTY) {
|
|
2
|
+
if (env.LOOPS_IMAGES === "off")
|
|
3
|
+
return null;
|
|
4
|
+
if (env.LOOPS_IMAGES === "iterm" || env.LOOPS_IMAGES === "kitty")
|
|
5
|
+
return env.LOOPS_IMAGES; // forced
|
|
6
|
+
if (!isTTY)
|
|
7
|
+
return null;
|
|
8
|
+
if (env.TMUX)
|
|
9
|
+
return null; // needs passthrough; not worth the risk of garbage output
|
|
10
|
+
if (env.KITTY_WINDOW_ID || env.TERM === "xterm-kitty" || env.TERM_PROGRAM === "ghostty")
|
|
11
|
+
return "kitty";
|
|
12
|
+
if (["iTerm.app", "WezTerm", "vscode"].includes(env.TERM_PROGRAM ?? ""))
|
|
13
|
+
return "iterm";
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/** Escape sequence that draws `png` about `cells` columns wide. */
|
|
17
|
+
export function drawImage(png, cells, protocol) {
|
|
18
|
+
const b64 = png.toString("base64");
|
|
19
|
+
if (protocol === "iterm")
|
|
20
|
+
return `\x1b]1337;File=inline=1;width=${cells};preserveAspectRatio=1:${b64}\x07`;
|
|
21
|
+
const chunks = [];
|
|
22
|
+
for (let i = 0; i < b64.length; i += 4096) {
|
|
23
|
+
const more = i + 4096 < b64.length ? 1 : 0;
|
|
24
|
+
chunks.push(`\x1b_G${i === 0 ? `a=T,f=100,c=${cells},` : ""}m=${more};${b64.slice(i, i + 4096)}\x1b\\`);
|
|
25
|
+
}
|
|
26
|
+
return chunks.join("");
|
|
27
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { renderDiagram } from "./diagram.js";
|
|
4
|
+
import { parsePlot, plotToBraille, renderPlot, sanitizeSvg, svgCaption } from "./figures.js";
|
|
5
|
+
import { mathToText, svgToPng, texToLines, texToPng } from "./math.js";
|
|
6
|
+
import { drawImage, imageProtocol } from "./termimg.js";
|
|
7
|
+
const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
8
|
+
const c256 = (n) => (s) => (useColor ? `\x1b[38;5;${n}m${s}\x1b[39m` : s);
|
|
9
|
+
export const t = {
|
|
10
|
+
accent: c256(141),
|
|
11
|
+
pink: c256(213),
|
|
12
|
+
cyan: c256(87),
|
|
13
|
+
ok: c256(78),
|
|
14
|
+
warn: c256(215),
|
|
15
|
+
bad: c256(203),
|
|
16
|
+
dim: pc.dim,
|
|
17
|
+
bold: pc.bold,
|
|
18
|
+
italic: pc.italic,
|
|
19
|
+
};
|
|
20
|
+
const LOGO = [
|
|
21
|
+
" ██╗ ██████╗ ██████╗ ██████╗ ███████╗",
|
|
22
|
+
" ██║ ██╔═══██╗██╔═══██╗██╔══██╗██╔════╝",
|
|
23
|
+
" ██║ ██║ ██║██║ ██║██████╔╝███████╗",
|
|
24
|
+
" ██║ ██║ ██║██║ ██║██╔═══╝ ╚════██║",
|
|
25
|
+
" ███████╗╚██████╔╝╚██████╔╝██║ ███████║",
|
|
26
|
+
" ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝",
|
|
27
|
+
];
|
|
28
|
+
const GRADIENT = [93, 99, 105, 141, 177, 213];
|
|
29
|
+
export function banner(subtitle = "learn anything, properly") {
|
|
30
|
+
console.log();
|
|
31
|
+
LOGO.forEach((line, i) => console.log(c256(GRADIENT[i])(line)));
|
|
32
|
+
console.log(t.dim(` ${subtitle}\n`));
|
|
33
|
+
}
|
|
34
|
+
/** Unwrap a clack prompt result; exit cleanly on Ctrl+C. */
|
|
35
|
+
export function must(value) {
|
|
36
|
+
if (p.isCancel(value)) {
|
|
37
|
+
p.cancel("Progress saved. See you next time ✦");
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
export async function withSpinner(label, fn) {
|
|
43
|
+
const s = p.spinner();
|
|
44
|
+
s.start(label);
|
|
45
|
+
try {
|
|
46
|
+
const r = await fn();
|
|
47
|
+
s.stop(t.ok("✓ ") + label.replace(/\.\.\.$/, ""));
|
|
48
|
+
return r;
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
s.stop(t.bad("✗ ") + label);
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function bar(done, total, width = 20) {
|
|
56
|
+
const filled = total === 0 ? 0 : Math.round((done / total) * width);
|
|
57
|
+
return t.accent("█".repeat(filled)) + t.dim("░".repeat(width - filled)) + t.dim(` ${done}/${total}`);
|
|
58
|
+
}
|
|
59
|
+
let tipShown = false;
|
|
60
|
+
/** Draw a raster inline if the terminal can; returns null otherwise. */
|
|
61
|
+
function inlineImage(r, maxCells) {
|
|
62
|
+
const proto = imageProtocol();
|
|
63
|
+
if (!proto)
|
|
64
|
+
return null;
|
|
65
|
+
const cols = process.stdout.columns ?? 100;
|
|
66
|
+
const cells = Math.max(8, Math.min(Math.ceil(r.width / 8), maxCells, cols - 4));
|
|
67
|
+
return " " + drawImage(r.png, cells, proto);
|
|
68
|
+
}
|
|
69
|
+
function figureNote(what, caption) {
|
|
70
|
+
const lines = [" " + t.pink(`◆ ${what}`) + (caption ? t.dim(` — ${caption}`) : "") + t.dim(" (saved in the lesson's markdown file)")];
|
|
71
|
+
if (!tipShown) {
|
|
72
|
+
tipShown = true;
|
|
73
|
+
lines.push(" " + t.dim("tip: iTerm2, WezTerm, Kitty and Ghostty draw figures inline; elsewhere open the .md file"));
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
function renderEquation(tex) {
|
|
78
|
+
try {
|
|
79
|
+
const img = imageProtocol() ? inlineImage(texToPng(tex, true), 60) : null;
|
|
80
|
+
if (img)
|
|
81
|
+
return ["", img, ""];
|
|
82
|
+
return ["", ...texToLines(tex).map((l) => " " + t.cyan(l)), ""];
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return ["", " " + t.dim(tex.trim()), ""];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function renderFigure(kind, src) {
|
|
89
|
+
if (kind === "plot") {
|
|
90
|
+
const p = renderPlot(src);
|
|
91
|
+
if (!p)
|
|
92
|
+
return figureNote("plot", "couldn't be drawn");
|
|
93
|
+
const img = imageProtocol() ? inlineImage(p.raster, 70) : null;
|
|
94
|
+
if (img)
|
|
95
|
+
return ["", img, ""];
|
|
96
|
+
const braille = plotToBraille(parsePlot(src), Math.min(64, (process.stdout.columns ?? 100) - 14), 14, useColor);
|
|
97
|
+
return braille ? ["", ...braille.map((l) => " " + l), ""] : figureNote("plot", p.caption);
|
|
98
|
+
}
|
|
99
|
+
const svg = sanitizeSvg(src);
|
|
100
|
+
if (!svg)
|
|
101
|
+
return figureNote("figure", "couldn't be drawn");
|
|
102
|
+
const img = imageProtocol() ? inlineImage(svgToPng(svg, 2), 70) : null;
|
|
103
|
+
return ["", img ?? figureNote("figure", svgCaption(svg)).join("\n"), ""];
|
|
104
|
+
}
|
|
105
|
+
/** Terminal markdown renderer: styling, mermaid, TeX equations, SVG figures and plots. */
|
|
106
|
+
export function renderMd(md) {
|
|
107
|
+
const out = [];
|
|
108
|
+
let fence = null;
|
|
109
|
+
let buf = [];
|
|
110
|
+
let math = null;
|
|
111
|
+
const inline = (s) => mathToText(s)
|
|
112
|
+
.replace(/\*\*(.+?)\*\*/g, (_, x) => t.bold(x))
|
|
113
|
+
.replace(/`([^`]+)`/g, (_, x) => t.cyan(x))
|
|
114
|
+
.replace(/(?<![*\w])\*([^*]+)\*(?!\w)/g, (_, x) => t.italic(x));
|
|
115
|
+
for (const raw of md.split("\n")) {
|
|
116
|
+
// display math: $$ … $$ (one or many lines) and \[ … \]
|
|
117
|
+
if (math) {
|
|
118
|
+
const close = raw.match(/^(.*?)(\$\$|\\\])\s*$/);
|
|
119
|
+
if (close) {
|
|
120
|
+
math.push(close[1]);
|
|
121
|
+
out.push(...renderEquation(math.join("\n")));
|
|
122
|
+
math = null;
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
math.push(raw);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (fence === null) {
|
|
129
|
+
const one = raw.match(/^\s*(?:\$\$|\\\[)(.+?)(?:\$\$|\\\])\s*$/);
|
|
130
|
+
if (one) {
|
|
131
|
+
out.push(...renderEquation(one[1]));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const open = raw.match(/^\s*(\$\$|\\\[)(.*)$/);
|
|
135
|
+
if (open) {
|
|
136
|
+
math = open[2].trim() ? [open[2]] : [];
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const fm = raw.match(/^```(\w*)/);
|
|
141
|
+
if (fm) {
|
|
142
|
+
if (fence === null) {
|
|
143
|
+
fence = fm[1] || "code";
|
|
144
|
+
buf = [];
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
if (fence === "mermaid") {
|
|
148
|
+
const art = renderDiagram(buf.join("\n"));
|
|
149
|
+
out.push("", ...(art ? art.split("\n").map((l) => " " + l) : [" " + t.pink("◆ diagram ") + t.dim("(too wide for this terminal — open the markdown file)")]), "");
|
|
150
|
+
}
|
|
151
|
+
else if (fence === "svg" || fence === "plot")
|
|
152
|
+
out.push(...renderFigure(fence, buf.join("\n")));
|
|
153
|
+
fence = null;
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (fence === "mermaid" || fence === "svg" || fence === "plot") {
|
|
158
|
+
buf.push(raw);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (fence !== null) {
|
|
162
|
+
out.push(" " + t.dim("│ ") + t.cyan(raw));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const h = raw.match(/^(#{1,4})\s+(.*)/);
|
|
166
|
+
if (h) {
|
|
167
|
+
out.push("", " " + (h[1].length <= 2 ? t.bold(t.accent(inline(h[2]))) : t.bold(inline(h[2]))));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const li = raw.match(/^(\s*)[-*]\s+(.*)/);
|
|
171
|
+
if (li) {
|
|
172
|
+
out.push(" " + li[1] + t.accent("• ") + inline(li[2]));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const q = raw.match(/^>\s?(.*)/);
|
|
176
|
+
if (q) {
|
|
177
|
+
out.push(" " + t.warn("▌ ") + t.italic(inline(q[1])));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
out.push(raw.trim() ? " " + inline(raw) : "");
|
|
181
|
+
}
|
|
182
|
+
return out.join("\n");
|
|
183
|
+
}
|
|
184
|
+
/** Plain-text math for prompts, options and flashcards. */
|
|
185
|
+
export const m = mathToText;
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "learn-loops",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "A learning CLI that uses Claude Code / Codex as your tutor",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"loops": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "rm -rf dist && tsc && chmod +x dist/cli.js",
|
|
11
|
+
"dev": "tsx src/cli.ts",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"test": "tsx --test test/*.test.ts",
|
|
14
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@clack/prompts": "^1.8.1",
|
|
22
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
23
|
+
"beautiful-mermaid": "^1.1.3",
|
|
24
|
+
"mathjax-full": "^3.2.1",
|
|
25
|
+
"mathjs": "^15.2.0",
|
|
26
|
+
"picocolors": "^1.1.1",
|
|
27
|
+
"unicodeit": "^0.7.5"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^26.6.5",
|
|
31
|
+
"tsx": "^4.23.15",
|
|
32
|
+
"typescript": "^7.0.2"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"keywords": [
|
|
39
|
+
"learning",
|
|
40
|
+
"cli",
|
|
41
|
+
"claude-code",
|
|
42
|
+
"codex",
|
|
43
|
+
"mermaid",
|
|
44
|
+
"spaced-repetition",
|
|
45
|
+
"tutor"
|
|
46
|
+
]
|
|
47
|
+
}
|