learn-loops 0.4.3 → 0.4.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import * as p from "@clack/prompts";
3
- import { listTopics, loadProfile, configDir } from "./store.js";
3
+ import { listTopics, loadProfile, configDir, deleteTopic } from "./store.js";
4
4
  import { PROVIDER_HELP, allStatuses, noProviderMessage, setNotifier } from "./llm.js";
5
5
  import { isDue } from "./srs.js";
6
6
  import { currentStreak, doneToday, loadStats, recordActivity, saveCheckin } from "./stats.js";
@@ -19,9 +19,12 @@ const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as
19
19
  loops streak your streak
20
20
  loops map show a topic's map and prerequisite diagram
21
21
  loops topics list your topics and progress
22
+ loops delete delete a topic and all its notes and progress
22
23
  loops profile edit how you like to learn
23
24
  loops --help this help
24
25
 
26
+ To delete a course: run ${t.bold("loops delete")}, or choose "Delete a topic" from the menu.
27
+
25
28
  Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, LOOPS_ALLOW_API_BILLING=1 (allow pay-per-token logins), NO_COLOR`;
26
29
  const dueCount = (topics) => topics.reduce((n, tp) => n + tp.state.cards.filter((c) => isDue(c)).length, 0);
27
30
  function progressOf(tp) {
@@ -102,12 +105,15 @@ async function startScreen(profile, topics, rec) {
102
105
  }),
103
106
  { value: "__new", label: t.accent("+ Start something new") },
104
107
  { value: "__menu", label: t.dim("Go to the main menu") },
108
+ { value: "__delete", label: t.dim("Delete a topic"), hint: "or run `loops delete`" },
105
109
  ],
106
110
  }));
107
111
  if (choice === "__menu")
108
112
  return;
109
113
  if (choice === "__new")
110
114
  return dispatch("new", profile);
115
+ if (choice === "__delete")
116
+ return dispatch("delete", profile);
111
117
  const tp = recent[Number(choice)];
112
118
  if (tp.state.map && tp.state.phase === "survey" && !tp.state.progress[tp.state.map.areas[0].id]?.surveyed)
113
119
  printMap(tp);
@@ -134,7 +140,7 @@ async function menu(profile) {
134
140
  ...(due ? [{ value: "review", label: "Review flashcards", hint: `${due} due${star("review")}` }] : []),
135
141
  ...(cards >= 4 ? [{ value: "quiz", label: "Mixed quiz", hint: `interleaved, weakest first${star("quiz")}` }] : []),
136
142
  ...(topics.some((tp) => tp.state.mistakes?.length) ? [{ value: "mistakes", label: "Mistake journal" }] : []),
137
- ...(topics.length ? [{ value: "topics", label: "My topics" }] : []),
143
+ ...(topics.length ? [{ value: "topics", label: "My topics" }, { value: "delete", label: "Delete a topic" }] : []),
138
144
  ];
139
145
  // put the recommendation first
140
146
  opts.sort((a, b) => Number(b.value === rec) - Number(a.value === rec));
@@ -216,6 +222,17 @@ async function dispatch(action, profile, arg) {
216
222
  printMap(tp);
217
223
  break;
218
224
  }
225
+ case "delete": {
226
+ if (!topics.length)
227
+ return p.log.warn("No topics to delete.");
228
+ const tp = arg ? topics.find((x) => x.state.title.toLowerCase() === arg.toLowerCase()) ?? (await chooseTopic(topics, "Which topic?")) : await chooseTopic(topics, "Which topic do you want to delete?");
229
+ const ok = must(await p.confirm({ message: `Delete "${tp.state.title}" and all its notes, cards and progress? This can't be undone.`, initialValue: false }));
230
+ if (!ok)
231
+ return p.log.info("Kept it.");
232
+ await deleteTopic(tp);
233
+ p.log.success(t.ok(`Deleted "${tp.state.title}".`));
234
+ break;
235
+ }
219
236
  case "profile":
220
237
  await onboard(profile);
221
238
  break;
@@ -255,7 +272,7 @@ async function main() {
255
272
  }
256
273
  if (!cmd)
