@bojackduy/opencode-learn 1.2.4 → 1.3.0
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 +2 -0
- package/dist/server.js +47 -30
- package/dist/tui.js +9 -7
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +14 -7
- package/plugins/learn.ts +48 -21
- package/skills/teach/SKILL.md +11 -7
package/README.md
CHANGED
|
@@ -115,6 +115,8 @@ quiz_batch(quizzes=[{question:"…", options:[…], correctAnswer:["Red","Blue"]
|
|
|
115
115
|
```
|
|
116
116
|
Single → TUI popup `QuizDialog` (single/multi + `I don't know` + note). Batch → deck `1/3→3/3` `QuizBatchDialog` (same 4-state `hit/miss/false-alarm/correct-rejection` solid `bg` inverted). Both durable `pendingDir` `.opencode/learn-pending` — kill `opencode` mid-popup → re-show on restart.
|
|
117
117
|
|
|
118
|
+
Quiz dialogs are asynchronous. The agent must call `quiz` or `quiz_batch` alone, end that assistant turn once the dialog is displayed, and continue only after the TUI injects the learner's answer.
|
|
119
|
+
|
|
118
120
|
Open forks: native `question` (single/multi `Other`).
|
|
119
121
|
|
|
120
122
|
**Visual — one correct picture**
|
package/dist/server.js
CHANGED
|
@@ -529,24 +529,31 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
529
529
|
slog("watchAndInject no sessionID", id);
|
|
530
530
|
return;
|
|
531
531
|
}
|
|
532
|
+
activeWatchers.get(id)?.();
|
|
532
533
|
const dir = pendingDir(directory);
|
|
533
534
|
const respPath = path.join(dir, `response-${id}.json`);
|
|
534
535
|
const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`);
|
|
535
536
|
const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)];
|
|
537
|
+
let watcher;
|
|
538
|
+
let pollTimer;
|
|
539
|
+
let closed = false;
|
|
536
540
|
const closeWatcher = () => {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
541
|
+
if (closed)
|
|
542
|
+
return;
|
|
543
|
+
closed = true;
|
|
544
|
+
try {
|
|
545
|
+
watcher?.close();
|
|
546
|
+
} catch {}
|
|
547
|
+
if (pollTimer)
|
|
548
|
+
clearInterval(pollTimer);
|
|
549
|
+
if (activeWatchers.get(id) === closeWatcher)
|
|
542
550
|
activeWatchers.delete(id);
|
|
543
|
-
}
|
|
544
551
|
};
|
|
552
|
+
activeWatchers.set(id, closeWatcher);
|
|
545
553
|
const fire = async (attempt = 0) => {
|
|
546
554
|
try {
|
|
547
555
|
fs.renameSync(respPath, claimPath);
|
|
548
556
|
} catch {
|
|
549
|
-
closeWatcher();
|
|
550
557
|
return;
|
|
551
558
|
}
|
|
552
559
|
let data;
|
|
@@ -626,13 +633,17 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
626
633
|
}
|
|
627
634
|
if (!ok) {
|
|
628
635
|
slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0, 200));
|
|
636
|
+
closeWatcher();
|
|
629
637
|
try {
|
|
630
|
-
fs.renameSync(claimPath,
|
|
631
|
-
} catch {
|
|
638
|
+
fs.renameSync(claimPath, respPath);
|
|
639
|
+
} catch {
|
|
640
|
+
try {
|
|
641
|
+
fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`));
|
|
642
|
+
} catch {}
|
|
643
|
+
}
|
|
632
644
|
try {
|
|
633
645
|
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } });
|
|
634
646
|
} catch {}
|
|
635
|
-
closeWatcher();
|
|
636
647
|
return;
|
|
637
648
|
}
|
|
638
649
|
try {
|
|
@@ -649,13 +660,19 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
649
660
|
} catch {}
|
|
650
661
|
};
|
|
651
662
|
try {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
fire();
|
|
663
|
+
watcher = fs.watch(dir, () => {
|
|
664
|
+
fire();
|
|
655
665
|
});
|
|
656
|
-
|
|
657
|
-
activeWatchers.set(id, w);
|
|
666
|
+
watcher.on("error", () => {});
|
|
658
667
|
} catch {}
|
|
668
|
+
pollTimer = setInterval(() => {
|
|
669
|
+
if (fs.existsSync(respPath)) {
|
|
670
|
+
fire();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (!pendingCandidates.some((p) => fs.existsSync(p)) && !fs.existsSync(claimPath))
|
|
674
|
+
closeWatcher();
|
|
675
|
+
}, 500);
|
|
659
676
|
if (fs.existsSync(respPath)) {
|
|
660
677
|
slog("watchAndInject fast-path", id);
|
|
661
678
|
fire();
|
|
@@ -901,16 +918,16 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
901
918
|
slog("classify response written", data.id, inferred.join(","));
|
|
902
919
|
} catch {}
|
|
903
920
|
};
|
|
921
|
+
const sweep = () => {
|
|
922
|
+
try {
|
|
923
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
|
|
924
|
+
processClassify(f);
|
|
925
|
+
}
|
|
926
|
+
} catch {}
|
|
927
|
+
};
|
|
928
|
+
sweep();
|
|
904
929
|
try {
|
|
905
|
-
|
|
906
|
-
processClassify(f);
|
|
907
|
-
}
|
|
908
|
-
} catch {}
|
|
909
|
-
try {
|
|
910
|
-
const w = fs.watch(dir, (_e, filename) => {
|
|
911
|
-
if (filename)
|
|
912
|
-
processClassify(filename);
|
|
913
|
-
});
|
|
930
|
+
const w = fs.watch(dir, () => sweep());
|
|
914
931
|
w.on("error", () => {});
|
|
915
932
|
} catch {}
|
|
916
933
|
}
|
|
@@ -1165,9 +1182,9 @@ Explanation: ${j.explanation}${note}`;
|
|
|
1165
1182
|
},
|
|
1166
1183
|
tool: {
|
|
1167
1184
|
quiz: tool({
|
|
1168
|
-
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 correct answer, and shows explanation. 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.",
|
|
1185
|
+
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.",
|
|
1169
1186
|
args: {
|
|
1170
|
-
question: tool.schema.string().describe("Single quiz question to ask.
|
|
1187
|
+
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."),
|
|
1171
1188
|
details: tool.schema.string().optional().describe("Extra context shown under question."),
|
|
1172
1189
|
options: tool.schema.array(tool.schema.object({
|
|
1173
1190
|
label: tool.schema.string().describe("Display label"),
|
|
@@ -1265,7 +1282,7 @@ Explanation: ${eFixed}${note}`;
|
|
|
1265
1282
|
}
|
|
1266
1283
|
}
|
|
1267
1284
|
if (tuiAlive) {
|
|
1268
|
-
return `[quiz displayed in TUI
|
|
1285
|
+
return `[quiz displayed in TUI - waiting for the user's answer in the popup. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answer will be injected as a new turn.]`;
|
|
1269
1286
|
}
|
|
1270
1287
|
const isTTY = process.stdin?.isTTY && process.stdout?.isTTY;
|
|
1271
1288
|
const insideOpencode = !!process.env?.OPENCODE || !!process.env?.OPENCODE_TUI;
|
|
@@ -1344,7 +1361,7 @@ Explanation: ${eFixed}`;
|
|
|
1344
1361
|
}
|
|
1345
1362
|
}),
|
|
1346
1363
|
quiz_batch: tool({
|
|
1347
|
-
description: "Batch version of quiz
|
|
1364
|
+
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.",
|
|
1348
1365
|
args: {
|
|
1349
1366
|
quizzes: tool.schema.array(tool.schema.object({
|
|
1350
1367
|
question: tool.schema.string(),
|
|
@@ -1454,9 +1471,9 @@ Explanation: ${eFixed}`;
|
|
|
1454
1471
|
});
|
|
1455
1472
|
slog("quiz_batch watchAndInject armed", id, "alive", isAlive);
|
|
1456
1473
|
if (isAlive)
|
|
1457
|
-
return `[quiz batch displayed in TUI
|
|
1474
|
+
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.]`;
|
|
1458
1475
|
else
|
|
1459
|
-
return `[quiz batch
|
|
1476
|
+
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.]`;
|
|
1460
1477
|
}
|
|
1461
1478
|
}),
|
|
1462
1479
|
md_log: tool({
|
package/dist/tui.js
CHANGED
|
@@ -2628,6 +2628,14 @@ var tui = async (api) => {
|
|
|
2628
2628
|
} catch {}
|
|
2629
2629
|
return null;
|
|
2630
2630
|
};
|
|
2631
|
+
const hasAnswerArtifact = (id) => {
|
|
2632
|
+
const response = `response-${id}`;
|
|
2633
|
+
try {
|
|
2634
|
+
return fs.readdirSync(pendingDir).some((file) => file === `${response}.json` || file.startsWith(`${response}.claim-`));
|
|
2635
|
+
} catch {
|
|
2636
|
+
return false;
|
|
2637
|
+
}
|
|
2638
|
+
};
|
|
2631
2639
|
const processPending = () => {
|
|
2632
2640
|
const curSid = getCurrentSessionID();
|
|
2633
2641
|
if (!curSid)
|
|
@@ -2653,13 +2661,7 @@ var tui = async (api) => {
|
|
|
2653
2661
|
} catch {
|
|
2654
2662
|
return null;
|
|
2655
2663
|
}
|
|
2656
|
-
}).filter(Boolean).filter((x) =>
|
|
2657
|
-
try {
|
|
2658
|
-
return !fs.existsSync(path.join(pendingDir, `response-${x.j.id}.json`));
|
|
2659
|
-
} catch {
|
|
2660
|
-
return true;
|
|
2661
|
-
}
|
|
2662
|
-
});
|
|
2664
|
+
}).filter(Boolean).filter((x) => !hasAnswerArtifact(x.j.id));
|
|
2663
2665
|
const pick = matching.find((x) => x.j.sessionID === curSid) || matching.find((x) => !x.j.sessionID);
|
|
2664
2666
|
if (!pick)
|
|
2665
2667
|
return;
|
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
|
+
"version": "1.3.0",
|
|
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
|
@@ -778,6 +778,15 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
778
778
|
return null
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
+
const hasAnswerArtifact = (id: string): boolean => {
|
|
782
|
+
const response = `response-${id}`
|
|
783
|
+
try {
|
|
784
|
+
return fs.readdirSync(pendingDir).some((file) => file === `${response}.json` || file.startsWith(`${response}.claim-`))
|
|
785
|
+
} catch {
|
|
786
|
+
return false
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
781
790
|
const processPending = () => {
|
|
782
791
|
const curSid = getCurrentSessionID()
|
|
783
792
|
if (!curSid) return
|
|
@@ -788,9 +797,7 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
788
797
|
try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
|
|
789
798
|
// Session-distinct: only show pending for current session.
|
|
790
799
|
// Skip answered-pending (a response file exists, server is consuming): prevents re-popup after answer.
|
|
791
|
-
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 => {
|
|
792
|
-
try { return !fs.existsSync(path.join(pendingDir, `response-${x!.j.id}.json`)) } catch { return true }
|
|
793
|
-
}) as Array<{f: string, j: any}>
|
|
800
|
+
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}>
|
|
794
801
|
const pick = matching.find(x => x.j.sessionID === curSid) || matching.find(x => !x.j.sessionID)
|
|
795
802
|
if (!pick) return
|
|
796
803
|
const file = pick.f
|
|
@@ -875,12 +882,12 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
875
882
|
}
|
|
876
883
|
}
|
|
877
884
|
} catch {}
|
|
878
|
-
//
|
|
879
|
-
//
|
|
885
|
+
// Keep the pending payload until the server confirms injection. It is the recovery context
|
|
886
|
+
// needed to rebuild the prompt if either process exits after the answer is written.
|
|
880
887
|
api.ui.dialog.clear()
|
|
881
888
|
currentBySession.delete(curSid)
|
|
882
889
|
setTimeout(processPending, 150)
|
|
883
|
-
// Watchdog: if the response is still unconsumed after 8s, re-touch to retrigger the
|
|
890
|
+
// Watchdog: if the response is still unconsumed (server didn't fire) after 8s, re-touch to retrigger the watch + log loudly.
|
|
884
891
|
// Safe: server claims via atomic rename, so a rewrite can never cause double-inject.
|
|
885
892
|
setTimeout(() => {
|
|
886
893
|
try {
|
|
@@ -910,7 +917,7 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
910
917
|
} catch {}
|
|
911
918
|
}
|
|
912
919
|
} catch {}
|
|
913
|
-
//
|
|
920
|
+
// Keep the pending payload until the server consumes the cancellation response.
|
|
914
921
|
api.ui.dialog.clear()
|
|
915
922
|
currentBySession.delete(curSid)
|
|
916
923
|
setTimeout(processPending, 150)
|
package/plugins/learn.ts
CHANGED
|
@@ -476,18 +476,29 @@ async function waitForResponse(directory: string, id: string, abort: AbortSignal
|
|
|
476
476
|
}
|
|
477
477
|
|
|
478
478
|
// Server-side inject (loopd pattern: host-adapter.ts:100 promptAsync + path.id + body.parts)
|
|
479
|
-
const activeWatchers = new Map<string,
|
|
479
|
+
const activeWatchers = new Map<string, () => void>()
|
|
480
480
|
function watchAndInject(client: any, directory: string, id: string, sessionID: string, buildText: (result: any) => string) {
|
|
481
481
|
slog("watchAndInject start", id, sessionID)
|
|
482
482
|
if (!sessionID) { slog("watchAndInject no sessionID", id); return }
|
|
483
|
+
activeWatchers.get(id)?.()
|
|
483
484
|
const dir = pendingDir(directory)
|
|
484
485
|
const respPath = path.join(dir, `response-${id}.json`)
|
|
485
486
|
const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`)
|
|
486
487
|
const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)]
|
|
487
|
-
|
|
488
|
+
let watcher: fs.FSWatcher | undefined
|
|
489
|
+
let pollTimer: ReturnType<typeof setInterval> | undefined
|
|
490
|
+
let closed = false
|
|
491
|
+
const closeWatcher = () => {
|
|
492
|
+
if (closed) return
|
|
493
|
+
closed = true
|
|
494
|
+
try { watcher?.close() } catch {}
|
|
495
|
+
if (pollTimer) clearInterval(pollTimer)
|
|
496
|
+
if (activeWatchers.get(id) === closeWatcher) activeWatchers.delete(id)
|
|
497
|
+
}
|
|
498
|
+
activeWatchers.set(id, closeWatcher)
|
|
488
499
|
const fire = async (attempt = 0): Promise<void> => {
|
|
489
500
|
// Atomic single-consumer claim: exactly one process proceeds (fixes double-inject across processes)
|
|
490
|
-
try { fs.renameSync(respPath, claimPath) } catch {
|
|
501
|
+
try { fs.renameSync(respPath, claimPath) } catch { return } // no answer yet, or another process claimed it
|
|
491
502
|
let data: any
|
|
492
503
|
try {
|
|
493
504
|
data = JSON.parse(fs.readFileSync(claimPath, "utf8"))
|
|
@@ -547,14 +558,16 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
547
558
|
}
|
|
548
559
|
if (!ok) {
|
|
549
560
|
slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0,200))
|
|
550
|
-
|
|
561
|
+
closeWatcher()
|
|
562
|
+
try { fs.renameSync(claimPath, respPath) } catch {
|
|
563
|
+
try { fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`)) } catch {}
|
|
564
|
+
}
|
|
551
565
|
try {
|
|
552
566
|
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } })
|
|
553
567
|
} catch {}
|
|
554
|
-
closeWatcher()
|
|
555
568
|
return
|
|
556
569
|
}
|
|
557
|
-
// Success:
|
|
570
|
+
// Success: consume claim + defensively try pending too (TUI is the primary owner of pending deletion)
|
|
558
571
|
try { fs.unlinkSync(claimPath) } catch {}
|
|
559
572
|
for (const p of pendingCandidates) try { fs.unlinkSync(p) } catch {}
|
|
560
573
|
closeWatcher()
|
|
@@ -564,10 +577,18 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
564
577
|
} catch {}
|
|
565
578
|
}
|
|
566
579
|
try {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
580
|
+
// Fire on ANY directory event, not just exact filename match — atomic tmp+rename writes can report
|
|
581
|
+
// a different filename (or no filename) on some fs.watch backends (notably macOS FSEvents). The claim
|
|
582
|
+
// rename inside fire() makes this safe to call spuriously: if respPath doesn't exist yet, it just no-ops.
|
|
583
|
+
watcher = fs.watch(dir, () => { void fire() })
|
|
584
|
+
watcher.on("error", () => {})
|
|
570
585
|
} catch {}
|
|
586
|
+
// fs.watch is lossy by design. Polling keeps persisted answers moving after a TUI/server restart
|
|
587
|
+
// even when the filesystem event is dropped.
|
|
588
|
+
pollTimer = setInterval(() => {
|
|
589
|
+
if (fs.existsSync(respPath)) { void fire(); return }
|
|
590
|
+
if (!pendingCandidates.some((p) => fs.existsSync(p)) && !fs.existsSync(claimPath)) closeWatcher()
|
|
591
|
+
}, 500)
|
|
571
592
|
// Recheck after arming to close the event gap (claim makes double-fire safe)
|
|
572
593
|
if (fs.existsSync(respPath)) { slog("watchAndInject fast-path", id); void fire(); return }
|
|
573
594
|
}
|
|
@@ -803,14 +824,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
803
824
|
const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, isIDK, classifySessionID: (llmRes as any)?.sessionID, note: data.note, at: Date.now() }
|
|
804
825
|
try { fs.writeFileSync(respPath, JSON.stringify(out), "utf8"); slog("classify response written", data.id, inferred.join(",")) } catch {}
|
|
805
826
|
}
|
|
827
|
+
const sweep = () => {
|
|
828
|
+
try {
|
|
829
|
+
for (const f of fs.readdirSync(dir).filter(f => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
|
|
830
|
+
void processClassify(f)
|
|
831
|
+
}
|
|
832
|
+
} catch {}
|
|
833
|
+
}
|
|
806
834
|
// Initial sweep
|
|
835
|
+
sweep()
|
|
807
836
|
try {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
try {
|
|
813
|
-
const w = fs.watch(dir, (_e, filename) => { if (filename) void processClassify(filename) })
|
|
837
|
+
// Re-scan on ANY directory event rather than trusting the reported filename — atomic tmp+rename
|
|
838
|
+
// writes (used by the TUI) can report a different filename (or none) on some fs.watch backends
|
|
839
|
+
// (notably macOS FSEvents), which would otherwise silently drop classify requests.
|
|
840
|
+
const w = fs.watch(dir, () => sweep())
|
|
814
841
|
w.on("error", () => {})
|
|
815
842
|
// Keep watcher alive; store to avoid GC? No need.
|
|
816
843
|
} catch {}
|
|
@@ -1045,9 +1072,9 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1045
1072
|
tool: {
|
|
1046
1073
|
// ── quiz: graded question ────────────────────────────────────────
|
|
1047
1074
|
quiz: tool({
|
|
1048
|
-
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 correct answer, and shows explanation. 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.",
|
|
1075
|
+
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.",
|
|
1049
1076
|
args: {
|
|
1050
|
-
question: tool.schema.string().describe("Single quiz question to ask.
|
|
1077
|
+
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."),
|
|
1051
1078
|
details: tool.schema.string().optional().describe("Extra context shown under question."),
|
|
1052
1079
|
options: tool.schema.array(tool.schema.object({
|
|
1053
1080
|
label: tool.schema.string().describe("Display label"),
|
|
@@ -1130,7 +1157,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1130
1157
|
}
|
|
1131
1158
|
}
|
|
1132
1159
|
if (tuiAlive) {
|
|
1133
|
-
return `[quiz displayed in TUI
|
|
1160
|
+
return `[quiz displayed in TUI - waiting for the user's answer in the popup. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answer will be injected as a new turn.]`
|
|
1134
1161
|
}
|
|
1135
1162
|
// ── Fallback: console TTY (NEVER inside opencode TUI — readline steals raw mode + mouse SGR `^[[<35;...M` and garbles alt-screen)
|
|
1136
1163
|
// Inside opencode `OPENCODE=1` is always set, so skip readline and use instruction fallback that works with native `question` tool.
|
|
@@ -1190,7 +1217,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1190
1217
|
|
|
1191
1218
|
// ── quiz_batch: optional deck — quiz 1/3 → 2/3 → 3/3 in one dialog, one inject
|
|
1192
1219
|
quiz_batch: tool({
|
|
1193
|
-
description: "Batch version of quiz
|
|
1220
|
+
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.",
|
|
1194
1221
|
args: {
|
|
1195
1222
|
quizzes: tool.schema.array(tool.schema.object({
|
|
1196
1223
|
question: tool.schema.string(),
|
|
@@ -1280,8 +1307,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1280
1307
|
return `[quiz_batch answered] ${normalized.length} quizzes\n` + lines
|
|
1281
1308
|
})
|
|
1282
1309
|
slog("quiz_batch watchAndInject armed", id, "alive", isAlive)
|
|
1283
|
-
if (isAlive) return `[quiz batch displayed in TUI
|
|
1284
|
-
else return `[quiz batch
|
|
1310
|
+
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.]`
|
|
1311
|
+
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.]`
|
|
1285
1312
|
}
|
|
1286
1313
|
}),
|
|
1287
1314
|
|
package/skills/teach/SKILL.md
CHANGED
|
@@ -72,6 +72,10 @@ The two principles are *how* you teach. This is *when* — the shape of a teachi
|
|
|
72
72
|
|
|
73
73
|
**Accuracy is non-negotiable — verify, don't wing it from memory.** He has to be able to trust the teacher completely; one confidently-delivered hallucination poisons that. Working from memory alone is where LLMs invent things, so: **the moment you are even slightly unsure of any fact, name, date, formula, definition, or claim, stop and confirm it with a quick `researcher` subagent (via `task` with `subagent_type: "researcher"`) before you say it.** Pausing to verify is always acceptable — accuracy beats flow, every time. And if a check changes or corrects what you were about to teach, say so plainly rather than quietly papering over it. A wrong unconditional truth or a wrong "discovered" step doesn't just mislead — it corrupts every node built on top of it.
|
|
74
74
|
|
|
75
|
+
### Interactive turns — one at a time
|
|
76
|
+
|
|
77
|
+
**Interactive turns are serialized.** `quiz` is asynchronous in the OpenCode TUI: it returns control to the agent while its popup is still waiting for the learner. Therefore, call `quiz` **alone** in an assistant turn. Never call it in parallel with native `question`, `quiz_batch`, another `quiz`, or any other user-input tool. When its result says the quiz is displayed/waiting, end the turn immediately without asking another question in prose; the learner's answer will be injected as a new turn. Apply the same rule to `quiz_batch`. Only use native `question` after a quiz call when the quiz result explicitly requests that fallback because the TUI is unavailable.
|
|
78
|
+
|
|
75
79
|
### Writing quiz options — a construction procedure (applies to every `quiz`)
|
|
76
80
|
|
|
77
81
|
The tool already tells you to keep options even. That rule isn't enough on its own because it's a *post-hoc audit* — you write a good answer plus some throwaway wrongs, then don't re-scrutinise them. The tell is baked in before any check runs. So don't audit afterwards; **build the options so evenness is automatic**:
|
|
@@ -85,9 +89,11 @@ If, reading the finished set cold, you can still tell which is right without kno
|
|
|
85
89
|
|
|
86
90
|
### Phase 1 — Probe (never skip this)
|
|
87
91
|
|
|
88
|
-
You can't teach into his zone of proximal development without knowing where its edges are, and you can't aim the teaching without knowing what he's actually reaching for. Two separate unknowns, two separate tools — keep the boundary clean
|
|
92
|
+
You can't teach into his zone of proximal development without knowing where its edges are, and you can't aim the teaching without knowing what he's actually reaching for. Two separate unknowns, two separate tools, and separate turns — keep the boundary clean. Resolve the goal first so you know which prerequisite strands are relevant, then probe those strands. Never launch the goal question and a quiz together.
|
|
89
93
|
|
|
90
|
-
**1a. His
|
|
94
|
+
**1a. His learning goal — use native `question` only when needed.** Find out what he actually wants taught. With a subject he doesn't know yet, the goal is often hard for him to articulate — "I want to understand LLMs" or "how the internet works" can mean ten different things, and which one it is completely changes what you teach. If his request already makes the desired outcome concrete, accept it and do not ask again. Otherwise, interrogate the vision until it's concrete. This has no right answer, so it's `question`, never `quiz`. Do not use `quiz` or `quiz_batch` for the goal — goal has no correct answer. Wait for this answer before starting 1b.
|
|
95
|
+
|
|
96
|
+
**1b. His current level — use `quiz`. This is a mapping job, not a spot-check.** Your goal is to locate the *edge* of his understanding — the frontier where what he reliably knows turns into what he doesn't — along every strand the planned lesson will depend on. Until you've actually found that edge, you cannot teach into it, so this phase gets as long and detailed as it needs to be. There is no rush.
|
|
91
97
|
|
|
92
98
|
**The edge is only located when it's bracketed.** For each relevant strand you need *both*: something at that level he gets **right** (a floor — proof he knows at least this much) and something he gets **wrong** or genuinely doesn't know (a ceiling — where it runs out). The edge sits between them. One side alone tells you almost nothing.
|
|
93
99
|
|
|
@@ -98,9 +104,7 @@ You can't teach into his zone of proximal development without knowing where its
|
|
|
98
104
|
|
|
99
105
|
Do not advance to Phase 2 until, for each goal-relevant strand, you can state concretely both what he has and where it ends. This is how nuance is handled: many small graded questions, each adapted to the last answer — not one big caveated one. Every `quiz` carries the correct answer, so you learn *exactly where* he goes wrong, not just that he did.
|
|
100
106
|
|
|
101
|
-
**Guardrail — one quiz at a time:** Call exactly **one** `quiz` per turn and
|
|
102
|
-
|
|
103
|
-
**1b. His learning goal — use native `question`.** Find out what he actually wants taught. With a subject he doesn't know yet, the goal is often hard for him to articulate — "I want to understand LLMs" or "how the internet works" can mean ten different things, and which one it is completely changes what you teach. Interrogate the vision until it's concrete. This has no right answer, so it's `question`, never `quiz`. Do not use `quiz` or `quiz_batch` for the goal — goal has no correct answer.
|
|
107
|
+
**Guardrail — one quiz at a time:** Call exactly **one** `quiz` per turn, call no other user-input tool in that turn, and stop immediately after the quiz reports that it is waiting. Wait for the user's answer (injected via the TUI) before the next probe. Never call `quiz_batch` or multiple `quiz` in parallel for Phase 1 — the next question must adapt to the last answer.
|
|
104
108
|
|
|
105
109
|
### Phase 2 — Plan (think hard here)
|
|
106
110
|
|
|
@@ -108,7 +112,7 @@ This is the highest-leverage step; don't rush it. With his level and his goal no
|
|
|
108
112
|
|
|
109
113
|
- **Scope the field first with a `researcher` subagent.** Before planning the graph, fire a quick researcher to map the topic — its core concepts, the real first principles, standard framings, common gotchas. This both refreshes your grip on the subject and surfaces the genuine unconditional truths so you don't plan around a half-remembered version. Cheap, and it makes the whole plan more accurate.
|
|
110
114
|
- What are the unconditional truths this rests on? Is there a clean atomic unit ("ALL X is done through {____}")?
|
|
111
|
-
- Which of those does he already hold (from Phase
|
|
115
|
+
- Which of those does he already hold (from Phase 1b)? Build from there — not below it, not above it.
|
|
112
116
|
- What's the motivated discovery path from those truths to his goal? Where does each step come from — why would anyone reach for it?
|
|
113
117
|
- Socratic or expository for each stretch, given the topic and his energy?
|
|
114
118
|
|
|
@@ -116,7 +120,7 @@ A good plan is what makes the teaching feel inevitable instead of arbitrary.
|
|
|
116
120
|
|
|
117
121
|
**Then present the plan in chat — always, before any teaching.** Two parts:
|
|
118
122
|
|
|
119
|
-
1. **The approach, in prose.** What we'll cover, in what order, and why this way — given
|
|
123
|
+
1. **The approach, in prose.** What we'll cover, in what order, and why this way — given what he's reaching for (Phase 1a) and where his edge sits (Phase 1b). A few freeform sentences.
|
|
120
124
|
2. **The dependency map.** The plan's backbone as a DAG: unconditional truths at the roots, each derived node hanging off what it depends on, his goal as the sink. Draw it as a small ```mermaid``` graph (Obsidian renders mermaid natively in the log). This map *is* the teaching order — Phase 3 builds it node by node. Keep it small: few nodes, short labels — a map, not the territory.
|
|
121
125
|
|
|
122
126
|
**Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow.
|