@bojackduy/opencode-learn 1.4.2 → 1.4.4
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 +7 -1
- package/dist/server.js +55 -15
- package/dist/tui.js +27 -1
- package/package.json +4 -4
- package/plugins/learn-tui.tsx +27 -1
- package/plugins/learn.ts +55 -14
- package/scripts/install.mjs +10 -9
package/README.md
CHANGED
|
@@ -58,8 +58,14 @@ Add to **both** configs (opencode needs server + TUI):
|
|
|
58
58
|
```
|
|
59
59
|
**`~/.config/opencode/tui.json`** — TUI (`learn-tui`):
|
|
60
60
|
```jsonc
|
|
61
|
-
{ "plugin": ["@bojackduy/opencode-learn
|
|
61
|
+
{ "plugin": ["@bojackduy/opencode-learn"] }
|
|
62
62
|
```
|
|
63
|
+
Use the **bare package name** in both files, not `@bojackduy/opencode-learn/tui`/`/server` —
|
|
64
|
+
opencode already picks the right export (`./tui` vs `./server`) based on which host loads it.
|
|
65
|
+
A scoped-package spec with a `/tui` or `/server` suffix is silently broken: `npm-package-arg`
|
|
66
|
+
parses the second slash as a local directory reference instead of a package+subpath reference,
|
|
67
|
+
so the plugin never resolves or activates, with **no error logged anywhere**.
|
|
68
|
+
|
|
63
69
|
Restart OpenCode. Verify `/md_log`, `quiz`, `write_mermaid` appear in tool list.
|
|
64
70
|
|
|
65
71
|
Local checkout:
|
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");
|
|
@@ -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.
|
|
4
|
+
"version": "1.4.4",
|
|
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",
|
|
@@ -82,9 +82,9 @@
|
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@mermaid-js/mermaid-cli": "^11.4.2",
|
|
84
84
|
"@opencode-ai/plugin": "1.18.25",
|
|
85
|
-
"@opentui/core": "
|
|
86
|
-
"@opentui/solid": "
|
|
87
|
-
"solid-js": "
|
|
85
|
+
"@opentui/core": "0.5.11",
|
|
86
|
+
"@opentui/solid": "0.5.11",
|
|
87
|
+
"solid-js": "1.9.12"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
90
|
"@types/node": "^26.4.0",
|
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)
|
|
@@ -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.
|
|
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
|
|
package/scripts/install.mjs
CHANGED
|
@@ -101,10 +101,13 @@ async function configurePlugins(isUninstall) {
|
|
|
101
101
|
} else {
|
|
102
102
|
// Keep non-learn plugins, add/update learn
|
|
103
103
|
next = plugins.filter(v => !isLearnPluginSpec(v))
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
// Both tui.json and opencode.json use the bare package name: "@scope/name/tui" is
|
|
105
|
+
// silently broken for SCOPED packages — npm-package-arg parses a scoped spec with a
|
|
106
|
+
// second slash as a local "directory" reference instead of a package+subpath
|
|
107
|
+
// reference, so it never resolves and the plugin never activates (no error logged).
|
|
108
|
+
// The loader already picks the "./tui" vs "./server" export from package.json
|
|
109
|
+
// automatically based on which host (tui vs server) loads it.
|
|
110
|
+
next.push(packageName)
|
|
108
111
|
// Deduplicate
|
|
109
112
|
next = [...new Set(next)]
|
|
110
113
|
}
|
|
@@ -113,8 +116,7 @@ async function configurePlugins(isUninstall) {
|
|
|
113
116
|
if (!findRootProperty(source, "plugin") && !isUninstall) {
|
|
114
117
|
const eol = source.includes("\r\n") ? "\r\n" : "\n"
|
|
115
118
|
const indent = " "
|
|
116
|
-
const
|
|
117
|
-
const spec = isTui ? `${packageName}/tui` : packageName
|
|
119
|
+
const spec = packageName
|
|
118
120
|
const pluginStr = `,\n${indent}"plugin": ${formatPluginArray([spec], indent, eol)}`
|
|
119
121
|
// Insert before final }
|
|
120
122
|
const lastBrace = source.lastIndexOf("}")
|
|
@@ -127,8 +129,7 @@ async function configurePlugins(isUninstall) {
|
|
|
127
129
|
if (e?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`)
|
|
128
130
|
if (!isUninstall) {
|
|
129
131
|
// Create new config file if it doesn't exist
|
|
130
|
-
const
|
|
131
|
-
const spec = isTui ? `${packageName}/tui` : packageName
|
|
132
|
+
const spec = packageName
|
|
132
133
|
// Only create opencode.jsonc and tui.jsonc by default
|
|
133
134
|
if ((name === "opencode.jsonc" || name === "tui.jsonc") && !isUninstall) {
|
|
134
135
|
const content = `{\n "plugin": ["${spec}"]\n}\n`
|
|
@@ -219,7 +220,7 @@ async function installOrUpdate() {
|
|
|
219
220
|
console.log(` Agents: ${agentsCount} (researcher, mermaid-maker, svg-maker, classify)`)
|
|
220
221
|
console.log(` Skills: ${skillsCount} (teach, visualize, marker-pdf-parser, notebooklm-lecture-notes)`)
|
|
221
222
|
if (commandsCount) console.log(` Commands: ${commandsCount}`)
|
|
222
|
-
console.log(` Plugin: ${packageName} (server
|
|
223
|
+
console.log(` Plugin: ${packageName} (server + TUI)`)
|
|
223
224
|
console.log("\nRestart OpenCode to load plugins.")
|
|
224
225
|
console.log(" /md_log <file> — mirror to Obsidian")
|
|
225
226
|
console.log(" quiz / quiz_batch — graded checks")
|