257
274
  return menu(profile);
258
- if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map"].includes(cmd))
275
+ if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map", "delete"].includes(cmd))
259
276
  return dispatch(cmd, profile, rest.join(" ") || undefined);
260
277
  console.log(HELP);
261
278
  process.exit(1);
@@ -184,9 +184,11 @@ async function teach(ctx) {
184
184
  }
185
185
  // 7. Think deeper: trade-offs, failure modes, second-order effects.
186
186
  if (ctx.deep) {
187
- const rounds = await thinkDeeper(profile, topic.state, ctx.title, lesson.deeper);
188
- if (rounds.length)
189
- extras.deeper = rounds;
187
+ if (lesson.deeper) {
188
+ const rounds = await thinkDeeper(profile, topic.state, ctx.title, lesson.deeper);
189
+ if (rounds.length)
190
+ extras.deeper = rounds;
191
+ }
190
192
  // 8. Personal connection.
191
193
  const conn = must(await p.text({ message: `${t.pink("Where could you use this?")}`, placeholder: "a project, a decision, something you've seen (blank to skip)" })).trim();
192
194
  if (conn) {
@@ -12,7 +12,7 @@ function showCritique(c) {
12
12
  * Trade-offs, failure modes, scale and second-order effects. Round one uses the lesson's own question;
13
13
  * further rounds rotate through different lenses so the learner practises seeing a topic from many sides.
14
14
  */
15
- export async function thinkDeeper(profile, s, title, seed, maxRounds = 3) {
15
+ export async function thinkDeeper(profile, s, title, seed, maxRounds = 2) {
16
16
  const rounds = [];
17
17
  let lensIdx = Math.floor(Math.random() * LENSES.length);
18
18
  let current = seed ? { lens: "Think deeper", ...seed } : undefined;
package/dist/prompts.js CHANGED
@@ -54,7 +54,7 @@ Rules: max ~250 words in markdown. ${p.order === "examples-first" ? "Open with a
54
54
  Use the learner's preferred styles (${p.styles.join(", ") || "any"}). ${diagramRule(p)} ${MERMAID_RULES}
55
55
  ${STEM_RULES}
56
56
  ${QUESTION_RULES}
57
- 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.
57
+ Exactly 3 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) in rising difficulty: 1 easy (recall the core idea), 1 medium (apply it to a simple case), 1 hard (a subtle case or a common misconception). Order them easy to hard. 2-3 flashcards.
58
58
  Return ONLY JSON of this shape:
59
59
  ${lessonShape}`;
60
60
  }
@@ -72,9 +72,9 @@ Rules: 400-650 words of markdown with ## subheadings. ${p.order === "examples-fi
72
72
  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}
73
73
  ${STEM_RULES}
74
74
  ${QUESTION_RULES}
75
- 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.
75
+ Challenge level: ${p.challenge}. Exactly ${p.challenge === "tough" ? 5 : p.challenge === "gentle" ? 3 : 4} multiple-choice checks that test understanding/application, not trivia (4 options, "answer" is 0-based), ordered easy to hard: at least one easy recall check, mostly medium application checks, and at least one hard check (subtle case or misconception). 3-4 flashcards phrased as recall questions.
76
76
  ${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." : ""}
77
- 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" : ""}.
77
+ Also include the "prediction" (a situation where a wrong intuition is likely, so they learn from guessing wrong). Include the "deeper" trade-off question ONLY if this lesson has a genuine judgement call, trade-off or failure mode worth debating; for foundational or purely procedural lessons set "deeper" to null (roughly half of lessons should have none)${p.socratic ? ", and exactly 3 \"socratic\" questions" : ""}.
78
78
  Return ONLY JSON of this shape (omit the /* */ comment):
79
79
  ${lessonShapeBase}${deepExtras(!!p.socratic)}`;
80
80
  }
@@ -150,6 +150,7 @@ export function critiquePrompt(p, title, question, considerations, answer) {
150
150
  A thoughtful answer would raise: ${considerations.map((c) => `- ${c}`).join("\n")}
151
151
  Learner's answer: """${answer}"""
152
152
  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}.
153
+ Be proportionate, not pedantic: list at most 2 blind spots and only ones that would change their conclusion; skip nitpicks, wording quibbles and edge cases that don't matter. If the answer is solid, say so and leave "blindSpots" empty and "pushback" an empty string.
153
154
  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"}`;
154
155
  }
155
156
  export function challengePrompt(p, s, area, covered) {
package/dist/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile, readdir, rm } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, dirname } from "node:path";
@@ -54,6 +54,13 @@ export async function listTopics(profile) {
54
54
  return topics.sort((a, b) => lastTouched(b).localeCompare(lastTouched(a)));
55
55
  }
56
56
  const lastTouched = (t) => t.state.cards.map((c) => c.due).sort().pop() ?? t.state.createdAt;
57
+ /** Removes a topic's folder (notes, cards, progress) and its category folder if that leaves it empty. */
58
+ export async function deleteTopic(topic) {
59
+ await rm(topic.dir, { recursive: true, force: true });
60
+ const cat = dirname(topic.dir);
61
+ if (!(await readdir(cat).catch(() => ["x"])).length)
62
+ await rm(cat, { recursive: true, force: true });
63
+ }
57
64
  export async function listCategories(profile) {
58
65
  const root = expandHome(profile.rootDir);
59
66
  if (!existsSync(root))
package/dist/table.js ADDED
@@ -0,0 +1,50 @@
1
+ import Table from "cli-table3";
2
+ export const isTableLine = (s) => /^\s*\|.*\|\s*$/.test(s);
3
+ const ANSI = /\x1b\[[0-9;]*m/g;
4
+ const visible = (s) => s.replace(ANSI, "").length;
5
+ function splitRow(line) {
6
+ const body = line.trim().replace(/^\|/, "").replace(/\|$/, "");
7
+ return body.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, "|").trim());
8
+ }
9
+ const alignOf = (c) => (/^:-+:$/.test(c) ? "center" : /^-+:$/.test(c) ? "right" : "left");
10
+ /**
11
+ * Draw a markdown table (consecutive `| a | b |` lines) as a bordered, aligned box that wraps to maxWidth.
12
+ * `cell` styles a cell's text (e.g. inline bold/code/math); `head` styles header cells.
13
+ */
14
+ export function renderTable(lines, cell, head, border, maxWidth) {
15
+ const rows = lines.map(splitRow);
16
+ const hasHeader = rows.length > 1 && rows[1].every((c) => /^:?-+:?$/.test(c));
17
+ const aligns = hasHeader ? rows[1].map(alignOf) : [];
18
+ const data = hasHeader ? [rows[0], ...rows.slice(2)] : rows;
19
+ const n = Math.max(...data.map((r) => r.length));
20
+ const grid = data.map((r, i) => Array.from({ length: n }, (_, c) => (hasHeader && i === 0 ? head : cell)(r[c] ?? "")));
21
+ const widths = Array.from({ length: n }, (_, c) => Math.max(1, ...grid.map((r) => Math.max(...r[c].split("\n").map(visible)))));
22
+ const budget = maxWidth - (3 * n + 1);
23
+ if (budget < 5 * n)
24
+ return stacked(data, hasHeader, cell, head);
25
+ while (widths.reduce((a, b) => a + b, 0) > budget)
26
+ widths[widths.indexOf(Math.max(...widths))]--;
27
+ const table = new Table({
28
+ chars: { top: "─", "top-mid": "┬", "top-left": "╭", "top-right": "╮", bottom: "─", "bottom-mid": "┴", "bottom-left": "╰", "bottom-right": "╯", left: "│", "left-mid": "├", mid: "─", "mid-mid": "┼", right: "│", "right-mid": "┤", middle: "│" },
29
+ style: { head: [], border: [], "padding-left": 1, "padding-right": 1 },
30
+ colWidths: widths.map((w) => w + 2),
31
+ colAligns: Array.from({ length: n }, (_, c) => aligns[c] ?? "left"),
32
+ wordWrap: true,
33
+ });
34
+ grid.forEach((r) => table.push(r));
35
+ let drawn = table.toString().split("\n");
36
+ // keep the rule under the header; drop the ones between single-line rows (they only add noise)
37
+ const wrapped = drawn.length - (grid.length * 2 + 1) > 0;
38
+ if (!wrapped)
39
+ drawn = drawn.filter((l, i) => !l.startsWith("├") || i === drawn.findIndex((x) => x.startsWith("├")));
40
+ return drawn.map((l) => l.replace(/[─│╭╮╰╯├┤┬┴┼]+/g, border));
41
+ }
42
+ /** Fallback for terminals too narrow for a grid: one "header: value" block per row. */
43
+ function stacked(data, hasHeader, cell, head) {
44
+ const out = [];
45
+ const keys = hasHeader ? data[0] : [];
46
+ for (const r of hasHeader ? data.slice(1) : data) {
47
+ out.push(...r.map((v, c) => (keys[c] ? `${head(keys[c])}: ${cell(v)}` : cell(v))), "");
48
+ }
49
+ return out.slice(0, -1);
50
+ }
package/dist/ui.js CHANGED
@@ -3,6 +3,7 @@ import pc from "picocolors";
3
3
  import { renderDiagram } from "./diagram.js";
4
4
  import { parsePlot, plotToBraille, renderPlot, sanitizeSvg, svgCaption } from "./figures.js";
5
5
  import { mathToText, svgToPng, texToLines, texToPng } from "./math.js";
6
+ import { isTableLine, renderTable } from "./table.js";
6
7
  import { drawImage, imageProtocol } from "./termimg.js";
7
8
  const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
8
9
  const c256 = (n) => (s) => (useColor ? `\x1b[38;5;${n}m${s}\x1b[39m` : s);
@@ -112,7 +113,15 @@ export function renderMd(md) {
112
113
  .replace(/\*\*(.+?)\*\*/g, (_, x) => t.bold(x))
113
114
  .replace(/`([^`]+)`/g, (_, x) => t.cyan(x))
114
115
  .replace(/(?<![*\w])\*([^*]+)\*(?!\w)/g, (_, x) => t.italic(x));
116
+ let tableBuf = [];
117
+ const flushTable = () => {
118
+ const cols = process.stdout.columns ?? 100;
119
+ out.push(...renderTable(tableBuf, inline, (x) => t.bold(t.accent(inline(x))), t.dim, cols - 4).map((l) => " " + l));
120
+ tableBuf = [];
121
+ };
115
122
  for (const raw of md.split("\n")) {
123
+ if (tableBuf.length && !isTableLine(raw))
124
+ flushTable();
116
125
  // display math: $$ … $$ (one or many lines) and \[ … \]
117
126
  if (math) {
118
127
  const close = raw.match(/^(.*?)(\$\$|\\\])\s*$/);
@@ -162,6 +171,10 @@ export function renderMd(md) {
162
171
  out.push(" " + t.dim("│ ") + t.cyan(raw));
163
172
  continue;
164
173
  }
174
+ if (isTableLine(raw)) {
175
+ tableBuf.push(raw);
176
+ continue;
177
+ }
165
178
  const h = raw.match(/^(#{1,4})\s+(.*)/);
166
179
  if (h) {
167
180
  out.push("", " " + (h[1].length <= 2 ? t.bold(t.accent(inline(h[2]))) : t.bold(inline(h[2]))));
@@ -179,6 +192,8 @@ export function renderMd(md) {
179
192
  }
180
193
  out.push(raw.trim() ? " " + inline(raw) : "");
181
194
  }
195
+ if (tableBuf.length)
196
+ flushTable();
182
197
  return out.join("\n");
183
198
  }
184
199
  /** Plain-text math for prompts, options and flashcards. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "learn-loops",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "A learning CLI that uses Claude Code / Codex as your tutor",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "@clack/prompts": "^1.8.1",
22
22
  "@resvg/resvg-js": "^2.6.2",
23
23
  "beautiful-mermaid": "^1.1.3",
24
+ "cli-table3": "^0.6.5",
24
25
  "mathjax-full": "^3.2.1",
25
26
  "mathjs": "^15.2.0",
26
27
  "picocolors": "^1.1.1",