@bojackduy/opencode-learn 1.2.1 → 1.2.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 +302 -141
- package/dist/tui.js +14 -14
- package/package.json +2 -2
- package/plugins/learn.ts +266 -106
package/dist/server.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var __require = import.meta.require;
|
|
3
|
-
|
|
4
2
|
// plugins/learn.ts
|
|
5
3
|
import { tool } from "@opencode-ai/plugin";
|
|
6
4
|
import * as fs from "fs";
|
|
@@ -159,30 +157,86 @@ function decodeQuizText(s) {
|
|
|
159
157
|
out = out.replace(/\\\\/g, "\\");
|
|
160
158
|
return out;
|
|
161
159
|
}
|
|
162
|
-
var
|
|
163
|
-
var
|
|
164
|
-
function
|
|
165
|
-
const prev =
|
|
160
|
+
var mdLinks = new Map;
|
|
161
|
+
var mdFileLocks = new Map;
|
|
162
|
+
function withMdFileLock(file, fn) {
|
|
163
|
+
const prev = mdFileLocks.get(file) ?? Promise.resolve();
|
|
166
164
|
let release;
|
|
167
|
-
|
|
165
|
+
const next = new Promise((r) => {
|
|
168
166
|
release = r;
|
|
169
167
|
});
|
|
168
|
+
mdFileLocks.set(file, next);
|
|
170
169
|
return prev.then(fn).finally(() => release());
|
|
171
170
|
}
|
|
172
|
-
function
|
|
173
|
-
if (!
|
|
171
|
+
function getMdFile(sessionID) {
|
|
172
|
+
if (!sessionID)
|
|
173
|
+
return;
|
|
174
|
+
return mdLinks.get(sessionID)?.file;
|
|
175
|
+
}
|
|
176
|
+
function appendToMdLogForSession(sessionID, text) {
|
|
177
|
+
const file = getMdFile(sessionID);
|
|
178
|
+
if (!file || !sessionID)
|
|
174
179
|
return;
|
|
175
180
|
try {
|
|
176
181
|
let current = "";
|
|
177
|
-
if (fs.existsSync(
|
|
178
|
-
current = fs.readFileSync(
|
|
182
|
+
if (fs.existsSync(file))
|
|
183
|
+
current = fs.readFileSync(file, "utf-8");
|
|
179
184
|
const prefix = current.trim().length > 0 ? `
|
|
180
185
|
|
|
181
186
|
` : "";
|
|
182
|
-
fs.writeFileSync(
|
|
187
|
+
fs.writeFileSync(file, current + prefix + text + `
|
|
183
188
|
`, "utf-8");
|
|
184
189
|
} catch {}
|
|
185
190
|
}
|
|
191
|
+
function loadMdLinks(markerPath, directory) {
|
|
192
|
+
try {
|
|
193
|
+
if (!fs.existsSync(markerPath))
|
|
194
|
+
return 0;
|
|
195
|
+
const data = JSON.parse(fs.readFileSync(markerPath, "utf-8"));
|
|
196
|
+
if (data && typeof data.file === "string" && !data.links) {
|
|
197
|
+
try {
|
|
198
|
+
fs.writeFileSync(markerPath + ".bak", JSON.stringify(data), "utf-8");
|
|
199
|
+
} catch {}
|
|
200
|
+
try {
|
|
201
|
+
fs.writeFileSync(markerPath, JSON.stringify({ version: 1, links: {} }), "utf-8");
|
|
202
|
+
} catch {}
|
|
203
|
+
try {
|
|
204
|
+
slog("md-log legacy marker backed up, starting empty 1-1-1", markerPath);
|
|
205
|
+
} catch {}
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
const links = data?.links ?? {};
|
|
209
|
+
let n = 0;
|
|
210
|
+
for (const [ses, v] of Object.entries(links)) {
|
|
211
|
+
const f = v?.file ?? (typeof v === "string" ? v : undefined);
|
|
212
|
+
if (typeof ses === "string" && typeof f === "string" && fs.existsSync(f)) {
|
|
213
|
+
mdLinks.set(ses, { file: f, directory: v?.directory || directory, linkedAt: v?.linkedAt || Date.now() });
|
|
214
|
+
n++;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return n;
|
|
218
|
+
} catch {
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function saveMdLinksForDirectory(markerPath, directory) {
|
|
223
|
+
try {
|
|
224
|
+
const out = {};
|
|
225
|
+
for (const [ses, meta] of mdLinks) {
|
|
226
|
+
if (meta.directory === directory)
|
|
227
|
+
out[ses] = meta;
|
|
228
|
+
}
|
|
229
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
230
|
+
fs.writeFileSync(markerPath, JSON.stringify({ version: 1, links: out }), "utf-8");
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
function extractHookSessionID(...candidates) {
|
|
234
|
+
for (const c of candidates) {
|
|
235
|
+
if (typeof c === "string" && c.length > 0)
|
|
236
|
+
return c;
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
186
240
|
function callout(type, title, bodyLines) {
|
|
187
241
|
const lines = [`> [!${type}] ${title}`];
|
|
188
242
|
for (const line of bodyLines)
|
|
@@ -263,6 +317,20 @@ function answerCalloutQuiz(details) {
|
|
|
263
317
|
}
|
|
264
318
|
return callout(type, title, body);
|
|
265
319
|
}
|
|
320
|
+
function answerCalloutQuestion(questions, answers) {
|
|
321
|
+
const qs = Array.isArray(questions) ? questions : [];
|
|
322
|
+
const ans = Array.isArray(answers) ? answers : [];
|
|
323
|
+
if (!qs.length) {
|
|
324
|
+
const flat = ans.map((a) => Array.isArray(a) ? a.join(", ") : String(a ?? "")).filter(Boolean);
|
|
325
|
+
return callout("example", "Answer", flat.length ? flat : ["(no answer)"]);
|
|
326
|
+
}
|
|
327
|
+
const body = qs.map((q, i) => {
|
|
328
|
+
const header = q?.header || `Q${i + 1}`;
|
|
329
|
+
const sel = Array.isArray(ans[i]) ? ans[i] : [];
|
|
330
|
+
return `${header}: ${sel.length ? sel.join(", ") : "(no answer)"}`;
|
|
331
|
+
});
|
|
332
|
+
return callout("example", "Answer", body);
|
|
333
|
+
}
|
|
266
334
|
function answerCalloutAsk(details) {
|
|
267
335
|
const status = details?.status;
|
|
268
336
|
if (status === "cancelled")
|
|
@@ -270,11 +338,17 @@ function answerCalloutAsk(details) {
|
|
|
270
338
|
if (status === "unavailable")
|
|
271
339
|
return callout("warning", "Question \u2014 unavailable", [details?.message || ""]);
|
|
272
340
|
const answers = details?.answers || [];
|
|
341
|
+
if (answers.length && answers.every((a) => Array.isArray(a))) {
|
|
342
|
+
const questions = details?.questions || [];
|
|
343
|
+
return answerCalloutQuestion(questions, answers);
|
|
344
|
+
}
|
|
273
345
|
const body = answers.map((a) => {
|
|
274
346
|
if (a.type === "other")
|
|
275
347
|
return `Other: ${a.label}`;
|
|
276
348
|
if (a.type === "text")
|
|
277
349
|
return a.label;
|
|
350
|
+
if (typeof a === "string")
|
|
351
|
+
return a;
|
|
278
352
|
return `${a.index}. ${a.label}`;
|
|
279
353
|
});
|
|
280
354
|
if (body.length === 0)
|
|
@@ -282,7 +356,8 @@ function answerCalloutAsk(details) {
|
|
|
282
356
|
return callout("example", "Answer", body);
|
|
283
357
|
}
|
|
284
358
|
async function backfillMdLog(client, sessionID, directory) {
|
|
285
|
-
|
|
359
|
+
const mdFile = getMdFile(sessionID);
|
|
360
|
+
if (!mdFile || !sessionID)
|
|
286
361
|
return 0;
|
|
287
362
|
try {
|
|
288
363
|
const res = await client.session.messages({ path: { id: sessionID }, query: { directory } });
|
|
@@ -346,6 +421,25 @@ async function backfillMdLog(client, sessionID, directory) {
|
|
|
346
421
|
}
|
|
347
422
|
continue;
|
|
348
423
|
}
|
|
424
|
+
if (toolName === "question" && Array.isArray(input.questions)) {
|
|
425
|
+
const qs = input.questions;
|
|
426
|
+
if (st.status === "pending" || st.status === "running") {
|
|
427
|
+
qs.forEach((q, i) => {
|
|
428
|
+
if (!q?.question)
|
|
429
|
+
return;
|
|
430
|
+
blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []));
|
|
431
|
+
});
|
|
432
|
+
} else if (st.status === "completed") {
|
|
433
|
+
qs.forEach((q, i) => {
|
|
434
|
+
if (!q?.question)
|
|
435
|
+
return;
|
|
436
|
+
blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []));
|
|
437
|
+
});
|
|
438
|
+
const ans = Array.isArray(meta.answers) ? meta.answers : [];
|
|
439
|
+
blocks.push(answerCalloutAsk({ answers: ans, questions: qs, status: "completed" }));
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
349
443
|
if (st.status === "pending" || st.status === "running") {
|
|
350
444
|
if (input.question) {
|
|
351
445
|
const opts = Array.isArray(input.options) ? input.options : [];
|
|
@@ -372,13 +466,14 @@ async function backfillMdLog(client, sessionID, directory) {
|
|
|
372
466
|
}
|
|
373
467
|
}
|
|
374
468
|
if (blocks.length) {
|
|
469
|
+
const mdFile2 = getMdFile(sessionID) || mdFile;
|
|
375
470
|
let current = "";
|
|
376
471
|
try {
|
|
377
|
-
if (fs.existsSync(
|
|
378
|
-
current = fs.readFileSync(
|
|
472
|
+
if (fs.existsSync(mdFile2))
|
|
473
|
+
current = fs.readFileSync(mdFile2, "utf-8");
|
|
379
474
|
} catch {}
|
|
380
475
|
if (current.trim().length === 0) {
|
|
381
|
-
fs.writeFileSync(
|
|
476
|
+
fs.writeFileSync(mdFile2, blocks.join(`
|
|
382
477
|
|
|
383
478
|
`) + `
|
|
384
479
|
`, "utf-8");
|
|
@@ -386,7 +481,7 @@ async function backfillMdLog(client, sessionID, directory) {
|
|
|
386
481
|
const prefix = current.trim().length > 0 ? `
|
|
387
482
|
|
|
388
483
|
` : "";
|
|
389
|
-
fs.writeFileSync(
|
|
484
|
+
fs.writeFileSync(mdFile2, current + prefix + blocks.join(`
|
|
390
485
|
|
|
391
486
|
`) + `
|
|
392
487
|
`, "utf-8");
|
|
@@ -514,17 +609,16 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
514
609
|
var server = async ({ client, directory }) => {
|
|
515
610
|
const markerPath = path.join(directory, ".opencode", "learn-md-log.json");
|
|
516
611
|
try {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
mdLogFile = data.file;
|
|
521
|
-
}
|
|
612
|
+
const n = loadMdLinks(markerPath, directory);
|
|
613
|
+
if (n)
|
|
614
|
+
slog("md-log links restored", n, markerPath);
|
|
522
615
|
} catch {}
|
|
523
616
|
let mermaidSession = null;
|
|
524
617
|
let svgSession = null;
|
|
525
618
|
const loggedTextPartIds = new Set;
|
|
526
619
|
const loggedToolCallIds = new Set;
|
|
527
620
|
const messageIdToRole = new Map;
|
|
621
|
+
const mdKey = (ses, id) => `${ses || "?"}:${id}`;
|
|
528
622
|
function heuristicClassify(note, options, multiSelect) {
|
|
529
623
|
const n = note.toLowerCase();
|
|
530
624
|
const scored = [];
|
|
@@ -554,7 +648,7 @@ var server = async ({ client, directory }) => {
|
|
|
554
648
|
}
|
|
555
649
|
return uniq;
|
|
556
650
|
}
|
|
557
|
-
async function llmClassify(
|
|
651
|
+
async function llmClassify(client, directory, note, options, question, parentSessionID, multiSelect) {
|
|
558
652
|
const modeHint = multiSelect ? "This is a MULTI-SELECT question (0..N options may be correct). You may return 0..N inferred indices." : "This is a SINGLE-SELECT question (exactly 0 or 1 inferred). You MUST return at most ONE inferred index. Never return multiple. If note is ambiguous or mentions several options, pick the SINGLE best match. Return [] if vague.";
|
|
559
653
|
const idkHint = `Also detect IDK intent: if note says "I don't know / idk / too hard / too difficult / need easier / want easier / skip / give me easier/harder" or expresses wanting difficulty adjustment, set "isIDK": true (and keep inferred as [] or best guess). Otherwise isIDK false. The main teacher will use this to adapt difficulty.`;
|
|
560
654
|
const prompt = `Map learner's free-text note (may be Vietnamese or English) to closest option(s) and judge semantic correctness. Only pick from given Options, no new options. ${modeHint} ${idkHint}
|
|
@@ -574,7 +668,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
574
668
|
const body = { title };
|
|
575
669
|
if (parentSessionID)
|
|
576
670
|
body.parentID = parentSessionID;
|
|
577
|
-
const created = await
|
|
671
|
+
const created = await client.session.create({ body, query: { directory } });
|
|
578
672
|
const sid = created?.data?.id || created?.id || created?.data?.sessionID;
|
|
579
673
|
if (!sid)
|
|
580
674
|
throw new Error("no sid");
|
|
@@ -583,24 +677,24 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
583
677
|
if (parentSessionID && createdSession?.parentID !== parentSessionID) {
|
|
584
678
|
throw new Error(`classifier parent mismatch: expected ${parentSessionID}, got ${createdSession?.parentID || "none"}`);
|
|
585
679
|
}
|
|
586
|
-
await
|
|
680
|
+
await client.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text", text: prompt }], agent: "classify" } });
|
|
587
681
|
for (let i = 0;i < 24; i++) {
|
|
588
682
|
await new Promise((r) => setTimeout(r, 500));
|
|
589
683
|
try {
|
|
590
|
-
const msgs = await
|
|
684
|
+
const msgs = await client.session.messages({ path: { id: sid } });
|
|
591
685
|
const data = msgs?.data || msgs;
|
|
592
686
|
const arr = Array.isArray(data) ? data : [];
|
|
593
687
|
for (let j = arr.length - 1;j >= 0; j--) {
|
|
594
688
|
const entry = arr[j];
|
|
595
689
|
if (entry?.info?.role === "assistant") {
|
|
596
690
|
const text = (entry.parts || []).filter((p) => p.type === "text").map((p) => p.text).join(" ") || "";
|
|
597
|
-
const enforceSingle = (
|
|
598
|
-
if (!multiSelect &&
|
|
599
|
-
const trimmed = [
|
|
600
|
-
slog("llmClassify enforce single",
|
|
691
|
+
const enforceSingle = (arr) => {
|
|
692
|
+
if (!multiSelect && arr.length > 1) {
|
|
693
|
+
const trimmed = [arr[0]];
|
|
694
|
+
slog("llmClassify enforce single", arr.join(","), "->", trimmed.join(","), multiSelect ? "multi" : "single");
|
|
601
695
|
return trimmed;
|
|
602
696
|
}
|
|
603
|
-
return
|
|
697
|
+
return arr;
|
|
604
698
|
};
|
|
605
699
|
const noteIsIDK = (() => {
|
|
606
700
|
const n = note.toLowerCase();
|
|
@@ -657,8 +751,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
657
751
|
}
|
|
658
752
|
return { inferred: [] };
|
|
659
753
|
}
|
|
660
|
-
function startClassifyWatcher(
|
|
661
|
-
const dir = pendingDir(
|
|
754
|
+
function startClassifyWatcher(client, directory) {
|
|
755
|
+
const dir = pendingDir(directory);
|
|
662
756
|
try {
|
|
663
757
|
fs.mkdirSync(dir, { recursive: true });
|
|
664
758
|
} catch {}
|
|
@@ -687,7 +781,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
687
781
|
let reason;
|
|
688
782
|
let isIDK;
|
|
689
783
|
const multi = !!data.multiSelect;
|
|
690
|
-
const llmRes = await llmClassify(
|
|
784
|
+
const llmRes = await llmClassify(client, directory, data.note, data.options, data.question, data.sessionID, multi);
|
|
691
785
|
isIDK = llmRes.isIDK;
|
|
692
786
|
if (!isIDK) {
|
|
693
787
|
const n = data.note.toLowerCase();
|
|
@@ -752,7 +846,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
752
846
|
} catch {}
|
|
753
847
|
};
|
|
754
848
|
try {
|
|
755
|
-
for (const f of fs.readdirSync(dir).filter((
|
|
849
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
|
|
756
850
|
processClassify(f);
|
|
757
851
|
}
|
|
758
852
|
} catch {}
|
|
@@ -782,9 +876,11 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
782
876
|
const ok = !dk && si.length === (j.correctIndices || []).length && si.every((i) => cs.has(i));
|
|
783
877
|
const note = r?.note ? `
|
|
784
878
|
Note: ${r.note}` : "";
|
|
785
|
-
if (
|
|
879
|
+
if (getMdFile(j.sessionID)) {
|
|
786
880
|
const details = { status: "completed", answers: r?.answers || [], correct: ok, correctIndices: j.correctIndices || [], explanation: j.explanation, dontKnow: dk, note: r?.note };
|
|
787
|
-
|
|
881
|
+
const sesJ = j.sessionID;
|
|
882
|
+
const fJ = getMdFile(sesJ);
|
|
883
|
+
withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)));
|
|
788
884
|
}
|
|
789
885
|
return dk ? `[quiz answered] "${j.question}" -> I don't know.
|
|
790
886
|
Correct: ${cstr}
|
|
@@ -793,12 +889,14 @@ Correct: ${cstr}
|
|
|
793
889
|
Explanation: ${j.explanation}${note}`;
|
|
794
890
|
} else if (j.type === "quiz_batch") {
|
|
795
891
|
const results = r?.results || [];
|
|
796
|
-
if (
|
|
892
|
+
if (getMdFile(j.sessionID)) {
|
|
797
893
|
for (let i = 0;i < (j.quizzes || []).length; i++) {
|
|
798
894
|
const qq = j.quizzes[i];
|
|
799
895
|
const x = results[i] || {};
|
|
800
896
|
const details = { status: "completed", answers: x.answers || [], correct: !!x.correct, correctIndices: qq.correctIndices || [], explanation: qq.explanation || "", dontKnow: !!x.dontKnow, note: x.note };
|
|
801
|
-
|
|
897
|
+
const sesJ = j.sessionID;
|
|
898
|
+
const fJ = getMdFile(sesJ);
|
|
899
|
+
withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)));
|
|
802
900
|
}
|
|
803
901
|
}
|
|
804
902
|
const lines = (j.quizzes || []).map((qq, i) => {
|
|
@@ -836,10 +934,12 @@ Explanation: ${j.explanation}${note}`;
|
|
|
836
934
|
output.agent = agents;
|
|
837
935
|
await client.app.log({ body: { service: "learn", level: "info", message: "learn plugin initialized", extra: { directory } } });
|
|
838
936
|
},
|
|
839
|
-
"chat.message": async (
|
|
840
|
-
if (!mdLogFile)
|
|
841
|
-
return;
|
|
937
|
+
"chat.message": async (input, output) => {
|
|
842
938
|
try {
|
|
939
|
+
const ses = extractHookSessionID(input?.sessionID, output?.message?.sessionID);
|
|
940
|
+
const mdFile = getMdFile(ses);
|
|
941
|
+
if (!mdFile || !ses)
|
|
942
|
+
return;
|
|
843
943
|
const msg = output.message;
|
|
844
944
|
const parts = output.parts ?? [];
|
|
845
945
|
let text = "";
|
|
@@ -857,70 +957,91 @@ Explanation: ${j.explanation}${note}`;
|
|
|
857
957
|
if (/^\[(quiz|quiz_batch|question) (answered|cancelled)\]/i.test(text) || text.startsWith("[quiz answered]") || text.startsWith("[quiz_batch answered]") || text.startsWith("[question answered]"))
|
|
858
958
|
return;
|
|
859
959
|
const mid = msg?.id ? `msg:${msg.id}` : `chat:${Date.now()}`;
|
|
860
|
-
|
|
960
|
+
const mkey = mdKey(ses, mid);
|
|
961
|
+
if (loggedTextPartIds.has(mkey))
|
|
861
962
|
return;
|
|
862
|
-
loggedTextPartIds.add(
|
|
863
|
-
await
|
|
963
|
+
loggedTextPartIds.add(mkey);
|
|
964
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, userBlock(text)));
|
|
864
965
|
} catch {}
|
|
865
966
|
},
|
|
866
967
|
"experimental.text.complete": async (input, output) => {
|
|
867
|
-
if (!mdLogFile)
|
|
868
|
-
return;
|
|
869
968
|
try {
|
|
969
|
+
const ses = extractHookSessionID(input?.sessionID);
|
|
970
|
+
const mdFile = getMdFile(ses);
|
|
971
|
+
if (!mdFile || !ses)
|
|
972
|
+
return;
|
|
870
973
|
const text = output.text?.trim();
|
|
871
974
|
if (!text)
|
|
872
975
|
return;
|
|
873
976
|
const partID = input.partID;
|
|
874
|
-
|
|
977
|
+
const pkey = partID ? mdKey(ses, partID) : undefined;
|
|
978
|
+
if (pkey && loggedTextPartIds.has(pkey))
|
|
875
979
|
return;
|
|
876
|
-
if (
|
|
877
|
-
loggedTextPartIds.add(
|
|
878
|
-
await
|
|
980
|
+
if (pkey)
|
|
981
|
+
loggedTextPartIds.add(pkey);
|
|
982
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))));
|
|
879
983
|
} catch {}
|
|
880
984
|
},
|
|
881
|
-
"tool.execute.before": async (input) => {
|
|
882
|
-
if (!mdLogFile)
|
|
883
|
-
return;
|
|
985
|
+
"tool.execute.before": async (input, output) => {
|
|
884
986
|
try {
|
|
987
|
+
const ses = extractHookSessionID(input?.sessionID);
|
|
988
|
+
const mdFile = getMdFile(ses);
|
|
989
|
+
if (!mdFile || !ses)
|
|
990
|
+
return;
|
|
885
991
|
const toolName = input.tool;
|
|
886
|
-
const args = input.args ?? {};
|
|
992
|
+
const args = output?.args ?? input.args ?? {};
|
|
887
993
|
if (toolName === "question") {
|
|
888
|
-
const q = args.question || args.header || "";
|
|
889
|
-
const ctx2 = args.details?.trim() || undefined;
|
|
890
|
-
const opts = Array.isArray(args.options) ? args.options : [];
|
|
891
994
|
const callID = input.callID;
|
|
892
|
-
|
|
995
|
+
const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined;
|
|
996
|
+
if (qkey && loggedToolCallIds.has(qkey))
|
|
893
997
|
return;
|
|
894
|
-
if (
|
|
895
|
-
loggedToolCallIds.add(
|
|
896
|
-
|
|
897
|
-
|
|
998
|
+
if (qkey)
|
|
999
|
+
loggedToolCallIds.add(qkey);
|
|
1000
|
+
const qs = Array.isArray(args.questions) ? args.questions : args.question ? [{ question: args.question, header: args.header, options: args.options ?? [] }] : [];
|
|
1001
|
+
for (let i = 0;i < qs.length; i++) {
|
|
1002
|
+
const q = qs[i];
|
|
1003
|
+
if (!q?.question)
|
|
1004
|
+
continue;
|
|
1005
|
+
const label = q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question");
|
|
1006
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout(label, q.question, undefined, q.options ?? [])));
|
|
1007
|
+
}
|
|
898
1008
|
}
|
|
899
1009
|
} catch {}
|
|
900
1010
|
},
|
|
901
1011
|
"tool.execute.after": async (input, output) => {
|
|
902
|
-
if (!mdLogFile)
|
|
903
|
-
return;
|
|
904
1012
|
try {
|
|
1013
|
+
const ses = extractHookSessionID(input?.sessionID);
|
|
1014
|
+
const mdFile = getMdFile(ses);
|
|
1015
|
+
if (!mdFile || !ses)
|
|
1016
|
+
return;
|
|
905
1017
|
const toolName = input.tool;
|
|
906
1018
|
const callID = input.callID;
|
|
907
|
-
|
|
1019
|
+
const akey = callID ? mdKey(ses, `answer:${callID}`) : undefined;
|
|
1020
|
+
if (akey && loggedToolCallIds.has(akey))
|
|
908
1021
|
return;
|
|
909
1022
|
if (toolName === "question") {
|
|
910
|
-
const meta = output
|
|
911
|
-
|
|
912
|
-
|
|
1023
|
+
const meta = output?.metadata ?? {};
|
|
1024
|
+
const inArgs = input?.args ?? {};
|
|
1025
|
+
const qs = Array.isArray(inArgs.questions) ? inArgs.questions : [];
|
|
1026
|
+
let answers = meta.answers;
|
|
1027
|
+
if (!Array.isArray(answers) || !answers.length) {
|
|
1028
|
+
const outText = typeof output?.output === "string" ? output.output.trim() : "";
|
|
1029
|
+
if (outText) {
|
|
1030
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, callout("example", "Answer", [outText.slice(0, 500)])));
|
|
1031
|
+
if (akey)
|
|
1032
|
+
loggedToolCallIds.add(akey);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
913
1035
|
answers = [];
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1036
|
+
}
|
|
1037
|
+
const details = { answers, questions: qs, status: "completed" };
|
|
1038
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)));
|
|
1039
|
+
if (akey)
|
|
1040
|
+
loggedToolCallIds.add(akey);
|
|
918
1041
|
}
|
|
919
1042
|
} catch {}
|
|
920
1043
|
},
|
|
921
1044
|
event: async ({ event }) => {
|
|
922
|
-
if (!mdLogFile)
|
|
923
|
-
return;
|
|
924
1045
|
const t = event.type;
|
|
925
1046
|
const props = event.properties ?? {};
|
|
926
1047
|
try {
|
|
@@ -928,18 +1049,24 @@ Explanation: ${j.explanation}${note}`;
|
|
|
928
1049
|
const info = props.info;
|
|
929
1050
|
if (info?.id && info?.role)
|
|
930
1051
|
messageIdToRole.set(info.id, info.role);
|
|
1052
|
+
return;
|
|
931
1053
|
} else if (t === "message.part.updated") {
|
|
932
1054
|
const part = props.part;
|
|
933
1055
|
const delta = props.delta;
|
|
934
1056
|
if (!part || !part.id)
|
|
935
1057
|
return;
|
|
1058
|
+
const ses = extractHookSessionID(part.sessionID, props?.sessionID, props.info?.sessionID);
|
|
1059
|
+
const mdFile = getMdFile(ses);
|
|
1060
|
+
if (!mdFile || !ses)
|
|
1061
|
+
return;
|
|
936
1062
|
if (part.type === "text") {
|
|
937
1063
|
if (part.synthetic || part.ignored)
|
|
938
1064
|
return;
|
|
939
1065
|
const isFinal = !!(part.time?.end !== undefined) || delta === undefined;
|
|
940
1066
|
if (!isFinal)
|
|
941
1067
|
return;
|
|
942
|
-
|
|
1068
|
+
const pkey = mdKey(ses, part.id);
|
|
1069
|
+
if (loggedTextPartIds.has(pkey))
|
|
943
1070
|
return;
|
|
944
1071
|
const text = (part.text || "").trim();
|
|
945
1072
|
if (!text)
|
|
@@ -947,8 +1074,8 @@ Explanation: ${j.explanation}${note}`;
|
|
|
947
1074
|
const role = messageIdToRole.get(part.messageID);
|
|
948
1075
|
if (role === "user")
|
|
949
1076
|
return;
|
|
950
|
-
loggedTextPartIds.add(
|
|
951
|
-
await
|
|
1077
|
+
loggedTextPartIds.add(pkey);
|
|
1078
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))));
|
|
952
1079
|
}
|
|
953
1080
|
}
|
|
954
1081
|
} catch {}
|
|
@@ -1018,6 +1145,7 @@ Explanation: ${j.explanation}${note}`;
|
|
|
1018
1145
|
try {
|
|
1019
1146
|
await ctx.metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } });
|
|
1020
1147
|
} catch {}
|
|
1148
|
+
const quizSes = ctx.sessionID;
|
|
1021
1149
|
watchAndInject(client, directory, id, ctx.sessionID, (r) => {
|
|
1022
1150
|
const dk = !!r?.dontKnow;
|
|
1023
1151
|
const sel = (r?.answers || []).map((a) => `${a.index}. ${a.label}`).join(", ") || "(none)";
|
|
@@ -1026,7 +1154,8 @@ Explanation: ${j.explanation}${note}`;
|
|
|
1026
1154
|
const ok = !dk && si.length === correctIndices.length && si.every((i) => cs.has(i));
|
|
1027
1155
|
const note = r?.note ? `
|
|
1028
1156
|
Note: ${r.note}` : "";
|
|
1029
|
-
|
|
1157
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined;
|
|
1158
|
+
if (qf && quizSes) {
|
|
1030
1159
|
const details = {
|
|
1031
1160
|
status: "completed",
|
|
1032
1161
|
answers: r?.answers || [],
|
|
@@ -1036,7 +1165,7 @@ Note: ${r.note}` : "";
|
|
|
1036
1165
|
dontKnow: dk,
|
|
1037
1166
|
note: r?.note
|
|
1038
1167
|
};
|
|
1039
|
-
|
|
1168
|
+
withMdFileLock(qf, () => appendToMdLogForSession(quizSes, answerCalloutQuiz(details)));
|
|
1040
1169
|
}
|
|
1041
1170
|
return dk ? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).
|
|
1042
1171
|
Correct: ${correctStr}
|
|
@@ -1044,10 +1173,13 @@ Explanation: ${eFixed}${note}` : `[quiz answered] "${qFixed}" -> ${sel} = ${ok ?
|
|
|
1044
1173
|
Correct: ${correctStr}
|
|
1045
1174
|
Explanation: ${eFixed}${note}`;
|
|
1046
1175
|
});
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1176
|
+
{
|
|
1177
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined;
|
|
1178
|
+
if (qf && quizSes) {
|
|
1179
|
+
try {
|
|
1180
|
+
await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label })))));
|
|
1181
|
+
} catch {}
|
|
1182
|
+
}
|
|
1051
1183
|
}
|
|
1052
1184
|
if (tuiAlive) {
|
|
1053
1185
|
return `[quiz displayed in TUI \u2014 waiting for your answer in the popup. I'll continue once you respond.]`;
|
|
@@ -1057,21 +1189,21 @@ Explanation: ${eFixed}${note}`;
|
|
|
1057
1189
|
if (isTTY && !insideOpencode) {
|
|
1058
1190
|
const readline = await import("readline");
|
|
1059
1191
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1060
|
-
const abortPromise = new Promise((
|
|
1192
|
+
const abortPromise = new Promise((resolve) => ctx.abort.addEventListener("abort", () => {
|
|
1061
1193
|
try {
|
|
1062
1194
|
rl.close();
|
|
1063
1195
|
} catch {}
|
|
1064
|
-
|
|
1196
|
+
resolve(null);
|
|
1065
1197
|
}, { once: true }));
|
|
1066
1198
|
const promptText = `
|
|
1067
1199
|
[quiz] ${args.question}
|
|
1068
1200
|
${args.details ? args.details + `
|
|
1069
1201
|
` : ""}${display}
|
|
1070
1202
|
${args.multiSelect ? "Select all correct (comma-separated numbers, e.g. 1,3) or 0 for 'I don't know': " : "Select one number or 0 for 'I don't know': "}`;
|
|
1071
|
-
const answerPromise = new Promise((
|
|
1203
|
+
const answerPromise = new Promise((resolve) => {
|
|
1072
1204
|
rl.question(promptText, (ans) => {
|
|
1073
1205
|
rl.close();
|
|
1074
|
-
|
|
1206
|
+
resolve(ans);
|
|
1075
1207
|
});
|
|
1076
1208
|
});
|
|
1077
1209
|
const raw = await Promise.race([answerPromise, abortPromise]);
|
|
@@ -1082,8 +1214,11 @@ ${args.multiSelect ? "Select all correct (comma-separated numbers, e.g. 1,3) or
|
|
|
1082
1214
|
const msg = `User selected "I don't know" \u2014 genuine gap, not a guess.
|
|
1083
1215
|
Correct: ${correctStr}
|
|
1084
1216
|
Explanation: ${eFixed}`;
|
|
1085
|
-
|
|
1086
|
-
|
|
1217
|
+
{
|
|
1218
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined;
|
|
1219
|
+
if (qf && quizSes)
|
|
1220
|
+
await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout("question", "Quiz \u2014 I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])));
|
|
1221
|
+
}
|
|
1087
1222
|
return msg;
|
|
1088
1223
|
}
|
|
1089
1224
|
const nums = trimmed.split(/[,\s]+/).map((s) => parseInt(s, 10)).filter((n) => !isNaN(n) && n >= 1 && n <= options.length);
|
|
@@ -1097,8 +1232,11 @@ Selected: ${selectedStr}
|
|
|
1097
1232
|
Correct: ${correctStr}
|
|
1098
1233
|
Explanation: ${eFixed}`;
|
|
1099
1234
|
ctx.metadata?.({ title: correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", metadata: { correct, correctIndices, explanation: eFixed } });
|
|
1100
|
-
|
|
1101
|
-
|
|
1235
|
+
{
|
|
1236
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined;
|
|
1237
|
+
if (qf && quizSes)
|
|
1238
|
+
await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout(correct ? "success" : "failure", correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])));
|
|
1239
|
+
}
|
|
1102
1240
|
return result;
|
|
1103
1241
|
}
|
|
1104
1242
|
const instruction = [
|
|
@@ -1184,36 +1322,42 @@ Explanation: ${eFixed}`;
|
|
|
1184
1322
|
try {
|
|
1185
1323
|
await ctx.metadata?.({ title: `Quiz batch ${normalized.length}`, metadata: { pendingId: id } });
|
|
1186
1324
|
} catch {}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
try {
|
|
1192
|
-
await withMdLock(() => appendToMdLog(questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o) => ({ label: o.label })))));
|
|
1193
|
-
} catch {}
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
watchAndInject(client, directory, id, ctx.sessionID, (r) => {
|
|
1197
|
-
const results = r?.results || [];
|
|
1198
|
-
if (mdLogFile) {
|
|
1325
|
+
const batchSes = ctx.sessionID;
|
|
1326
|
+
{
|
|
1327
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined;
|
|
1328
|
+
if (bf && batchSes) {
|
|
1199
1329
|
for (let i = 0;i < normalized.length; i++) {
|
|
1200
1330
|
const q = normalized[i];
|
|
1201
|
-
const x = results[i] || {};
|
|
1202
|
-
const details = {
|
|
1203
|
-
status: "completed",
|
|
1204
|
-
answers: x.answers || [],
|
|
1205
|
-
correct: !!x.correct,
|
|
1206
|
-
correctIndices: q.correctIndices || [],
|
|
1207
|
-
explanation: q.explanation || "",
|
|
1208
|
-
dontKnow: !!x.dontKnow,
|
|
1209
|
-
note: x.note
|
|
1210
|
-
};
|
|
1211
1331
|
const label = `Quiz ${i + 1}/${normalized.length}`;
|
|
1212
1332
|
try {
|
|
1213
|
-
|
|
1333
|
+
await withMdFileLock(bf, () => appendToMdLogForSession(batchSes, questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o) => ({ label: o.label })))));
|
|
1214
1334
|
} catch {}
|
|
1215
1335
|
}
|
|
1216
1336
|
}
|
|
1337
|
+
}
|
|
1338
|
+
watchAndInject(client, directory, id, ctx.sessionID, (r) => {
|
|
1339
|
+
const results = r?.results || [];
|
|
1340
|
+
{
|
|
1341
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined;
|
|
1342
|
+
if (bf && batchSes) {
|
|
1343
|
+
for (let i = 0;i < normalized.length; i++) {
|
|
1344
|
+
const q = normalized[i];
|
|
1345
|
+
const x = results[i] || {};
|
|
1346
|
+
const details = {
|
|
1347
|
+
status: "completed",
|
|
1348
|
+
answers: x.answers || [],
|
|
1349
|
+
correct: !!x.correct,
|
|
1350
|
+
correctIndices: q.correctIndices || [],
|
|
1351
|
+
explanation: q.explanation || "",
|
|
1352
|
+
dontKnow: !!x.dontKnow,
|
|
1353
|
+
note: x.note
|
|
1354
|
+
};
|
|
1355
|
+
try {
|
|
1356
|
+
withMdFileLock(bf, () => appendToMdLogForSession(batchSes, answerCalloutQuiz(details)));
|
|
1357
|
+
} catch {}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1217
1361
|
const lines = results.map((x, i) => {
|
|
1218
1362
|
const q = normalized[i];
|
|
1219
1363
|
const cs = (q.correctIndices || []).map((idx) => `${idx}. ${q.options[idx - 1]?.label}`).join(", ");
|
|
@@ -1233,47 +1377,64 @@ Explanation: ${eFixed}`;
|
|
|
1233
1377
|
}
|
|
1234
1378
|
}),
|
|
1235
1379
|
md_log: tool({
|
|
1236
|
-
description: "Mirror
|
|
1380
|
+
description: "Mirror THIS session to a markdown file for comfortable reading in Obsidian. The link is bound 1-1-1 to this sessionID: resuming the same session auto-restores, a different session stays silent until it links its own file. Use `md_unlog` to stop.",
|
|
1237
1381
|
args: {
|
|
1238
1382
|
filepath: tool.schema.string().describe("Existing markdown file to link (relative to worktree or absolute). Must exist.")
|
|
1239
1383
|
},
|
|
1240
1384
|
async execute(args, ctx) {
|
|
1385
|
+
const sessionID = ctx.sessionID;
|
|
1386
|
+
if (!sessionID)
|
|
1387
|
+
return `md_log error: no sessionID in context \u2014 cannot establish 1-1-1 link`;
|
|
1241
1388
|
const resolved = path.isAbsolute(args.filepath) ? args.filepath : path.resolve(ctx.directory, args.filepath);
|
|
1242
1389
|
if (!fs.existsSync(resolved))
|
|
1243
1390
|
return `File does not exist: ${resolved}`;
|
|
1244
1391
|
if (!fs.statSync(resolved).isFile())
|
|
1245
1392
|
return `Not a file: ${resolved}`;
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
fs.writeFileSync(markerPath, JSON.stringify({ file: resolved }), "utf-8");
|
|
1250
|
-
} catch {}
|
|
1251
|
-
let backfilled = 0;
|
|
1252
|
-
const sessionID = ctx.sessionID;
|
|
1253
|
-
if (sessionID) {
|
|
1254
|
-
try {
|
|
1255
|
-
backfilled = await backfillMdLog(client, sessionID, directory);
|
|
1256
|
-
} catch (e) {
|
|
1257
|
-
slog("backfill error", String(e));
|
|
1393
|
+
for (const [ses, meta] of mdLinks) {
|
|
1394
|
+
if (meta.file === resolved && ses !== sessionID) {
|
|
1395
|
+
return `File already linked to session ${ses.slice(0, 8)} \u2014 1-1-1 violation. Copy to a new file or md_unlog that session first.`;
|
|
1258
1396
|
}
|
|
1259
1397
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1398
|
+
mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() });
|
|
1399
|
+
saveMdLinksForDirectory(markerPath, directory);
|
|
1400
|
+
let backfilled = 0;
|
|
1401
|
+
try {
|
|
1402
|
+
backfilled = await backfillMdLog(client, sessionID, directory);
|
|
1403
|
+
} catch (e) {
|
|
1404
|
+
slog("backfill error", String(e));
|
|
1405
|
+
}
|
|
1406
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled, sessionID } } });
|
|
1407
|
+
return `Linked: ${resolved} to session ${sessionID.slice(0, 8)} \u2014 ${backfilled ? `${backfilled} entries backfilled \u2014 ` : ""}future messages for THIS session will be mirrored. Other sessions stay silent.`;
|
|
1408
|
+
}
|
|
1409
|
+
}),
|
|
1410
|
+
md_log_status: tool({
|
|
1411
|
+
description: "Show md-log link status for this session and directory.",
|
|
1412
|
+
args: {},
|
|
1413
|
+
async execute(_args, ctx) {
|
|
1414
|
+
const sessionID = ctx.sessionID;
|
|
1415
|
+
const own = sessionID ? mdLinks.get(sessionID) : undefined;
|
|
1416
|
+
let countDir = 0;
|
|
1417
|
+
for (const [, meta] of mdLinks)
|
|
1418
|
+
if (meta.directory === directory)
|
|
1419
|
+
countDir++;
|
|
1420
|
+
return `session ${sessionID?.slice(0, 8) ?? "(none)"} -> ${own?.file ?? "(no link)"} | links in this directory: ${countDir}`;
|
|
1262
1421
|
}
|
|
1263
1422
|
}),
|
|
1264
1423
|
md_unlog: tool({
|
|
1265
|
-
description: "Stop mirroring
|
|
1424
|
+
description: "Stop mirroring THIS session to its markdown file (other sessions unaffected).",
|
|
1266
1425
|
args: {},
|
|
1267
|
-
async execute() {
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1426
|
+
async execute(_args, ctx) {
|
|
1427
|
+
const sessionID = ctx.sessionID;
|
|
1428
|
+
if (!sessionID)
|
|
1429
|
+
return "No session in context";
|
|
1430
|
+
const meta = mdLinks.get(sessionID);
|
|
1431
|
+
if (!meta)
|
|
1432
|
+
return "No file linked for this session";
|
|
1433
|
+
const name = path.basename(meta.file);
|
|
1434
|
+
mdLinks.delete(sessionID);
|
|
1435
|
+
saveMdLinksForDirectory(markerPath, directory);
|
|
1436
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}`, extra: { sessionID } } });
|
|
1437
|
+
return `Unlinked: ${name} from session ${sessionID.slice(0, 8)} (other sessions unaffected)`;
|
|
1277
1438
|
}
|
|
1278
1439
|
}),
|
|
1279
1440
|
write_mermaid: tool({
|