@bojackduy/opencode-learn 1.4.2 → 1.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/dist/server.js CHANGED
@@ -574,6 +574,27 @@ function releaseOwnerLock(dir, id) {
574
574
  fs.unlinkSync(ownerLockPath(dir, id));
575
575
  } catch {}
576
576
  }
577
+ var PENDING_TTL_MS = 24 * 60 * 60 * 1000;
578
+ function isPendingExpired(j) {
579
+ try {
580
+ const ts = j?.timestamp;
581
+ if (typeof ts !== "number")
582
+ return false;
583
+ return Date.now() - ts > PENDING_TTL_MS;
584
+ } catch {
585
+ return false;
586
+ }
587
+ }
588
+ function archiveExpiredPending(dir, f) {
589
+ try {
590
+ const expDir = path.join(dir, "expired");
591
+ try {
592
+ fs.mkdirSync(expDir, { recursive: true });
593
+ } catch {}
594
+ fs.renameSync(path.join(dir, f), path.join(expDir, `${Date.now()}-${f}`));
595
+ slog("pending expired, archived", f);
596
+ } catch {}
597
+ }
577
598
  var activeWatchers = new Map;
578
599
  function watchAndInject(client, directory, id, sessionID, buildText) {
579
600
  slog("watchAndInject start", id, sessionID);
@@ -1010,6 +1031,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1010
1031
  for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
1011
1032
  try {
1012
1033
  const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
1034
+ if (isPendingExpired(j)) {
1035
+ archiveExpiredPending(dir, f);
1036
+ continue;
1037
+ }
1013
1038
  if (j?.id && j?.sessionID) {
1014
1039
  watchAndInject(client, directory, j.id, j.sessionID, (r) => {
1015
1040
  if (j.type === "quiz") {
@@ -1240,7 +1265,7 @@ Explanation: ${j.explanation}${note}`;
1240
1265
  },
1241
1266
  tool: {
1242
1267
  quiz: tool({
1243
- description: "Ask the user a GRADED question with a known correct answer, then grade and give feedback. Unlike the native `question` tool (which collects preferences with no right answer), `quiz` has a correct answer, marks selection right/wrong, reveals the correct answer, and shows an explanation. The TUI interaction is asynchronous, so call `quiz` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, another `quiz`, `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answer will be injected as a new turn. Only call native `question` after `quiz` if its result explicitly requests the no-TUI fallback. Use to assess understanding before teaching and for retrieval practice after. Options-only: single/multi-select plus auto 'I don't know'. No free-text. For non-graded questions use the native `question` tool.",
1268
+ description: "Ask the user a GRADED question with a known correct answer, then grade and give feedback. Unlike the native `question` tool (which collects preferences with no right answer), `quiz` has a correct answer, marks selection right/wrong, reveals the correct answer, and shows an explanation. The TUI interaction is asynchronous, so call `quiz` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, another `quiz`, `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answer will be injected as a new turn. Never call the native `question` tool for a quiz \u2014 there is no two-step flow. If no popup is available, the quiz result itself contains the question to ask in plain chat text. Use to assess understanding before teaching and for retrieval practice after. Options-only: single/multi-select plus auto 'I don't know'. No free-text. For non-graded questions use the native `question` tool.",
1244
1269
  args: {
1245
1270
  question: tool.schema.string().describe("Single quiz question to ask. Call this tool alone; do not combine it with another user-input tool in the same turn."),
1246
1271
  details: tool.schema.string().optional().describe("Extra context shown under question."),
@@ -1398,20 +1423,15 @@ Explanation: ${eFixed}`;
1398
1423
  return result;
1399
1424
  }
1400
1425
  const instruction = [
1401
- `[quiz ready \u2014 awaiting user answer via \`question\` tool]`,
1426
+ `[quiz \u2014 no popup available, asking directly in chat]`,
1402
1427
  `Question: ${qFixed}`,
1403
1428
  dFixed ? `Details: ${dFixed}` : null,
1404
- `Options (display order, already shuffled):`,
1405
- ...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` \u2014 ${o.description}` : ""} (value="${o.value}")`),
1406
- `Correct indices: ${correctIndices.join(", ")} (Correct values: ${correctStr})`,
1407
- `Explanation (reveal AFTER answer): ${eFixed}`,
1408
- `Mode: ${args.multiSelect ? "multi-select (exact set)" : "single-select"}`,
1429
+ ...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` \u2014 ${o.description}` : ""}`),
1430
+ `0. I don't know`,
1409
1431
  ``,
1410
- `INSTRUCTION FOR LLM: Call the built-in \`question\` tool with:`,
1411
- ` header: "Quiz"`,
1412
- ` question: "${qFixed.replace(/"/g, "\\\"")}"`,
1413
- ` options: [${options.map((o) => `{label:"${o.label.replace(/"/g, "\\\"")}", description:"${(o.description ?? "").replace(/"/g, "\\\"")}"}`).join(", ")}]`,
1414
- `Then compare the user's selected labels to correct indices [${correctIndices.join(", ")}]. Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show \u2713/\u2717, reveal Correct: ${correctStr}, and Explanation. An 'I don't know' maps to dontKnow (genuine gap).`
1432
+ `INSTRUCTION FOR LLM: ask the question above IN YOUR REPLY TEXT, exactly as written (numbered options, ending with the "I don't know" line). Do NOT call the \`question\` tool, another \`quiz\`/\`quiz_batch\`, or any other tool \u2014 just write the question and wait for the user's reply.`,
1433
+ `When they reply, compare their numbers/labels to correct indices [${correctIndices.join(", ")}] (correct: ${correctStr}). Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show \u2713/\u2717, reveal Correct: ${correctStr}, then the explanation below. Treat 0/"I don't know" as a genuine gap, not a guess.`,
1434
+ `Explanation (reveal ONLY after they answer): ${eFixed}`
1415
1435
  ].filter(Boolean).join(`
1416
1436
  `);
1417
1437
  ctx.metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { correctIndices, explanation: eFixed, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } });
@@ -1419,7 +1439,7 @@ Explanation: ${eFixed}`;
1419
1439
  }
1420
1440
  }),
1421
1441
  quiz_batch: tool({
1422
- description: "Batch version of quiz - shows 2-8 graded questions as a deck (Quiz 1/3 to 2/3 to 3/3) in one TUI, then injects one combined answer. The TUI interaction is asynchronous, so call `quiz_batch` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, `quiz`, another `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answers will be injected as a new turn. Use when you want multiple non-adaptive checks in one deck. Each entry has the same schema as quiz.",
1442
+ description: "Batch version of quiz - shows 2-8 graded questions as a deck (Quiz 1/3 to 2/3 to 3/3) in one TUI, then injects one combined answer. The TUI interaction is asynchronous, so call `quiz_batch` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, `quiz`, another `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answers will be injected as a new turn. Never call the native `question` tool for a quiz batch \u2014 there is no two-step flow. If no popup is available, the result itself contains the questions to ask in plain chat text. Use when you want multiple non-adaptive checks in one deck. Each entry has the same schema as quiz.",
1423
1443
  args: {
1424
1444
  quizzes: tool.schema.array(tool.schema.object({
1425
1445
  question: tool.schema.string(),
@@ -1530,8 +1550,28 @@ Explanation: ${eFixed}`;
1530
1550
  slog("quiz_batch watchAndInject armed", id, "alive", isAlive);
1531
1551
  if (isAlive)
1532
1552
  return `[quiz batch displayed in TUI - ${normalized.length} quizzes as deck Quiz 1/${normalized.length} to ${normalized.length}/${normalized.length}. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answers will be injected as a new turn.]`;
1533
- else
1534
- return `[quiz batch stored durably - TUI not alive yet, so it will appear on restart. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answers will be injected after the user completes the deck.]`;
1553
+ const askAll = normalized.map((q, qi) => {
1554
+ const lines = [`Q${qi + 1}/${normalized.length}: ${q.question}`];
1555
+ if (q.details?.trim())
1556
+ lines.push(q.details.trim());
1557
+ q.options.forEach((o, i) => lines.push(`${i + 1}. ${o.label}${o.description ? ` \u2014 ${o.description}` : ""}`));
1558
+ lines.push(`0. I don't know`);
1559
+ lines.push(`(hidden correct indices for grading only: ${(q.correctIndices || []).join(",")})`);
1560
+ return lines.join(`
1561
+ `);
1562
+ }).join(`
1563
+
1564
+ `);
1565
+ const explainAll = normalized.map((q, qi) => `Q${qi + 1} explanation (reveal ONLY after they answer): ${q.explanation}`).join(`
1566
+ `);
1567
+ return [
1568
+ `[quiz batch \u2014 no popup available, asking directly in chat]`,
1569
+ askAll,
1570
+ ``,
1571
+ `INSTRUCTION FOR LLM: ask ALL questions above IN YOUR REPLY TEXT, exactly as written. Do NOT call the \`question\` tool, another \`quiz\`/\`quiz_batch\`, or any other tool \u2014 just write them and wait for the user's reply. Grade each answer against its hidden correct indices (${normalized.map((q) => q.multiSelect ? "exact-set" : "single").join(", ")}), show \u2713/\u2717 per question with Correct + Explanation. Treat 0/"I don't know" as a genuine gap.`,
1572
+ explainAll
1573
+ ].join(`
1574
+ `);
1535
1575
  }
1536
1576
  }),
1537
1577
  md_log: tool({
package/dist/tui.js CHANGED
@@ -2601,10 +2601,17 @@ function QuizBatchDialog(props) {
2601
2601
  })();
2602
2602
  }
2603
2603
  var tui = async (api) => {
2604
- const dir = api.state.path.directory || api.state.path.worktree || process.cwd();
2604
+ let dir;
2605
+ try {
2606
+ const p = api?.state?.path;
2607
+ dir = p?.directory || p?.worktree || process.cwd();
2608
+ } catch {
2609
+ dir = process.cwd();
2610
+ }
2605
2611
  const pendingDir = path.join(dir, PENDING_DIR);
2606
2612
  globalThis.__learnPendingDir = pendingDir;
2607
2613
  ensureDir(pendingDir);
2614
+ tlog("learn-tui init", `dir=${pendingDir}`, `pid=${process.pid}`);
2608
2615
  const heartbeatPath = path.join(pendingDir, ".tui-alive");
2609
2616
  try {
2610
2617
  fs.writeFileSync(heartbeatPath, String(Date.now()), "utf8");
@@ -2707,6 +2714,25 @@ var tui = async (api) => {
2707
2714
  } catch {
2708
2715
  return;
2709
2716
  }
2717
+ try {
2718
+ for (const f of files) {
2719
+ try {
2720
+ const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8"));
2721
+ const ts = j?.timestamp;
2722
+ if (typeof ts === "number" && Date.now() - ts > 24 * 60 * 60 * 1000) {
2723
+ const expDir = path.join(pendingDir, "expired");
2724
+ try {
2725
+ fs.mkdirSync(expDir, {
2726
+ recursive: true
2727
+ });
2728
+ } catch {}
2729
+ fs.renameSync(path.join(pendingDir, f), path.join(expDir, `${Date.now()}-${f}`));
2730
+ tlog("pending expired, archived", j?.id || f);
2731
+ }
2732
+ } catch {}
2733
+ }
2734
+ files = fs.readdirSync(pendingDir).filter((f) => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort();
2735
+ } catch {}
2710
2736
  const matching = files.map((f) => {
2711
2737
  try {
2712
2738
  const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8"));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-learn",
4
- "version": "1.4.2",
4
+ "version": "1.4.3",
5
5
  "description": "Pi learn system for OpenCode — Socratic teaching, graded quiz, Obsidian md_log, and visual makers. Port of amosblomqvist/learn (video: How I Use AI to Learn Things) to OpenCode.",
6
6
  "type": "module",
7
7
  "license": "AGPL-3.0-or-later",
@@ -756,10 +756,18 @@ function QuizBatchDialog(props: {
756
756
  }
757
757
 
758
758
  export const tui: TuiPlugin = async (api) => {
759
- const dir = api.state.path.directory || api.state.path.worktree || process.cwd()
759
+ // Guard the very first state access: if a future opencode version reshapes the TUI API
760
+ // object, a synchronous throw here would kill the whole plugin with zero trace (no
761
+ // heartbeat, no log line) — exactly the silent-death signature. Fall back to cwd.
762
+ let dir: string
763
+ try {
764
+ const p: any = (api as any)?.state?.path
765
+ dir = p?.directory || p?.worktree || process.cwd()
766
+ } catch { dir = process.cwd() }
760
767
  const pendingDir = path.join(dir, PENDING_DIR)
761
768
  ;(globalThis as any).__learnPendingDir = pendingDir
762
769
  ensureDir(pendingDir)
770
+ tlog("learn-tui init", `dir=${pendingDir}`, `pid=${process.pid}`)
763
771
  const heartbeatPath = path.join(pendingDir, ".tui-alive")
764
772
  try { fs.writeFileSync(heartbeatPath, String(Date.now()), "utf8") } catch {}
765
773
  const hbTimer = setInterval(() => { try { fs.writeFileSync(heartbeatPath, String(Date.now()), "utf8") } catch {} }, 2000)
@@ -834,6 +842,24 @@ export const tui: TuiPlugin = async (api) => {
834
842
  if (api.ui.dialog.open) return
835
843
  let files: string[] = []
836
844
  try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
845
+ // Expire pendings answered long ago via fallback (same 24h TTL as the server re-arm):
846
+ // never pop a quiz the session moved past hours ago just because a TUI attached late.
847
+ // Archive-then-rescan so an expired entry can't block a newer live quiz behind it.
848
+ try {
849
+ for (const f of files) {
850
+ try {
851
+ const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8")) as any
852
+ const ts = j?.timestamp
853
+ if (typeof ts === "number" && Date.now() - ts > 24 * 60 * 60 * 1000) {
854
+ const expDir = path.join(pendingDir, "expired")
855
+ try { fs.mkdirSync(expDir, { recursive: true }) } catch {}
856
+ fs.renameSync(path.join(pendingDir, f), path.join(expDir, `${Date.now()}-${f}`))
857
+ tlog("pending expired, archived", (j as any)?.id || f)
858
+ }
859
+ } catch {}
860
+ }
861
+ files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort()
862
+ } catch {}
837
863
  // Session-distinct: only show pending for current session.
838
864
  // Skip answered-pending (a response file exists, server is consuming): prevents re-popup after answer.
839
865
  const matching = files.map(f => { try { const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8")) as any; return { f, j } } catch { return null } }).filter(Boolean).filter(x => !hasAnswerArtifact(x!.j.id)) as Array<{f: string, j: any}>
package/plugins/learn.ts CHANGED
@@ -517,6 +517,30 @@ function acquireOwnerLock(dir: string, id: string): boolean {
517
517
  function refreshOwnerLock(dir: string, id: string) { try { fs.writeFileSync(ownerLockPath(dir, id), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8") } catch {} }
518
518
  function releaseOwnerLock(dir: string, id: string) { try { fs.unlinkSync(ownerLockPath(dir, id)) } catch {} }
519
519
 
520
+ // Pending quizzes answered via the no-TUI fallback (native question / manual chat) leave a
521
+ // pending file nobody will ever answer through the popup. Without expiry these rot forever:
522
+ // re-armed on every (re)start, and popped confusingly if a TUI attaches hours later, long
523
+ // after the session moved on. Quiz execute never blocks on the TUI (the no-TUI path returns
524
+ // the native-question fallback immediately), so a pending older than the TTL with no response
525
+ // is definitionally obsolete — archive it instead of re-arming. (TUI pickup applies the same
526
+ // TTL; see processPending in learn-tui.tsx.)
527
+ const PENDING_TTL_MS = 24 * 60 * 60 * 1000
528
+ function isPendingExpired(j: any): boolean {
529
+ try {
530
+ const ts = (j as any)?.timestamp
531
+ if (typeof ts !== "number") return false
532
+ return Date.now() - ts > PENDING_TTL_MS
533
+ } catch { return false }
534
+ }
535
+ function archiveExpiredPending(dir: string, f: string) {
536
+ try {
537
+ const expDir = path.join(dir, "expired")
538
+ try { fs.mkdirSync(expDir, { recursive: true }) } catch {}
539
+ fs.renameSync(path.join(dir, f), path.join(expDir, `${Date.now()}-${f}`))
540
+ slog("pending expired, archived", f)
541
+ } catch {}
542
+ }
543
+
520
544
  // Server-side inject (loopd pattern: host-adapter.ts:100 promptAsync + path.id + body.parts)
521
545
  const activeWatchers = new Map<string, () => void>()
522
546
  function watchAndInject(client: any, directory: string, id: string, sessionID: string, buildText: (result: any) => string) {
@@ -911,6 +935,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
911
935
  for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
912
936
  try {
913
937
  const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))
938
+ if (isPendingExpired(j)) { archiveExpiredPending(dir, f); continue }
914
939
  if (j?.id && j?.sessionID) {
915
940
  watchAndInject(client, directory, j.id, j.sessionID, (r: any) => {
916
941
  if (j.type === "quiz") {
@@ -1117,7 +1142,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1117
1142
  tool: {
1118
1143
  // ── quiz: graded question ────────────────────────────────────────
1119
1144
  quiz: tool({
1120
- description: "Ask the user a GRADED question with a known correct answer, then grade and give feedback. Unlike the native `question` tool (which collects preferences with no right answer), `quiz` has a correct answer, marks selection right/wrong, reveals the correct answer, and shows an explanation. The TUI interaction is asynchronous, so call `quiz` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, another `quiz`, `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answer will be injected as a new turn. Only call native `question` after `quiz` if its result explicitly requests the no-TUI fallback. Use to assess understanding before teaching and for retrieval practice after. Options-only: single/multi-select plus auto 'I don't know'. No free-text. For non-graded questions use the native `question` tool.",
1145
+ description: "Ask the user a GRADED question with a known correct answer, then grade and give feedback. Unlike the native `question` tool (which collects preferences with no right answer), `quiz` has a correct answer, marks selection right/wrong, reveals the correct answer, and shows an explanation. The TUI interaction is asynchronous, so call `quiz` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, another `quiz`, `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answer will be injected as a new turn. Never call the native `question` tool for a quiz there is no two-step flow. If no popup is available, the quiz result itself contains the question to ask in plain chat text. Use to assess understanding before teaching and for retrieval practice after. Options-only: single/multi-select plus auto 'I don't know'. No free-text. For non-graded questions use the native `question` tool.",
1121
1146
  args: {
1122
1147
  question: tool.schema.string().describe("Single quiz question to ask. Call this tool alone; do not combine it with another user-input tool in the same turn."),
1123
1148
  details: tool.schema.string().optional().describe("Extra context shown under question."),
@@ -1239,21 +1264,19 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1239
1264
  }
1240
1265
  return result
1241
1266
  }
1267
+ // No-TUI path: single-prompt contract. NEVER route through the native `question`
1268
+ // tool here — that produces the quiz-plus-question double prompt, and there is no
1269
+ // "2-step flow": a quiz is one question, asked once, in plain text.
1242
1270
  const instruction = [
1243
- `[quiz ready awaiting user answer via \`question\` tool]`,
1271
+ `[quiz — no popup available, asking directly in chat]`,
1244
1272
  `Question: ${qFixed}`,
1245
1273
  dFixed ? `Details: ${dFixed}` : null,
1246
- `Options (display order, already shuffled):`,
1247
- ...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""} (value="${o.value}")`),
1248
- `Correct indices: ${correctIndices.join(", ")} (Correct values: ${correctStr})`,
1249
- `Explanation (reveal AFTER answer): ${eFixed}`,
1250
- `Mode: ${args.multiSelect ? "multi-select (exact set)" : "single-select"}`,
1274
+ ...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` ${o.description}` : ""}`),
1275
+ `0. I don't know`,
1251
1276
  ``,
1252
- `INSTRUCTION FOR LLM: Call the built-in \`question\` tool with:`,
1253
- ` header: "Quiz"`,
1254
- ` question: "${qFixed.replace(/"/g, '\\"')}"`,
1255
- ` options: [${options.map(o => `{label:"${o.label.replace(/"/g, '\\"')}", description:"${(o.description ?? "").replace(/"/g, '\\"')}"}`).join(", ")}]`,
1256
- `Then compare the user's selected labels to correct indices [${correctIndices.join(", ")}]. Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show ✓/✗, reveal Correct: ${correctStr}, and Explanation. An 'I don't know' maps to dontKnow (genuine gap).`,
1277
+ `INSTRUCTION FOR LLM: ask the question above IN YOUR REPLY TEXT, exactly as written (numbered options, ending with the "I don't know" line). Do NOT call the \`question\` tool, another \`quiz\`/\`quiz_batch\`, or any other tool — just write the question and wait for the user's reply.`,
1278
+ `When they reply, compare their numbers/labels to correct indices [${correctIndices.join(", ")}] (correct: ${correctStr}). Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show ✓/✗, reveal Correct: ${correctStr}, then the explanation below. Treat 0/"I don't know" as a genuine gap, not a guess.`,
1279
+ `Explanation (reveal ONLY after they answer): ${eFixed}`,
1257
1280
  ].filter(Boolean).join("\n")
1258
1281
  ;(ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { correctIndices, explanation: eFixed, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } })
1259
1282
  return instruction
@@ -1262,7 +1285,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1262
1285
 
1263
1286
  // ── quiz_batch: optional deck — quiz 1/3 → 2/3 → 3/3 in one dialog, one inject
1264
1287
  quiz_batch: tool({
1265
- description: "Batch version of quiz - shows 2-8 graded questions as a deck (Quiz 1/3 to 2/3 to 3/3) in one TUI, then injects one combined answer. The TUI interaction is asynchronous, so call `quiz_batch` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, `quiz`, another `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answers will be injected as a new turn. Use when you want multiple non-adaptive checks in one deck. Each entry has the same schema as quiz.",
1288
+ description: "Batch version of quiz - shows 2-8 graded questions as a deck (Quiz 1/3 to 2/3 to 3/3) in one TUI, then injects one combined answer. The TUI interaction is asynchronous, so call `quiz_batch` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, `quiz`, another `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answers will be injected as a new turn. Never call the native `question` tool for a quiz batch — there is no two-step flow. If no popup is available, the result itself contains the questions to ask in plain chat text. Use when you want multiple non-adaptive checks in one deck. Each entry has the same schema as quiz.",
1266
1289
  args: {
1267
1290
  quizzes: tool.schema.array(tool.schema.object({
1268
1291
  question: tool.schema.string(),
@@ -1353,7 +1376,25 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1353
1376
  })
1354
1377
  slog("quiz_batch watchAndInject armed", id, "alive", isAlive)
1355
1378
  if (isAlive) return `[quiz batch displayed in TUI - ${normalized.length} quizzes as deck Quiz 1/${normalized.length} to ${normalized.length}/${normalized.length}. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answers will be injected as a new turn.]`
1356
- else return `[quiz batch stored durably - TUI not alive yet, so it will appear on restart. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answers will be injected after the user completes the deck.]`
1379
+ // No-TUI path: single-prompt contract (same rationale as single quiz above
1380
+ // waiting for an inject that can never come would stall the session, and routing
1381
+ // through the native `question` tool produces the double prompt).
1382
+ const askAll = normalized.map((q: any, qi: number) => {
1383
+ const lines = [`Q${qi + 1}/${normalized.length}: ${q.question}`]
1384
+ if (q.details?.trim()) lines.push(q.details.trim())
1385
+ q.options.forEach((o: any, i: number) => lines.push(`${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""}`))
1386
+ lines.push(`0. I don't know`)
1387
+ lines.push(`(hidden correct indices for grading only: ${(q.correctIndices || []).join(",")})`)
1388
+ return lines.join("\n")
1389
+ }).join("\n\n")
1390
+ const explainAll = normalized.map((q: any, qi: number) => `Q${qi + 1} explanation (reveal ONLY after they answer): ${q.explanation}`).join("\n")
1391
+ return [
1392
+ `[quiz batch — no popup available, asking directly in chat]`,
1393
+ askAll,
1394
+ ``,
1395
+ `INSTRUCTION FOR LLM: ask ALL questions above IN YOUR REPLY TEXT, exactly as written. Do NOT call the \`question\` tool, another \`quiz\`/\`quiz_batch\`, or any other tool — just write them and wait for the user's reply. Grade each answer against its hidden correct indices (${normalized.map((q: any) => (q.multiSelect ? "exact-set" : "single")).join(", ")}), show ✓/✗ per question with Correct + Explanation. Treat 0/"I don't know" as a genuine gap.`,
1396
+ explainAll,
1397
+ ].join("\n")
1357
1398
  }
1358
1399
  }),
1359
1400