learn-loops 0.4.1 → 0.4.3

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 CHANGED
@@ -7,7 +7,7 @@ npm i -g learn-loops
7
7
  loops
8
8
  ```
9
9
 
10
- Requires Node 20+ and either the `claude` or `codex` CLI installed and logged in. If `loops` isn't found after install, add npm's global bin folder to your `PATH` (`npm prefix -g`, then `<that>/bin`).
10
+ Requires Node 20+ and either the `claude` or `codex` CLI installed and logged in (`npm i -g @anthropic-ai/claude-code` or `npm i -g @openai/codex`). If neither is found, `loops` says so and shows how to install one; if your saved tutor is missing but the other is present, it falls back to that one. If `loops` isn't found after install, add npm's global bin folder to your `PATH` (`npm prefix -g`, then `<that>/bin`).
11
11
 
12
12
  ## How it teaches
13
13
 
@@ -61,4 +61,10 @@ loops profile edit your learner profile
61
61
  ```
62
62
 
63
63
  Your profile and streak live in `~/.config/loops/`.
64
- Env: `LOOPS_HOME`, `LOOPS_PROVIDER=claude|codex`, `LOOPS_IMAGES`, `NO_COLOR`.
64
+ ### Billing safety
65
+
66
+ `loops` runs the CLIs under your subscription login and strips `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` and similar variables from their environment, so you aren't billed per token by accident. A CLI signed in with an API key (or Bedrock/Vertex) is refused unless you set `LOOPS_ALLOW_API_BILLING=1`. If your tutor fails (usage limit, outage), `loops` automatically tries the other one.
67
+
68
+ It **can't** see your account's "extra usage" (Claude) or credits (ChatGPT/Codex) settings, because neither CLI exposes them. If you don't want overage charges, turn those off in your account settings.
69
+
70
+ Env: `LOOPS_HOME`, `LOOPS_PROVIDER=claude|codex`, `LOOPS_ALLOW_API_BILLING=1`, `LOOPS_IMAGES`, `NO_COLOR`.
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import * as p from "@clack/prompts";
3
3
  import { listTopics, loadProfile, configDir } from "./store.js";
4
+ import { PROVIDER_HELP, allStatuses, noProviderMessage, setNotifier } from "./llm.js";
4
5
  import { isDue } from "./srs.js";
5
6
  import { currentStreak, doneToday, loadStats, recordActivity, saveCheckin } from "./stats.js";
6
7
  import { banner, bar, must, t } from "./ui.js";
@@ -21,7 +22,7 @@ const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as
21
22
  loops profile edit how you like to learn
22
23
  loops --help this help
23
24
 
24
- Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, NO_COLOR`;
25
+ Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, LOOPS_ALLOW_API_BILLING=1 (allow pay-per-token logins), NO_COLOR`;
25
26
  const dueCount = (topics) => topics.reduce((n, tp) => n + tp.state.cards.filter((c) => isDue(c)).length, 0);
