@bojackduy/opencode-learn 1.4.1 → 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 +55 -15
- package/dist/tui.js +50 -4
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +51 -3
- package/plugins/learn.ts +55 -14
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.
|
|
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
|
|
1426
|
+
`[quiz \u2014 no popup available, asking directly in chat]`,
|
|
1402
1427
|
`Question: ${qFixed}`,
|
|
1403
1428
|
dFixed ? `Details: ${dFixed}` : null,
|
|
1404
|
-
`
|
|
1405
|
-
|
|
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:
|
|
1411
|
-
`
|
|
1412
|
-
`
|
|
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
|
-
|
|
1534
|
-
|
|
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
|
-
|
|
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");
|
|
@@ -2677,8 +2684,27 @@ var tui = async (api) => {
|
|
|
2677
2684
|
return;
|
|
2678
2685
|
let current = currentBySession.get(curSid);
|
|
2679
2686
|
if (current) {
|
|
2680
|
-
|
|
2681
|
-
|
|
2687
|
+
const pendingStillExists = (() => {
|
|
2688
|
+
try {
|
|
2689
|
+
return fs.readdirSync(pendingDir).some((f) => f === `quiz-${current.id}.json` || f === `quiz_batch-${current.id}.json`);
|
|
2690
|
+
} catch {
|
|
2691
|
+
return true;
|
|
2692
|
+
}
|
|
2693
|
+
})();
|
|
2694
|
+
if (!pendingStillExists) {
|
|
2695
|
+
tlog("processPending stale current cleared (pending gone)", current.id);
|
|
2696
|
+
releasePopupClaim(current.id);
|
|
2697
|
+
currentBySession.delete(curSid);
|
|
2698
|
+
current = undefined;
|
|
2699
|
+
} else if (api.ui.dialog.open) {
|
|
2700
|
+
refreshPopupClaim(current.id);
|
|
2701
|
+
return;
|
|
2702
|
+
} else {
|
|
2703
|
+
tlog("processPending stale current cleared (dialog no longer open)", current.id);
|
|
2704
|
+
releasePopupClaim(current.id);
|
|
2705
|
+
currentBySession.delete(curSid);
|
|
2706
|
+
current = undefined;
|
|
2707
|
+
}
|
|
2682
2708
|
}
|
|
2683
2709
|
if (api.ui.dialog.open)
|
|
2684
2710
|
return;
|
|
@@ -2688,6 +2714,25 @@ var tui = async (api) => {
|
|
|
2688
2714
|
} catch {
|
|
2689
2715
|
return;
|
|
2690
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 {}
|
|
2691
2736
|
const matching = files.map((f) => {
|
|
2692
2737
|
try {
|
|
2693
2738
|
const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8"));
|
|
@@ -2699,7 +2744,8 @@ var tui = async (api) => {
|
|
|
2699
2744
|
return null;
|
|
2700
2745
|
}
|
|
2701
2746
|
}).filter(Boolean).filter((x) => !hasAnswerArtifact(x.j.id));
|
|
2702
|
-
const
|
|
2747
|
+
const byNewest = [...matching].sort((a, b) => (b.j?.timestamp || 0) - (a.j?.timestamp || 0));
|
|
2748
|
+
const pick = byNewest.find((x) => x.j.sessionID === curSid) || byNewest.find((x) => !x.j.sessionID);
|
|
2703
2749
|
if (!pick)
|
|
2704
2750
|
return;
|
|
2705
2751
|
const file = pick.f;
|
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.
|
|
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",
|
package/plugins/learn-tui.tsx
CHANGED
|
@@ -756,10 +756,18 @@ function QuizBatchDialog(props: {
|
|
|
756
756
|
}
|
|
757
757
|
|
|
758
758
|
export const tui: TuiPlugin = async (api) => {
|
|
759
|
-
|
|
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)
|
|
@@ -812,14 +820,54 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
812
820
|
const curSid = getCurrentSessionID()
|
|
813
821
|
if (!curSid) return
|
|
814
822
|
let current = currentBySession.get(curSid) as { id: string; type: string } | undefined
|
|
815
|
-
if (current) {
|
|
823
|
+
if (current) {
|
|
824
|
+
// Self-heal: the tracked dialog may have been dismissed through a path other than
|
|
825
|
+
// done()/cancel() (session/tab switch, TUI reconnect, dialog replaced externally),
|
|
826
|
+
// or its pending file may have been consumed externally. A stuck entry would block
|
|
827
|
+
// ALL future quizzes for this session forever — release it and rediscover below.
|
|
828
|
+
const pendingStillExists = (() => { try { return fs.readdirSync(pendingDir).some((f) => f === `quiz-${current!.id}.json` || f === `quiz_batch-${current!.id}.json`) } catch { return true } })()
|
|
829
|
+
if (!pendingStillExists) {
|
|
830
|
+
tlog("processPending stale current cleared (pending gone)", current.id)
|
|
831
|
+
releasePopupClaim(current.id)
|
|
832
|
+
currentBySession.delete(curSid)
|
|
833
|
+
current = undefined
|
|
834
|
+
} else if (api.ui.dialog.open) { refreshPopupClaim(current.id); return }
|
|
835
|
+
else {
|
|
836
|
+
tlog("processPending stale current cleared (dialog no longer open)", current.id)
|
|
837
|
+
releasePopupClaim(current.id)
|
|
838
|
+
currentBySession.delete(curSid)
|
|
839
|
+
current = undefined
|
|
840
|
+
}
|
|
841
|
+
}
|
|
816
842
|
if (api.ui.dialog.open) return
|
|
817
843
|
let files: string[] = []
|
|
818
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 {}
|
|
819
863
|
// Session-distinct: only show pending for current session.
|
|
820
864
|
// Skip answered-pending (a response file exists, server is consuming): prevents re-popup after answer.
|
|
821
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}>
|
|
822
|
-
|
|
866
|
+
// Newest-first: when several quizzes stack up for the same session (e.g. earlier ones
|
|
867
|
+
// answered manually in chat after a popup failure), the LATEST quiz is the live one the
|
|
868
|
+
// agent is waiting on. Alphabetical order would keep re-showing the oldest stuck quiz.
|
|
869
|
+
const byNewest = [...matching].sort((a, b) => (((b as any).j?.timestamp || 0) as number) - (((a as any).j?.timestamp || 0) as number))
|
|
870
|
+
const pick = byNewest.find(x => x.j.sessionID === curSid) || byNewest.find(x => !x.j.sessionID)
|
|
823
871
|
if (!pick) return
|
|
824
872
|
const file = pick.f
|
|
825
873
|
const full = path.join(pendingDir, file)
|
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.
|
|
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
|
|
1271
|
+
`[quiz — no popup available, asking directly in chat]`,
|
|
1244
1272
|
`Question: ${qFixed}`,
|
|
1245
1273
|
dFixed ? `Details: ${dFixed}` : null,
|
|
1246
|
-
`
|
|
1247
|
-
|
|
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:
|
|
1253
|
-
`
|
|
1254
|
-
`
|
|
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
|
-
|
|
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
|
|