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 +20 -3
- package/dist/flows/learn.js +5 -3
- package/dist/flows/think.js +1 -1
- package/dist/prompts.js +4 -3
- package/dist/store.js +8 -1
- package/dist/table.js +50 -0
- package/dist/ui.js +15 -0
- package/package.json +2 -1
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);
|
package/dist/flows/learn.js
CHANGED
|
@@ -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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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) {
|
package/dist/flows/think.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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" ?
|
|
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)
|
|
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
|
+
"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",
|