26
27
  function progressOf(tp) {
27
28
  const areas = tp.state.map?.areas ?? [];
@@ -231,11 +232,27 @@ async function main() {
231
232
  process.exit(1);
232
233
  }
233
234
  banner();
235
+ setNotifier((m) => p.log.warn(m));
236
+ const statuses = await allStatuses();
237
+ const usable = statuses.filter((x) => x.usable);
238
+ if (!usable.length) {
239
+ const installed = statuses.filter((x) => x.installed);
240
+ p.log.error(t.bad(installed.length ? `No usable tutor:\n${installed.map((x) => ` - ${x.reason}`).join("\n")}` : noProviderMessage()));
241
+ process.exit(1);
242
+ }
243
+ for (const x of statuses)
244
+ if (x.installed && !x.usable)
245
+ p.log.warn(x.reason);
234
246
  let profile = await loadProfile();
235
247
  if (!profile)
236
248
  profile = await onboard();
237
249
  if (process.env.LOOPS_PROVIDER === "claude" || process.env.LOOPS_PROVIDER === "codex")
238
250
  profile.provider = process.env.LOOPS_PROVIDER;
251
+ if (!usable.some((x) => x.name === profile.provider)) {
252
+ const fallback = usable[0].name;
253
+ p.log.warn(`Using ${PROVIDER_HELP[fallback].label} for this session instead of ${PROVIDER_HELP[profile.provider].label}.`);
254
+ profile.provider = fallback;
255
+ }
239
256
  if (!cmd)
240
257
  return menu(profile);
241
258
  if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map"].includes(cmd))
package/dist/diagram.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { renderMermaidASCII } from "beautiful-mermaid";
2
2
  const color = !!process.stdout.isTTY && !process.env.NO_COLOR;
3
+ // beautiful-mermaid hardcodes near-black label text, unreadable on dark terminals. Render labels in a
4
+ // sentinel colour and swap it for the terminal's default foreground; lines use a mid-grey legible on both.
5
+ const SENTINEL = "\x1b[38;5;201m";
6
+ const GREY = "#8a8a8a";
7
+ const theme = { fg: "#ff00ff", border: GREY, line: GREY, arrow: GREY, corner: GREY, junction: GREY };
3
8
  const widest = (s) => Math.max(...s.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").length));
4
9
  /** mindmap isn't supported by beautiful-mermaid, so draw it as an indented tree. */
5
10
  function mindmapTree(src) {
@@ -34,7 +39,8 @@ export function renderDiagram(src, cols = process.stdout.columns ?? 100) {
34
39
  return mindmapTree(text);
35
40
  const attempt = (s) => {
36
41
  try {
37
- return renderMermaidASCII(s, { colorMode: color ? "ansi256" : "none", paddingY: 2, paddingX: 4 });
42
+ const out = renderMermaidASCII(s, { colorMode: color ? "ansi256" : "none", paddingY: 2, paddingX: 4, theme });
43
+ return color ? out.split(SENTINEL).join("\x1b[39m") : out;
38
44
  }
39
45
  catch {
40
46
  return null;
@@ -1,25 +1,28 @@
1
1
  import * as p from "@clack/prompts";
2
- import { execFileSync } from "node:child_process";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
4
+ import { PROVIDER_HELP, hasBinary as has, installedProviders } from "../llm.js";
5
5
  import { saveProfile } from "../store.js";
6
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
7
  export async function onboard(existing) {
17
8
  p.intro(t.bold(t.accent(existing ? " Edit your learner profile " : " Let's set up how YOU learn ")));
18
- if (!existing)
9
+ if (!existing) {
19
10
  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");
11
+ const found = ["claude", "codex"].map((n) => `${has(n) ? t.ok("✓") : t.dim("✗")} ${PROVIDER_HELP[n].label}`).join(" ");
12
+ p.note([
13
+ `loops has no AI of its own. It looks for Claude Code or Codex on your computer and uses it as your tutor: ${found}`,
14
+ "",
15
+ "To avoid surprise charges:",
16
+ " • It only uses your subscription login. API-key / pay-per-token logins are refused,",
17
+ " and API-key environment variables are hidden from the tutor.",
18
+ " • If one tutor fails (e.g. usage limit), it switches to the other.",
19
+ " • It can't see your account's \"extra usage\" / credits setting. Turn that off in your",
20
+ " Claude or ChatGPT account settings if you don't want to pay beyond your plan.",
21
+ ].join("\n"), "Your tutor & billing");
22
+ }
20
23
  const d = existing;
21
24
  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);
25
+ const installed = installedProviders();
23
26
  const provider = must(await p.select({
24
27
  message: "Which AI tutor should power your lessons?",
25
28
  initialValue: d?.provider ?? installed[0] ?? "claude",
package/dist/llm.js CHANGED
@@ -1,16 +1,59 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { accessSync, constants } from "node:fs";
2
3
  import { mkdtemp, readFile, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
+ import { delimiter, join } from "node:path";
5
6
  export const TUTOR_SYSTEM = [
6
7
  "You are an exceptional tutor grounded in learning science: you activate prior knowledge,",
7
8
  "teach in small chunks, use concrete examples and diagrams, test retrieval rather than recognition,",
8
9
  "and correct misconceptions precisely. You never pad. You are accurate: if unsure, say so.",
9
10
  "You output exactly what is asked for, in the requested format, with no preamble.",
10
11
  ].join(" ");
11
- function run(cmd, args, input, timeoutMs) {
12
+ export const PROVIDER_HELP = {
13
+ claude: { label: "Claude Code", install: "npm install -g @anthropic-ai/claude-code", login: "claude" },
14
+ codex: { label: "Codex CLI", install: "npm install -g @openai/codex", login: "codex login" },
15
+ };
16
+ /** True if `bin` is an executable file in some PATH directory (honours PATHEXT on Windows). */
17
+ export function hasBinary(bin, env = process.env) {
18
+ const exts = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
19
+ for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
20
+ for (const ext of exts) {
21
+ try {
22
+ accessSync(join(dir, bin + ext), constants.X_OK);
23
+ return true;
24
+ }
25
+ catch { }
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+ export const installedProviders = () => ["claude", "codex"].filter((n) => hasBinary(n));
31
+ export function noProviderMessage() {
32
+ return [
33
+ "loops needs Claude Code or Codex installed to act as your tutor, and found neither on your PATH.",
34
+ "",
35
+ "Install one, sign in, then run loops again:",
36
+ ...Object.keys(PROVIDER_HELP).map((n) => ` ${PROVIDER_HELP[n].label}: ${PROVIDER_HELP[n].install} (then: ${PROVIDER_HELP[n].login})`),
37
+ "",
38
+ "Already installed in a non-standard place? Add its folder to your PATH.",
39
+ "Sign in with a subscription account: loops refuses API-key / pay-per-token logins unless LOOPS_ALLOW_API_BILLING=1.",
40
+ ].join("\n");
41
+ }
42
+ /** Credentials that make a CLI bill per token instead of using the user's subscription login. */
43
+ const METERED_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "OPENAI_API_KEY", "CODEX_API_KEY"];
44
+ export const allowMetered = (env = process.env) => env.LOOPS_ALLOW_API_BILLING === "1";
45
+ /** The environment child CLIs run in: pay-per-token credentials are removed unless the user opted in. */
46
+ export function childEnv(env = process.env) {
47
+ if (allowMetered(env))
48
+ return { ...env };
49
+ const out = { ...env };
50
+ for (const k of METERED_ENV)
51
+ delete out[k];
52
+ return out;
53
+ }
54
+ function run(cmd, args, input, timeoutMs, includeStderr = false) {
12
55
  return new Promise((resolve, reject) => {
13
- const child = spawn(cmd, args, { cwd: tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
56
+ const child = spawn(cmd, args, { cwd: tmpdir(), env: childEnv(), stdio: ["pipe", "pipe", "pipe"] });
14
57
  let out = "";
15
58
  let err = "";
16
59
  const timer = setTimeout(() => {
@@ -21,19 +64,20 @@ function run(cmd, args, input, timeoutMs) {
21
64
  child.stderr.on("data", (d) => (err += d));
22
65
  child.on("error", (e) => {
23
66
  clearTimeout(timer);
24
- reject(new Error(`Could not run "${cmd}" — is it installed and on your PATH? (${e.message})`));
67
+ const help = PROVIDER_HELP[cmd];
68
+ reject(new Error(`Could not run "${cmd}" — is it installed and on your PATH?${help ? ` Install: ${help.install}` : ""} (${e.message})`));
25
69
  });
26
70
  child.on("close", (code) => {
27
71
  clearTimeout(timer);
28
72
  if (code === 0)
29
- resolve(out);
73
+ resolve(includeStderr ? out + err : out);
30
74
  else
31
75
  reject(new Error(`${cmd} exited with ${code}: ${err.trim().split("\n").slice(-4).join("\n")}`));
32
76
  });
33
77
  child.stdin.end(input);
34
78
  });
35
79
  }
36
- export async function complete(provider, prompt, timeoutMs = 240_000) {
80
+ async function completeWith(provider, prompt, timeoutMs) {
37
81
  if (provider === "claude") {
38
82
  return run("claude", ["-p", "--tools", "", "--no-session-persistence", "--output-format", "text", "--system-prompt", TUTOR_SYSTEM], prompt, timeoutMs);
39
83
  }
@@ -47,6 +91,87 @@ export async function complete(provider, prompt, timeoutMs = 240_000) {
47
91
  await rm(dir, { recursive: true, force: true });
48
92
  }
49
93
  }
94
+ /** Classify `claude auth status` JSON. Anything that isn't a first-party subscription login bills per token. */
95
+ export function classifyClaude(raw) {
96
+ let j;
97
+ try {
98
+ j = JSON.parse(raw);
99
+ }
100
+ catch {
101
+ return "unknown";
102
+ }
103
+ if (j.loggedIn === false)
104
+ return "logged-out";
105
+ const method = String(j.authMethod ?? "");
106
+ if ((j.apiProvider && j.apiProvider !== "firstParty") || /api.?key|console/i.test(method))
107
+ return "metered";
108
+ return /claude\.ai|oauth|subscription/i.test(method) ? "subscription" : "unknown";
109
+ }
110
+ /** Classify `codex login status` output. */
111
+ export function classifyCodex(text) {
112
+ if (/not logged in/i.test(text))
113
+ return "logged-out";
114
+ if (/api key/i.test(text))
115
+ return "metered";
116
+ return /chatgpt/i.test(text) ? "subscription" : "unknown";
117
+ }
118
+ async function probe(name) {
119
+ const label = PROVIDER_HELP[name].label;
120
+ const bad = (billing, reason) => ({ name, installed: hasBinary(name), billing, usable: false, reason });
121
+ if (!hasBinary(name))
122
+ return bad("unknown", `${label} isn't installed (${PROVIDER_HELP[name].install})`);
123
+ let billing;
124
+ try {
125
+ billing = name === "claude" ? classifyClaude(await run("claude", ["auth", "status"], "", 20_000)) : classifyCodex(await run("codex", ["login", "status"], "", 20_000, true));
126
+ }
127
+ catch {
128
+ billing = "logged-out";
129
+ }
130
+ if (billing === "logged-out")
131
+ return bad(billing, `${label} isn't signed in (run: ${PROVIDER_HELP[name].login})`);
132
+ if (billing === "metered" && !allowMetered()) {
133
+ return bad(billing, `${label} is signed in with an API key / pay-per-token account, which would charge you per request. Sign in with your subscription instead, or set LOOPS_ALLOW_API_BILLING=1 to allow it`);
134
+ }
135
+ return { name, installed: true, billing, usable: true, reason: "" };
136
+ }
137
+ const statusCache = new Map();
138
+ export const providerStatus = (name) => {
139
+ if (!statusCache.has(name))
140
+ statusCache.set(name, probe(name));
141
+ return statusCache.get(name);
142
+ };
143
+ export const allStatuses = () => Promise.all(["claude", "codex"].map(providerStatus));
144
+ let notify = (m) => console.error(m);
145
+ export const setNotifier = (fn) => (notify = fn);
146
+ let sticky;
147
+ /**
148
+ * Run the prompt on the preferred tutor; if it can't be used or fails (usage limit, outage, signed out), fall back
149
+ * to the other one — but never to a CLI that would bill per token. Once a fallback works it's kept for the session.
150
+ */
151
+ export async function complete(preferred, prompt, timeoutMs = 240_000) {
152
+ const first = sticky ?? preferred;
153
+ const order = [first, first === "claude" ? "codex" : "claude"];
154
+ const problems = [];
155
+ for (const name of order) {
156
+ const st = await providerStatus(name);
157
+ if (!st.usable) {
158
+ problems.push(st.reason);
159
+ continue;
160
+ }
161
+ if (name !== first)
162
+ notify(`${PROVIDER_HELP[order[0]].label} didn't work, so I'm trying ${PROVIDER_HELP[name].label} instead.`);
163
+ try {
164
+ const out = await completeWith(name, prompt, timeoutMs);
165
+ if (name !== preferred)
166
+ sticky = name;
167
+ return out;
168
+ }
169
+ catch (e) {
170
+ problems.push(`${PROVIDER_HELP[name].label}: ${e.message}`);
171
+ }
172
+ }
173
+ throw new Error(problems.join("\n"));
174
+ }
50
175
  /**
51
176
  * Models often forget to double LaTeX backslashes inside JSON strings. `\theta` would silently parse as a
52
177
  * tab + "heta", `\nabla` as newline + "abla", `\frac` as form feed + "rac". Double the backslash of any
package/dist/prompts.js CHANGED
@@ -20,6 +20,12 @@ const STEM_RULES = `Math and science content:
20
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
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
22
  - Use mermaid only for processes, hierarchies and state/flow structure. Add equations or figures only where they genuinely clarify; never decorate.`;
23
+ const QUESTION_RULES = `Question precision (every question you write, including multiple-choice checks, flashcard fronts, predictions and follow-ups):
24
+ - Each question must have exactly one defensible reading. Name the specific thing being asked about; never use bare "it", "this" or "the system" without a clear referent, and never lean on context the learner cannot see.
25
+ - State any assumptions, conditions or scope the answer depends on inside the question itself (e.g. "assuming a single thread", "in the ideal case").
26
+ - Ask for one thing at a time, and say what form the answer takes (which, why, how many, what happens next).
27
+ - For multiple-choice: exactly one option is correct, the others are clearly wrong on a strict reading, and no option is a matter of opinion or "it depends". Avoid "all/none of the above" and vague words like "usually", "best" or "better" unless the question defines the criterion.
28
+ - Open-ended questions may have many good answers, but the task must still be unambiguous about what to address.`;
23
29
  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
30
  export function mapPrompt(p, topic, prior) {
25
31
  return `${learnerBlurb(p)}
@@ -47,6 +53,7 @@ Concepts to touch: ${area.concepts.map((c) => c.name).join(", ")}.
47
53
  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
54
  Use the learner's preferred styles (${p.styles.join(", ") || "any"}). ${diagramRule(p)} ${MERMAID_RULES}
49
55
  ${STEM_RULES}
56
+ ${QUESTION_RULES}
50
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.
51
58
  Return ONLY JSON of this shape:
52
59
  ${lessonShape}`;
@@ -64,6 +71,7 @@ Already covered in this area: ${doneTitles.join("; ") || "nothing yet"}. Build o
64
71
  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
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}
66
73
  ${STEM_RULES}
74
+ ${QUESTION_RULES}
67
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.
68
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." : ""}
69
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" : ""}.
@@ -102,13 +110,14 @@ export const PERSONAS = [
102
110
  export function personaPrompt(p, persona, title, keyPoints, answer) {
103
111
  return `Role-play ${persona.label} (${persona.voice}) who just heard the learner explain "${title}". Their explanation: """${answer}"""
104
112
  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.
113
+ 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. The question must still be unambiguous: one specific thing asked, with any needed context stated.
106
114
  Return ONLY JSON: {"reaction":"...","question":"..."}`;
107
115
  }
108
116
  export function diagnoseQuestionsPrompt(p, title, missed, explanation) {
109
117
  return `${learnerBlurb(p)}
110
118
  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
119
  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.
120
+ ${QUESTION_RULES}
112
121
  Return ONLY JSON: {"questions":["...","..."]}`;
113
122
  }
114
123
  export function diagnoseResultPrompt(p, title, qa, missed) {
@@ -117,6 +126,7 @@ Lesson "${title}". Missed questions: ${missed.join("; ") || "n/a"}.
117
126
  Diagnostic Q&A:
118
127
  ${qa.map((x) => `Q: ${x.q}\nA: ${x.a || "(no answer)"}`).join("\n")}
119
128
  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).
129
+ ${QUESTION_RULES}
120
130
  Return ONLY JSON: {"misconception":"one sentence","reteach":"markdown","retest":{"q":"...","options":["","","",""],"answer":0,"explain":"..."}}`;
121
131
  }
122
132
  export const LENSES = [
@@ -131,6 +141,7 @@ export function deeperPrompt(p, s, title, lens, earlier) {
131
141
  return `${learnerBlurb(p)} ${useBlurb(s)}
132
142
  Topic "${s.title}", lesson "${title}". Pose ONE open-ended "think deeper" question through the lens "${lens.label}": ${lens.ask}
133
143
  Make it concrete (a specific situation, not abstract). Do not repeat earlier questions: ${earlier.join(" | ") || "none"}.
144
+ ${QUESTION_RULES}
134
145
  Return ONLY JSON: {"question":"...","considerations":["4-5 things a thoughtful answer would raise"]}`;
135
146
  }
136
147
  export function critiquePrompt(p, title, question, considerations, answer) {
@@ -145,5 +156,6 @@ export function challengePrompt(p, s, area, covered) {
145
156
  return `${learnerBlurb(p)} ${useBlurb(s)}
146
157
  The learner just finished a deep dive into "${area.title}" (topic "${s.title}"), covering: ${covered.join("; ")}.
147
158
  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"}.
159
+ ${QUESTION_RULES}
148
160
  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
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "learn-loops",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "A learning CLI that uses Claude Code / Codex as your tutor",
5
5
  "type": "module",
6
6
  "bin": {