learn-loops 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import * as p from "@clack/prompts";
3
+ import { listTopics, loadProfile, configDir } from "./store.js";
4
+ import { isDue } from "./srs.js";
5
+ import { banner, bar, must, t } from "./ui.js";
6
+ import { onboard } from "./flows/onboard.js";
7
+ import { continueTopic, newTopic, printMap } from "./flows/learn.js";
8
+ import { reviewCards } from "./flows/quiz.js";
9
+ const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as your tutor
10
+
11
+ loops interactive menu
12
+ loops new [topic] start learning something new
13
+ loops continue pick up where you left off
14
+ loops review spaced-repetition review of due flashcards
15
+ loops topics list your topics and progress
16
+ loops profile edit how you like to learn
17
+ loops --help this help
18
+
19
+ Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, NO_COLOR`;
20
+ const dueCount = (topics) => topics.reduce((n, tp) => n + tp.state.cards.filter((c) => isDue(c)).length, 0);
21
+ function progressOf(tp) {
22
+ const areas = tp.state.map?.areas ?? [];
23
+ const lessons = areas.reduce((n, a) => n + (tp.state.progress[a.id]?.done.length ?? 0), 0);
24
+ return { areas: areas.length, surveyed: areas.filter((a) => tp.state.progress[a.id]?.surveyed).length, lessons };
25
+ }
26
+ async function chooseTopic(topics, message) {
27
+ const idx = must(await p.select({
28
+ message,
29
+ options: topics.map((tp, i) => {
30
+ const pr = progressOf(tp);
31
+ return { value: i, label: `${tp.state.title}`, hint: `${tp.state.category} · ${pr.surveyed}/${pr.areas} surveyed · ${pr.lessons} deep lessons` };
32
+ }),
33
+ }));
34
+ return topics[idx];
35
+ }
36
+ async function menu(profile) {
37
+ while (true) {
38
+ const topics = await listTopics(profile);
39
+ const due = dueCount(topics);
40
+ const action = must(await p.select({
41
+ message: `Hi ${profile.name}, what now?`,
42
+ options: [
43
+ ...(topics.length ? [{ value: "continue", label: "Continue learning", hint: topics[0].state.title }] : []),
44
+ { value: "new", label: "Start a new topic" },
45
+ ...(due ? [{ value: "review", label: `Review flashcards`, hint: `${due} due` }] : []),
46
+ ...(topics.length ? [{ value: "topics", label: "My topics" }] : []),
47
+ { value: "profile", label: "Edit my learner profile" },
48
+ { value: "quit", label: "Quit" },
49
+ ],
50
+ }));
51
+ if (action === "quit")
52
+ return p.outro(t.dim("Keep the loop going ✦"));
53
+ await dispatch(action, profile);
54
+ }
55
+ }
56
+ async function dispatch(action, profile, arg) {
57
+ const topics = await listTopics(profile);
58
+ switch (action) {
59
+ case "new": {
60
+ const tp = await newTopic(profile, arg);
61
+ if (tp && must(await p.select({ message: "Start the broad survey now?", options: [{ value: true, label: "Yes, let's go" }, { value: false, label: "Later" }] })))
62
+ await continueTopic(profile, tp);
63
+ break;
64
+ }
65
+ case "continue": {
66
+ if (!topics.length)
67
+ return p.log.warn("No topics yet — try `loops new`.");
68
+ const tp = topics.length === 1 ? topics[0] : await chooseTopic(topics, "Which topic?");
69
+ if (tp.state.map && tp.state.phase === "survey" && !tp.state.progress[tp.state.map.areas[0].id]?.surveyed)
70
+ printMap(tp);
71
+ await continueTopic(profile, tp);
72
+ break;
73
+ }
74
+ case "review": {
75
+ const n = await reviewCards(topics);
76
+ p.log.success(n ? t.ok(`Reviewed ${n} card${n > 1 ? "s" : ""}.`) : "Nothing due right now. Come back tomorrow — that's the point.");
77
+ break;
78
+ }
79
+ case "topics": {
80
+ if (!topics.length)
81
+ return p.log.warn("No topics yet.");
82
+ const lines = topics.map((tp) => {
83
+ const pr = progressOf(tp);
84
+ return `${t.bold(tp.state.title)} ${t.dim(`(${tp.state.category})`)}\n survey ${bar(pr.surveyed, pr.areas, 12)} ${t.dim(`${pr.lessons} deep lessons · ${tp.state.cards.length} cards`)}\n ${t.dim(tp.dir)}`;
85
+ });
86
+ p.note(lines.join("\n\n"), "Your topics");
87
+ break;
88
+ }
89
+ case "profile":
90
+ await onboard(profile);
91
+ break;
92
+ }
93
+ }
94
+ async function main() {
95
+ const [cmd, ...rest] = process.argv.slice(2);
96
+ if (cmd === "--help" || cmd === "-h" || cmd === "help")
97
+ return console.log(HELP);
98
+ if (cmd === "--version" || cmd === "-v")
99
+ return console.log("0.1.0");
100
+ if (!process.stdin.isTTY) {
101
+ console.error("loops needs an interactive terminal.");
102
+ process.exit(1);
103
+ }
104
+ banner();
105
+ let profile = await loadProfile();
106
+ if (!profile)
107
+ profile = await onboard();
108
+ if (process.env.LOOPS_PROVIDER === "claude" || process.env.LOOPS_PROVIDER === "codex")
109
+ profile.provider = process.env.LOOPS_PROVIDER;
110
+ if (!cmd)
111
+ return menu(profile);
112
+ if (["new", "continue", "review", "topics", "profile"].includes(cmd))
113
+ return dispatch(cmd, profile, rest.join(" ") || undefined);
114
+ console.log(HELP);
115
+ process.exit(1);
116
+ }
117
+ main().catch((e) => {
118
+ p.log.error(t.bad(e.message));
119
+ console.error(t.dim(`(config: ${configDir()})`));
120
+ process.exit(1);
121
+ });
@@ -0,0 +1,243 @@
1
+ import * as p from "@clack/prompts";
2
+ import { join } from "node:path";
3
+ import { askJson, askText } from "../llm.js";
4
+ import { bigPicturePrompt, deepLessonPrompt, mapPrompt, outlinePrompt, reexplainPrompt, surveyLessonPrompt } from "../prompts.js";
5
+ import { lessonMarkdown, mapMarkdown, writeIndex } from "../markdown.js";
6
+ import { createTopic, listCategories, saveTopic, slugify, writeText } from "../store.js";
7
+ import { addCards, isDue } from "../srs.js";
8
+ import { bar, must, renderMd, t, withSpinner } from "../ui.js";
9
+ import { reviewCards, runChecks, teachBack } from "./quiz.js";
10
+ const str = (v, name) => {
11
+ if (typeof v !== "string" || !v.trim())
12
+ throw new Error(`missing string "${name}"`);
13
+ return v;
14
+ };
15
+ function validateMap(x) {
16
+ if (!Array.isArray(x.areas) || x.areas.length < 3)
17
+ throw new Error("need at least 3 areas");
18
+ const areas = x.areas.map((a, i) => ({
19
+ id: slugify(a.id ?? a.title ?? `area-${i}`),
20
+ title: str(a.title, "title"),
21
+ summary: String(a.summary ?? ""),
22
+ prereqs: Array.isArray(a.prereqs) ? a.prereqs.map((s) => slugify(String(s))) : [],
23
+ concepts: (Array.isArray(a.concepts) ? a.concepts : []).map((c) => ({ name: str(c.name, "concept"), blurb: String(c.blurb ?? "") })),
24
+ }));
25
+ return { overview: String(x.overview ?? ""), areas };
26
+ }
27
+ function validateLesson(x) {
28
+ const checks = (Array.isArray(x.checks) ? x.checks : []).map((c) => {
29
+ if (!Array.isArray(c.options) || c.options.length < 2 || !Number.isInteger(c.answer) || c.answer < 0 || c.answer >= c.options.length)
30
+ throw new Error("bad check options/answer");
31
+ return { q: str(c.q, "q"), options: c.options.map(String), answer: c.answer, explain: String(c.explain ?? "") };
32
+ });
33
+ return {
34
+ primer: String(x.primer ?? ""),
35
+ markdown: str(x.markdown, "markdown"),
36
+ checks,
37
+ teachBack: str(x.teachBack, "teachBack"),
38
+ keyPoints: (Array.isArray(x.keyPoints) ? x.keyPoints : []).map(String),
39
+ cards: (Array.isArray(x.cards) ? x.cards : []).filter((c) => c?.front && c?.back).map((c) => ({ front: String(c.front), back: String(c.back) })),
40
+ };
41
+ }
42
+ const validateOutline = (x) => {
43
+ if (!Array.isArray(x.lessons) || !x.lessons.length)
44
+ throw new Error("no lessons");
45
+ return x.lessons.map((l, i) => ({ id: slugify(l.id ?? l.title ?? `l${i}`), title: str(l.title, "title"), objective: String(l.objective ?? "") }));
46
+ };
47
+ export async function newTopic(profile, title) {
48
+ p.intro(t.bold(t.accent(" New topic ")));
49
+ 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!") }));
50
+ const cats = await listCategories(profile);
51
+ let category = "general";
52
+ const choice = must(await p.select({
53
+ message: "Which folder should this live in?",
54
+ options: [...cats.map((c) => ({ value: c, label: c })), { value: "__new", label: t.accent("+ New folder") }],
55
+ }));
56
+ category = choice === "__new" ? must(await p.text({ message: "Folder name", placeholder: "e.g. programming, science, languages", validate: (v) => (v?.trim() ? undefined : "Required") })) : choice;
57
+ const prior = must(await p.text({
58
+ message: `Brain dump: what do you already know (or think you know) about ${name}?`,
59
+ placeholder: "anything — even guesses. Blank if nothing. This sets the starting point.",
60
+ }));
61
+ const topic = await createTopic(profile, name.trim(), category, prior.trim());
62
+ await buildMap(profile, topic);
63
+ return topic;
64
+ }
65
+ async function buildMap(profile, topic) {
66
+ topic.state.map = await withSpinner("Mapping the territory...", () => askJson(profile, mapPrompt(profile, topic.state.title, topic.state.priorKnowledge), validateMap));
67
+ topic.state.phase = "survey";
68
+ for (const a of topic.state.map.areas)
69
+ topic.state.progress[a.id] ??= { surveyed: false, done: [] };
70
+ await writeText(join(topic.dir, "map.md"), mapMarkdown(topic));
71
+ await writeIndex(topic);
72
+ await saveTopic(topic);
73
+ printMap(topic);
74
+ }
75
+ export function printMap(topic) {
76
+ const map = topic.state.map;
77
+ const lines = [t.italic(map.overview), ""];
78
+ map.areas.forEach((a, i) => {
79
+ const last = i === map.areas.length - 1;
80
+ lines.push(`${t.accent(last ? "└─" : "├─")} ${t.bold(a.title)} ${t.dim(a.summary)}`);
81
+ a.concepts.forEach((c, j) => lines.push(`${t.dim(last ? " " : "│ ")} ${t.dim(j === a.concepts.length - 1 ? "└─" : "├─")} ${c.name}`));
82
+ });
83
+ p.note(lines.join("\n"), `${topic.state.title} — the map`);
84
+ p.log.info(t.dim(`Saved with mermaid diagrams: ${join(topic.dir, "map.md")}`));
85
+ }
86
+ /** Warm-up retrieval of older material before new learning (spacing + interleaving). */
87
+ async function warmUp(topic) {
88
+ const due = topic.state.cards.filter((c) => isDue(c)).length;
89
+ if (!due)
90
+ return;
91
+ const go = must(await p.select({
92
+ message: `${due} card${due > 1 ? "s are" : " is"} due. Start with a quick warm-up? (recalling old stuff first makes new stuff stick)`,
93
+ options: [
94
+ { value: true, label: "Warm up (5 cards)" },
95
+ { value: false, label: "Skip" },
96
+ ],
97
+ }));
98
+ if (go)
99
+ await reviewCards([topic], 5);
100
+ }
101
+ async function teach(ctx) {
102
+ const { profile, topic } = ctx;
103
+ const lesson = await withSpinner(`Preparing "${ctx.title}"...`, ctx.load);
104
+ p.log.step(t.bold(t.accent(ctx.title)));
105
+ // 1. Pretest: guessing before reading primes attention even when wrong.
106
+ if (lesson.primer) {
107
+ must(await p.text({ message: `${t.pink("Guess first")} — ${lesson.primer}`, placeholder: "a rough guess is fine (enter to skip)" }));
108
+ }
109
+ // 2. The chunk, with an escape hatch.
110
+ console.log(renderMd(lesson.markdown) + "\n");
111
+ while (true) {
112
+ const next = must(await p.select({
113
+ message: "Does it make sense?",
114
+ options: [
115
+ { value: "go", label: "Yes — quiz me" },
116
+ { value: "again", label: "Not really — explain it differently" },
117
+ ],
118
+ }));
119
+ if (next === "go")
120
+ break;
121
+ const alt = await withSpinner("Finding another angle...", () => askText(profile, reexplainPrompt(profile, topic.state.title, ctx.title, lesson.markdown)));
122
+ console.log(renderMd(alt) + "\n");
123
+ lesson.markdown += `\n\n---\n\n### Another angle\n\n${alt}`;
124
+ }
125
+ // 3. Retrieval practice, then 4. teach-back.
126
+ const missed = await runChecks(lesson.checks);
127
+ const attempts = await teachBack(profile, ctx.title, lesson.teachBack, lesson.keyPoints);
128
+ const cards = [...lesson.cards, ...missed.map((m) => ({ front: m.q, back: `${m.options[m.answer]} — ${m.explain}` }))];
129
+ const added = addCards(topic.state.cards, cards, ctx.title);
130
+ await writeText(join(topic.dir, ctx.file), lessonMarkdown(ctx.title, lesson, attempts, missed.map((m) => m.q)));
131
+ p.log.success(`${added} flashcard${added === 1 ? "" : "s"} added to your review queue ${t.dim("→ " + ctx.file)}`);
132
+ }
133
+ const overTime = (start, profile) => (Date.now() - start) / 60_000 >= profile.minutesPerSession;
134
+ export async function continueTopic(profile, topic) {
135
+ const s = topic.state;
136
+ const started = Date.now();
137
+ if (!s.map)
138
+ await buildMap(profile, topic);
139
+ await warmUp(topic);
140
+ // Survey phase: breadth first.
141
+ const areas = s.map.areas;
142
+ while (s.phase === "survey") {
143
+ const next = areas.find((a) => !s.progress[a.id].surveyed);
144
+ if (!next) {
145
+ if (!s.bigPictureDone) {
146
+ const bp = bigPicturePrompt(s);
147
+ p.log.step(t.bold(t.accent("Survey complete — zoom out")));
148
+ const attempts = await teachBack(profile, `${s.title}: big picture`, bp.prompt, bp.keyPoints);
149
+ s.bigPictureDone = true;
150
+ if (attempts.length)
151
+ 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`);
152
+ }
153
+ s.phase = "deep";
154
+ await saveTopic(topic);
155
+ break;
156
+ }
157
+ const idx = areas.indexOf(next);
158
+ console.log(`\n ${t.dim("Survey")} ${bar(areas.filter((a) => s.progress[a.id].surveyed).length, areas.length)}`);
159
+ await teach({
160
+ profile,
161
+ topic,
162
+ title: next.title,
163
+ file: `survey/${next.id}.md`,
164
+ load: () => askJson(profile, surveyLessonPrompt(profile, s, next), validateLesson),
165
+ });
166
+ s.progress[next.id].surveyed = true;
167
+ await writeIndex(topic);
168
+ await saveTopic(topic);
169
+ if (idx < areas.length - 1 && !(await keepGoing(profile, started)))
170
+ return;
171
+ }
172
+ // Deep phase: pick where to go deep.
173
+ while (true) {
174
+ const area = await pickArea(topic);
175
+ if (!area)
176
+ return;
177
+ const pr = s.progress[area.id];
178
+ if (!pr.outline) {
179
+ pr.outline = await withSpinner(`Planning your deep dive into ${area.title}...`, () => askJson(profile, outlinePrompt(profile, s, area), validateOutline));
180
+ await saveTopic(topic);
181
+ }
182
+ for (const [i, l] of pr.outline.entries()) {
183
+ if (pr.done.includes(l.id))
184
+ continue;
185
+ console.log(`\n ${t.dim(area.title)} ${bar(pr.done.length, pr.outline.length)}`);
186
+ p.log.message(t.dim(`Goal: ${l.objective}`));
187
+ const doneTitles = pr.outline.filter((x) => pr.done.includes(x.id)).map((x) => x.title);
188
+ await teach({
189
+ profile,
190
+ topic,
191
+ title: l.title,
192
+ file: `deep/${area.id}/${String(i + 1).padStart(2, "0")}-${l.id}.md`,
193
+ load: () => askJson(profile, deepLessonPrompt(profile, s, area, l, doneTitles), validateLesson),
194
+ });
195
+ pr.done.push(l.id);
196
+ await writeIndex(topic);
197
+ await saveTopic(topic);
198
+ if (pr.done.length < pr.outline.length && !(await keepGoing(profile, started)))
199
+ return;
200
+ }
201
+ p.log.success(t.ok(`You finished the deep dive on ${area.title}. 🎉`));
202
+ const more = must(await p.select({ message: "Next?", options: [{ value: true, label: "Pick another area to go deep on" }, { value: false, label: "Stop here" }] }));
203
+ if (!more)
204
+ return;
205
+ }
206
+ }
207
+ async function keepGoing(profile, started) {
208
+ const over = overTime(started, profile);
209
+ const mins = Math.round((Date.now() - started) / 60_000);
210
+ return must(await p.select({
211
+ message: over ? `${t.warn(`${mins} min in`)} — a good place to stop. Spacing sessions beats cramming.` : "Continue?",
212
+ initialValue: !over,
213
+ options: [
214
+ { value: true, label: "Keep going" },
215
+ { value: false, label: "Stop here (progress is saved)" },
216
+ ],
217
+ }));
218
+ }
219
+ async function pickArea(topic) {
220
+ const s = topic.state;
221
+ const areas = s.map.areas;
222
+ const open = areas.filter((a) => (s.progress[a.id].outline?.length ?? 1) > s.progress[a.id].done.length);
223
+ if (!open.length) {
224
+ p.log.success("You've gone deep on every area. Review your cards to keep it fresh with `loops review`.");
225
+ return null;
226
+ }
227
+ const started = open.find((a) => s.progress[a.id].done.length > 0);
228
+ const choice = must(await p.select({
229
+ message: "Which area do you want to go deep on?",
230
+ initialValue: started?.id,
231
+ options: areas.map((a) => {
232
+ const pr = s.progress[a.id];
233
+ const total = pr.outline?.length;
234
+ const finished = total !== undefined && pr.done.length >= total;
235
+ return {
236
+ value: a.id,
237
+ label: `${finished ? t.ok("✓ ") : ""}${a.title}`,
238
+ hint: finished ? "done" : total ? `${pr.done.length}/${total} lessons` : a.prereqs.length ? `after: ${a.prereqs.join(", ")}` : a.summary.slice(0, 50),
239
+ };
240
+ }),
241
+ }));
242
+ return areas.find((a) => a.id === choice);
243
+ }
@@ -0,0 +1,111 @@
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 minutesPerSession = must(await p.select({
102
+ message: "How long is a typical study session?",
103
+ initialValue: d?.minutesPerSession ?? 25,
104
+ options: [10, 15, 25, 45, 60].map((m) => ({ value: m, label: `${m} min` })),
105
+ }));
106
+ const rootDir = must(await p.text({ message: "Where should your learning folders live?", initialValue: d?.rootDir ?? join(homedir(), "Learning").replace(homedir(), "~") }));
107
+ const profile = { name: name.trim(), provider, goal, level, minutesPerSession, styles, order, diagrams, challenge, tone, rootDir };
108
+ await saveProfile(profile);
109
+ p.outro(t.ok(`Profile saved. Nice to meet you, ${profile.name}!`));
110
+ return profile;
111
+ }
@@ -0,0 +1,98 @@
1
+ import * as p from "@clack/prompts";
2
+ import { askJson } from "../llm.js";
3
+ import { gradeLabel, gradePrompt } from "../prompts.js";
4
+ import { schedule, isDue } from "../srs.js";
5
+ import { 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. Returns the questions the learner got wrong. */
15
+ export async function runChecks(checks) {
16
+ const missed = [];
17
+ for (const [i, c] of checks.entries()) {
18
+ const picked = must(await p.select({
19
+ message: `${t.accent(`Q${i + 1}/${checks.length}`)} ${c.q}`,
20
+ options: c.options.map((o, idx) => ({ value: idx, label: o })),
21
+ }));
22
+ if (picked === c.answer)
23
+ p.log.success(t.ok("Correct. ") + t.dim(c.explain));
24
+ else {
25
+ missed.push(c);
26
+ p.log.error(t.bad("Not quite. ") + `Answer: ${c.options[c.answer]}\n ${t.dim(c.explain)}`);
27
+ }
28
+ }
29
+ return missed;
30
+ }
31
+ export function showGrade(g) {
32
+ const color = g.score >= 3 ? t.ok : g.score === 2 ? t.cyan : g.score === 1 ? t.warn : t.bad;
33
+ const lines = [
34
+ `${color(`${"●".repeat(g.score)}${"○".repeat(3 - g.score)} ${gradeLabel(g)}`)}`,
35
+ "",
36
+ g.feedback,
37
+ ...g.right.map((r) => `${t.ok("✓")} ${r}`),
38
+ ...g.missing.map((r) => `${t.warn("△")} missing: ${r}`),
39
+ ...g.misconceptions.map((r) => `${t.bad("✗")} ${r}`),
40
+ ];
41
+ p.note(lines.join("\n"), "Feedback");
42
+ }
43
+ /** Feynman-style teach-back with one optional retry. */
44
+ export async function teachBack(profile, title, prompt, keyPoints) {
45
+ const attempts = [];
46
+ let ask = prompt;
47
+ for (let i = 0; i < 2; i++) {
48
+ const answer = must(await p.text({
49
+ message: `${t.pink("In your own words")} — ${ask}`,
50
+ placeholder: "type your explanation (enter to submit, leave blank to skip)",
51
+ })).trim();
52
+ if (!answer)
53
+ break;
54
+ const grade = await withSpinner("Reading your explanation...", () => askJson(profile, gradePrompt(profile, title, prompt, keyPoints, answer), validateGrade));
55
+ attempts.push({ answer, grade });
56
+ showGrade(grade);
57
+ if (grade.score >= 2 || i === 1)
58
+ break;
59
+ const again = must(await p.select({
60
+ message: "Want another try with the feedback in mind? (struggling first is how it sticks)",
61
+ options: [
62
+ { value: true, label: "Try again" },
63
+ { value: false, label: "Move on" },
64
+ ],
65
+ }));
66
+ if (!again)
67
+ break;
68
+ ask = grade.followUp || prompt;
69
+ }
70
+ return attempts;
71
+ }
72
+ /** Spaced-repetition review: attempt recall, reveal, self-rate. */
73
+ export async function reviewCards(topics, limit = 20) {
74
+ const due = topics.flatMap((tp) => tp.state.cards.filter((c) => isDue(c)).map((card) => ({ tp, card }))).sort(() => Math.random() - 0.5).slice(0, limit);
75
+ let n = 0;
76
+ for (const { tp, card } of due) {
77
+ console.log(t.dim(`\n ${tp.state.title} · ${card.source}`));
78
+ must(await p.text({ message: `${t.accent("Recall")} ${card.front}`, placeholder: "type what you remember (enter to just think)" }));
79
+ p.log.info(`${t.bold("Answer:")} ${card.back}`);
80
+ const rating = must(await p.select({
81
+ message: "How did that go?",
82
+ options: [
83
+ { value: "again", label: t.bad("Again"), hint: "forgot — see it in 10 min" },
84
+ { value: "hard", label: t.warn("Hard"), hint: "got it with effort" },
85
+ { value: "good", label: t.ok("Good") },
86
+ { value: "easy", label: t.cyan("Easy"), hint: "push it further out" },
87
+ { value: "stop", label: "Stop for now" },
88
+ ],
89
+ }));
90
+ if (rating === "stop")
91
+ break;
92
+ const idx = tp.state.cards.findIndex((c) => c.id === card.id);
93
+ tp.state.cards[idx] = schedule(card, rating);
94
+ await saveTopic(tp);
95
+ n++;
96
+ }
97
+ return n;
98
+ }
package/dist/llm.js ADDED
@@ -0,0 +1,76 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ export const TUTOR_SYSTEM = [
6
+ "You are an exceptional tutor grounded in learning science: you activate prior knowledge,",
7
+ "teach in small chunks, use concrete examples and diagrams, test retrieval rather than recognition,",
8
+ "and correct misconceptions precisely. You never pad. You are accurate: if unsure, say so.",
9
+ "You output exactly what is asked for, in the requested format, with no preamble.",
10
+ ].join(" ");
11
+ function run(cmd, args, input, timeoutMs) {
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn(cmd, args, { cwd: tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
14
+ let out = "";
15
+ let err = "";
16
+ const timer = setTimeout(() => {
17
+ child.kill("SIGKILL");
18
+ reject(new Error(`${cmd} timed out after ${timeoutMs / 1000}s`));
19
+ }, timeoutMs);
20
+ child.stdout.on("data", (d) => (out += d));
21
+ child.stderr.on("data", (d) => (err += d));
22
+ child.on("error", (e) => {
23
+ clearTimeout(timer);
24
+ reject(new Error(`Could not run "${cmd}" — is it installed and on your PATH? (${e.message})`));
25
+ });
26
+ child.on("close", (code) => {
27
+ clearTimeout(timer);
28
+ if (code === 0)
29
+ resolve(out);
30
+ else
31
+ reject(new Error(`${cmd} exited with ${code}: ${err.trim().split("\n").slice(-4).join("\n")}`));
32
+ });
33
+ child.stdin.end(input);
34
+ });
35
+ }
36
+ export async function complete(provider, prompt, timeoutMs = 240_000) {
37
+ if (provider === "claude") {
38
+ return run("claude", ["-p", "--tools", "", "--no-session-persistence", "--output-format", "text", "--system-prompt", TUTOR_SYSTEM], prompt, timeoutMs);
39
+ }
40
+ const dir = await mkdtemp(join(tmpdir(), "loops-"));
41
+ const outFile = join(dir, "last.txt");
42
+ try {
43
+ await run("codex", ["exec", "--skip-git-repo-check", "--ephemeral", "-s", "read-only", "-o", outFile, "-"], `${TUTOR_SYSTEM}\n\n${prompt}`, timeoutMs);
44
+ return await readFile(outFile, "utf8");
45
+ }
46
+ finally {
47
+ await rm(dir, { recursive: true, force: true });
48
+ }
49
+ }
50
+ export function extractJson(text) {
51
+ const cleaned = text.replace(/```(?:json)?/gi, "");
52
+ const start = cleaned.indexOf("{");
53
+ const end = cleaned.lastIndexOf("}");
54
+ if (start === -1 || end <= start)
55
+ throw new Error("no JSON object in model output");
56
+ return JSON.parse(cleaned.slice(start, end + 1));
57
+ }
58
+ /** Ask for JSON, validate it, and retry once telling the model what was wrong. */
59
+ export async function askJson(profile, prompt, validate) {
60
+ let lastError = "";
61
+ for (let attempt = 0; attempt < 2; attempt++) {
62
+ const full = attempt === 0 ? prompt : `${prompt}\n\nYour previous reply was rejected: ${lastError}\nReturn ONLY valid JSON this time.`;
63
+ try {
64
+ return validate(extractJson(await complete(profile.provider, full)));
65
+ }
66
+ catch (e) {
67
+ lastError = e.message;
68
+ if (attempt === 1 || /not installed|PATH|timed out/.test(lastError))
69
+ throw new Error(lastError);
70
+ }
71
+ }
72
+ throw new Error(lastError);
73
+ }
74
+ export async function askText(profile, prompt) {
75
+ return (await complete(profile.provider, prompt)).trim();
76
+ }
@@ -0,0 +1,85 @@
1
+ import { join } from "node:path";
2
+ import { writeText } from "./store.js";
3
+ /** 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, "_");
6
+ export function mapMarkdown(topic) {
7
+ const { title, map } = topic.state;
8
+ if (!map)
9
+ return "";
10
+ const mind = [
11
+ "```mermaid",
12
+ "mindmap",
13
+ ` root((${clean(title)}))`,
14
+ ...map.areas.flatMap((a) => [` ${clean(a.title)}`, ...a.concepts.map((c) => ` ${clean(c.name)}`)]),
15
+ "```",
16
+ ].join("\n");
17
+ const edges = map.areas.flatMap((a) => a.prereqs.filter((p) => map.areas.some((x) => x.id === p)).map((p) => ` ${nodeId(p)} --> ${nodeId(a.id)}`));
18
+ const flow = [
19
+ "```mermaid",
20
+ "flowchart LR",
21
+ ...map.areas.map((a) => ` ${nodeId(a.id)}["${clean(a.title)}"]`),
22
+ ...edges,
23
+ "```",
24
+ ].join("\n");
25
+ return `# ${title} — map
26
+
27
+ ${map.overview}
28
+
29
+ ## The territory
30
+
31
+ ${mind}
32
+
33
+ ## Suggested order
34
+
35
+ Arrows mean "understand this first".
36
+
37
+ ${flow}
38
+
39
+ ## Areas
40
+
41
+ ${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
+ }
44
+ export function lessonMarkdown(title, lesson, own, missed) {
45
+ const checks = lesson.checks
46
+ .map((c, i) => `${i + 1}. ${c.q}\n${c.options.map((o, j) => ` - ${j === c.answer ? "✅" : "▫️"} ${o}`).join("\n")}\n - *${c.explain}*`)
47
+ .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`
52
+ : "";
53
+ return `# ${title}
54
+
55
+ ${lesson.markdown}
56
+
57
+ ## Key points
58
+
59
+ ${lesson.keyPoints.map((k) => `- ${k}`).join("\n")}
60
+
61
+ ## Check yourself
62
+
63
+ ${checks}
64
+ ${missed?.length ? `\n**Still shaky:** ${missed.join("; ")}\n` : ""}${ownBlock}
65
+ ## Flashcards
66
+
67
+ ${lesson.cards.map((c) => `- **${c.front}** → ${c.back}`).join("\n")}
68
+ `;
69
+ }
70
+ export async function writeIndex(topic) {
71
+ const { state } = topic;
72
+ const lines = [`# ${state.title}`, "", state.map?.overview ?? "", "", "- [Map of the territory](map.md)", ""];
73
+ if (state.map) {
74
+ lines.push("## Progress", "");
75
+ for (const a of state.map.areas) {
76
+ const pr = state.progress[a.id];
77
+ const done = pr?.done.length ?? 0;
78
+ const total = pr?.outline?.length ?? 0;
79
+ const surveyed = pr?.surveyed ? "✅" : "⬜";
80
+ lines.push(`- ${surveyed} **${a.title}** — survey: [overview](survey/${a.id}.md)${total ? ` · deep dive ${done}/${total}: ${(pr.outline ?? []).map((l, i) => (pr.done.includes(l.id) ? `[${l.title}](deep/${a.id}/${String(i + 1).padStart(2, "0")}-${l.id}.md)` : l.title)).join(", ")}` : ""}`);
81
+ }
82
+ }
83
+ lines.push("", `_${state.cards.length} flashcards tracked in topic.json_`, "");
84
+ await writeText(join(topic.dir, "README.md"), lines.join("\n"));
85
+ }
@@ -0,0 +1,73 @@
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
+ ].join(" ");
7
+ }
8
+ 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.`;
10
+ 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
+ export function mapPrompt(p, topic, prior) {
12
+ return `${learnerBlurb(p)}
13
+ Create a map of the whole territory of "${topic}" for this learner. Their stated prior knowledge: ${prior || "none stated"}.
14
+ Cover the field broadly: 5-9 areas, ordered so earlier areas support later ones. Each area has 3-6 concepts.
15
+ Return ONLY JSON:
16
+ {"overview":"2-3 sentence orientation: what this field is and why it matters",
17
+ "areas":[{"id":"short-kebab-id","title":"...","summary":"1-2 sentences","prereqs":["ids of areas that should come first"],"concepts":[{"name":"...","blurb":"one line"}]}]}`;
18
+ }
19
+ const lessonShape = `{"primer":"one open question that makes the learner GUESS before reading (no jargon they haven't seen)",
20
+ "markdown":"the lesson body in markdown",
21
+ "checks":[{"q":"...","options":["A","B","C","D"],"answer":0,"explain":"why the answer is right and the best distractor is wrong"}],
22
+ "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
+ "keyPoints":["3-5 points a correct own-words explanation should contain"],
24
+ "cards":[{"front":"recall question","back":"concise answer"}]}`;
25
+ export function surveyLessonPrompt(p, s, area) {
26
+ 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}).
28
+ Concepts to touch: ${area.concepts.map((c) => c.name).join(", ")}.
29
+ 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
+ Use the learner's preferred styles (${p.styles.join(", ") || "any"}). ${diagramRule(p)} ${MERMAID_RULES}
31
+ 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
+ Return ONLY JSON of this shape:
33
+ ${lessonShape}`;
34
+ }
35
+ export function outlinePrompt(p, s, area) {
36
+ return `${learnerBlurb(p)}
37
+ Topic: "${s.title}". Plan a DEEP DIVE into "${area.title}" (${area.summary}); concepts: ${area.concepts.map((c) => c.name).join(", ")}.
38
+ 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.
39
+ Return ONLY JSON: {"lessons":[{"id":"short-kebab-id","title":"...","objective":"By the end you can ..."}]}`;
40
+ }
41
+ export function deepLessonPrompt(p, s, area, lesson, doneTitles) {
42
+ return `${learnerBlurb(p)}
43
+ Topic: "${s.title}", area "${area.title}". DEEP lesson: "${lesson.title}" — objective: ${lesson.objective}.
44
+ Already covered in this area: ${doneTitles.join("; ") || "nothing yet"}. Build on that; connect back to it explicitly.
45
+ 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}
47
+ 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}`;
50
+ }
51
+ export function reexplainPrompt(p, topic, lessonTitle, original) {
52
+ 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}
54
+ Original explanation for reference:
55
+ ${original}
56
+ Return only the markdown.`;
57
+ }
58
+ export function gradePrompt(p, lessonTitle, prompt, keyPoints, answer) {
59
+ const tone = { encouraging: "warm and specific", direct: "blunt and efficient", socratic: "mostly questions that lead them to see gaps themselves" }[p.tone];
60
+ return `Grade a learner's own-words explanation. Lesson: "${lessonTitle}". Prompt given: "${prompt}".
61
+ A strong answer includes: ${keyPoints.map((k) => `- ${k}`).join("\n")}
62
+ Learner's answer: """${answer}"""
63
+ 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.
64
+ 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"}`;
65
+ }
66
+ export function bigPicturePrompt(s) {
67
+ const areas = s.map.areas;
68
+ return {
69
+ 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.`,
70
+ keyPoints: areas.map((a) => `${a.title}: ${a.summary}`),
71
+ };
72
+ }
73
+ export const gradeLabel = (g) => ["Not yet", "Getting there", "Mostly there", "Solid"][g.score];
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/store.js ADDED
@@ -0,0 +1,80 @@
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
+ };
77
+ const topic = { dir, state };
78
+ await saveTopic(topic);
79
+ return topic;
80
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/ui.js ADDED
@@ -0,0 +1,99 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
4
+ const c256 = (n) => (s) => (useColor ? `\x1b[38;5;${n}m${s}\x1b[39m` : s);
5
+ export const t = {
6
+ accent: c256(141),
7
+ pink: c256(213),
8
+ cyan: c256(87),
9
+ ok: c256(78),
10
+ warn: c256(215),
11
+ bad: c256(203),
12
+ dim: pc.dim,
13
+ bold: pc.bold,
14
+ italic: pc.italic,
15
+ };
16
+ const LOGO = [
17
+ " ██╗ ██████╗ ██████╗ ██████╗ ███████╗",
18
+ " ██║ ██╔═══██╗██╔═══██╗██╔══██╗██╔════╝",
19
+ " ██║ ██║ ██║██║ ██║██████╔╝███████╗",
20
+ " ██║ ██║ ██║██║ ██║██╔═══╝ ╚════██║",
21
+ " ███████╗╚██████╔╝╚██████╔╝██║ ███████║",
22
+ " ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝",
23
+ ];
24
+ const GRADIENT = [93, 99, 105, 141, 177, 213];
25
+ export function banner(subtitle = "learn anything, properly") {
26
+ console.log();
27
+ LOGO.forEach((line, i) => console.log(c256(GRADIENT[i])(line)));
28
+ console.log(t.dim(` ${subtitle}\n`));
29
+ }
30
+ /** Unwrap a clack prompt result; exit cleanly on Ctrl+C. */
31
+ export function must(value) {
32
+ if (p.isCancel(value)) {
33
+ p.cancel("Progress saved. See you next time ✦");
34
+ process.exit(0);
35
+ }
36
+ return value;
37
+ }
38
+ export async function withSpinner(label, fn) {
39
+ const s = p.spinner();
40
+ s.start(label);
41
+ try {
42
+ const r = await fn();
43
+ s.stop(t.ok("✓ ") + label.replace(/\.\.\.$/, ""));
44
+ return r;
45
+ }
46
+ catch (e) {
47
+ s.stop(t.bad("✗ ") + label);
48
+ throw e;
49
+ }
50
+ }
51
+ export function bar(done, total, width = 20) {
52
+ const filled = total === 0 ? 0 : Math.round((done / total) * width);
53
+ return t.accent("█".repeat(filled)) + t.dim("░".repeat(width - filled)) + t.dim(` ${done}/${total}`);
54
+ }
55
+ /** Lightweight terminal markdown renderer. Mermaid blocks are summarised, not drawn. */
56
+ export function renderMd(md) {
57
+ const out = [];
58
+ let fence = null;
59
+ const inline = (s) => s
60
+ .replace(/\*\*(.+?)\*\*/g, (_, x) => t.bold(x))
61
+ .replace(/`([^`]+)`/g, (_, x) => t.cyan(x))
62
+ .replace(/(?<![*\w])\*([^*]+)\*(?!\w)/g, (_, x) => t.italic(x));
63
+ for (const raw of md.split("\n")) {
64
+ const fm = raw.match(/^```(\w*)/);
65
+ if (fm) {
66
+ if (fence === null) {
67
+ fence = fm[1] || "code";
68
+ if (fence === "mermaid")
69
+ out.push(" " + t.pink("◆ diagram ") + t.dim("(rendered in the markdown file)"));
70
+ }
71
+ else
72
+ fence = null;
73
+ continue;
74
+ }
75
+ if (fence === "mermaid")
76
+ continue;
77
+ if (fence !== null) {
78
+ out.push(" " + t.dim("│ ") + t.cyan(raw));
79
+ continue;
80
+ }
81
+ const h = raw.match(/^(#{1,4})\s+(.*)/);
82
+ if (h) {
83
+ out.push("", " " + (h[1].length <= 2 ? t.bold(t.accent(h[2])) : t.bold(h[2])));
84
+ continue;
85
+ }
86
+ const li = raw.match(/^(\s*)[-*]\s+(.*)/);
87
+ if (li) {
88
+ out.push(" " + li[1] + t.accent("• ") + inline(li[2]));
89
+ continue;
90
+ }
91
+ const q = raw.match(/^>\s?(.*)/);
92
+ if (q) {
93
+ out.push(" " + t.warn("▌ ") + t.italic(inline(q[1])));
94
+ continue;
95
+ }
96
+ out.push(raw.trim() ? " " + inline(raw) : "");
97
+ }
98
+ return out.join("\n");
99
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "learn-loops",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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
+ "picocolors": "^1.1.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^26.6.5",
26
+ "tsx": "^4.23.15",
27
+ "typescript": "^7.0.2"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md"
32
+ ],
33
+ "keywords": [
34
+ "learning",
35
+ "cli",
36
+ "claude-code",
37
+ "codex",
38
+ "mermaid",
39
+ "spaced-repetition",
40
+ "tutor"
41
+ ]
6
42
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.