@bojackduy/opencode-learn 1.2.0 → 1.2.2
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/LICENSE +28 -24
- package/README.md +2 -2
- package/dist/server.js +240 -131
- package/dist/tui.js +14 -14
- package/package.json +2 -2
- package/plugins/learn.ts +204 -94
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)
|
|
@@ -282,7 +336,8 @@ function answerCalloutAsk(details) {
|
|
|
282
336
|
return callout("example", "Answer", body);
|
|
283
337
|
}
|
|
284
338
|
async function backfillMdLog(client, sessionID, directory) {
|
|
285
|
-
|
|
339
|
+
const mdFile = getMdFile(sessionID);
|
|
340
|
+
if (!mdFile || !sessionID)
|
|
286
341
|
return 0;
|
|
287
342
|
try {
|
|
288
343
|
const res = await client.session.messages({ path: { id: sessionID }, query: { directory } });
|
|
@@ -372,13 +427,14 @@ async function backfillMdLog(client, sessionID, directory) {
|
|
|
372
427
|
}
|
|
373
428
|
}
|
|
374
429
|
if (blocks.length) {
|
|
430
|
+
const mdFile2 = getMdFile(sessionID) || mdFile;
|
|
375
431
|
let current = "";
|
|
376
432
|
try {
|
|
377
|
-
if (fs.existsSync(
|
|
378
|
-
current = fs.readFileSync(
|
|
433
|
+
if (fs.existsSync(mdFile2))
|
|
434
|
+
current = fs.readFileSync(mdFile2, "utf-8");
|
|
379
435
|
} catch {}
|
|
380
436
|
if (current.trim().length === 0) {
|
|
381
|
-
fs.writeFileSync(
|
|
437
|
+
fs.writeFileSync(mdFile2, blocks.join(`
|
|
382
438
|
|
|
383
439
|
`) + `
|
|
384
440
|
`, "utf-8");
|
|
@@ -386,7 +442,7 @@ async function backfillMdLog(client, sessionID, directory) {
|
|
|
386
442
|
const prefix = current.trim().length > 0 ? `
|
|
387
443
|
|
|
388
444
|
` : "";
|
|
389
|
-
fs.writeFileSync(
|
|
445
|
+
fs.writeFileSync(mdFile2, current + prefix + blocks.join(`
|
|
390
446
|
|
|
391
447
|
`) + `
|
|
392
448
|
`, "utf-8");
|
|
@@ -514,17 +570,16 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
514
570
|
var server = async ({ client, directory }) => {
|
|
515
571
|
const markerPath = path.join(directory, ".opencode", "learn-md-log.json");
|
|
516
572
|
try {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
mdLogFile = data.file;
|
|
521
|
-
}
|
|
573
|
+
const n = loadMdLinks(markerPath, directory);
|
|
574
|
+
if (n)
|
|
575
|
+
slog("md-log links restored", n, markerPath);
|
|
522
576
|
} catch {}
|
|
523
577
|
let mermaidSession = null;
|
|
524
578
|
let svgSession = null;
|
|
525
579
|
const loggedTextPartIds = new Set;
|
|
526
580
|
const loggedToolCallIds = new Set;
|
|
527
581
|
const messageIdToRole = new Map;
|
|
582
|
+
const mdKey = (ses, id) => `${ses || "?"}:${id}`;
|
|
528
583
|
function heuristicClassify(note, options, multiSelect) {
|
|
529
584
|
const n = note.toLowerCase();
|
|
530
585
|
const scored = [];
|
|
@@ -554,7 +609,7 @@ var server = async ({ client, directory }) => {
|
|
|
554
609
|
}
|
|
555
610
|
return uniq;
|
|
556
611
|
}
|
|
557
|
-
async function llmClassify(
|
|
612
|
+
async function llmClassify(client, directory, note, options, question, parentSessionID, multiSelect) {
|
|
558
613
|
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
614
|
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
615
|
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 +629,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
574
629
|
const body = { title };
|
|
575
630
|
if (parentSessionID)
|
|
576
631
|
body.parentID = parentSessionID;
|
|
577
|
-
const created = await
|
|
632
|
+
const created = await client.session.create({ body, query: { directory } });
|
|
578
633
|
const sid = created?.data?.id || created?.id || created?.data?.sessionID;
|
|
579
634
|
if (!sid)
|
|
580
635
|
throw new Error("no sid");
|
|
@@ -583,24 +638,24 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
583
638
|
if (parentSessionID && createdSession?.parentID !== parentSessionID) {
|
|
584
639
|
throw new Error(`classifier parent mismatch: expected ${parentSessionID}, got ${createdSession?.parentID || "none"}`);
|
|
585
640
|
}
|
|
586
|
-
await
|
|
641
|
+
await client.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text", text: prompt }], agent: "classify" } });
|
|
587
642
|
for (let i = 0;i < 24; i++) {
|
|
588
643
|
await new Promise((r) => setTimeout(r, 500));
|
|
589
644
|
try {
|
|
590
|
-
const msgs = await
|
|
645
|
+
const msgs = await client.session.messages({ path: { id: sid } });
|
|
591
646
|
const data = msgs?.data || msgs;
|
|
592
647
|
const arr = Array.isArray(data) ? data : [];
|
|
593
648
|
for (let j = arr.length - 1;j >= 0; j--) {
|
|
594
649
|
const entry = arr[j];
|
|
595
650
|
if (entry?.info?.role === "assistant") {
|
|
596
651
|
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",
|
|
652
|
+
const enforceSingle = (arr) => {
|
|
653
|
+
if (!multiSelect && arr.length > 1) {
|
|
654
|
+
const trimmed = [arr[0]];
|
|
655
|
+
slog("llmClassify enforce single", arr.join(","), "->", trimmed.join(","), multiSelect ? "multi" : "single");
|
|
601
656
|
return trimmed;
|
|
602
657
|
}
|
|
603
|
-
return
|
|
658
|
+
return arr;
|
|
604
659
|
};
|
|
605
660
|
const noteIsIDK = (() => {
|
|
606
661
|
const n = note.toLowerCase();
|
|
@@ -657,8 +712,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
657
712
|
}
|
|
658
713
|
return { inferred: [] };
|
|
659
714
|
}
|
|
660
|
-
function startClassifyWatcher(
|
|
661
|
-
const dir = pendingDir(
|
|
715
|
+
function startClassifyWatcher(client, directory) {
|
|
716
|
+
const dir = pendingDir(directory);
|
|
662
717
|
try {
|
|
663
718
|
fs.mkdirSync(dir, { recursive: true });
|
|
664
719
|
} catch {}
|
|
@@ -687,7 +742,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
687
742
|
let reason;
|
|
688
743
|
let isIDK;
|
|
689
744
|
const multi = !!data.multiSelect;
|
|
690
|
-
const llmRes = await llmClassify(
|
|
745
|
+
const llmRes = await llmClassify(client, directory, data.note, data.options, data.question, data.sessionID, multi);
|
|
691
746
|
isIDK = llmRes.isIDK;
|
|
692
747
|
if (!isIDK) {
|
|
693
748
|
const n = data.note.toLowerCase();
|
|
@@ -752,7 +807,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
752
807
|
} catch {}
|
|
753
808
|
};
|
|
754
809
|
try {
|
|
755
|
-
for (const f of fs.readdirSync(dir).filter((
|
|
810
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
|
|
756
811
|
processClassify(f);
|
|
757
812
|
}
|
|
758
813
|
} catch {}
|
|
@@ -782,9 +837,11 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
782
837
|
const ok = !dk && si.length === (j.correctIndices || []).length && si.every((i) => cs.has(i));
|
|
783
838
|
const note = r?.note ? `
|
|
784
839
|
Note: ${r.note}` : "";
|
|
785
|
-
if (
|
|
840
|
+
if (getMdFile(j.sessionID)) {
|
|
786
841
|
const details = { status: "completed", answers: r?.answers || [], correct: ok, correctIndices: j.correctIndices || [], explanation: j.explanation, dontKnow: dk, note: r?.note };
|
|
787
|
-
|
|
842
|
+
const sesJ = j.sessionID;
|
|
843
|
+
const fJ = getMdFile(sesJ);
|
|
844
|
+
withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)));
|
|
788
845
|
}
|
|
789
846
|
return dk ? `[quiz answered] "${j.question}" -> I don't know.
|
|
790
847
|
Correct: ${cstr}
|
|
@@ -793,12 +850,14 @@ Correct: ${cstr}
|
|
|
793
850
|
Explanation: ${j.explanation}${note}`;
|
|
794
851
|
} else if (j.type === "quiz_batch") {
|
|
795
852
|
const results = r?.results || [];
|
|
796
|
-
if (
|
|
853
|
+
if (getMdFile(j.sessionID)) {
|
|
797
854
|
for (let i = 0;i < (j.quizzes || []).length; i++) {
|
|
798
855
|
const qq = j.quizzes[i];
|
|
799
856
|
const x = results[i] || {};
|
|
800
857
|
const details = { status: "completed", answers: x.answers || [], correct: !!x.correct, correctIndices: qq.correctIndices || [], explanation: qq.explanation || "", dontKnow: !!x.dontKnow, note: x.note };
|
|
801
|
-
|
|
858
|
+
const sesJ = j.sessionID;
|
|
859
|
+
const fJ = getMdFile(sesJ);
|
|
860
|
+
withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)));
|
|
802
861
|
}
|
|
803
862
|
}
|
|
804
863
|
const lines = (j.quizzes || []).map((qq, i) => {
|
|
@@ -836,10 +895,12 @@ Explanation: ${j.explanation}${note}`;
|
|
|
836
895
|
output.agent = agents;
|
|
837
896
|
await client.app.log({ body: { service: "learn", level: "info", message: "learn plugin initialized", extra: { directory } } });
|
|
838
897
|
},
|
|
839
|
-
"chat.message": async (
|
|
840
|
-
if (!mdLogFile)
|
|
841
|
-
return;
|
|
898
|
+
"chat.message": async (input, output) => {
|
|
842
899
|
try {
|
|
900
|
+
const ses = extractHookSessionID(input?.sessionID, output?.message?.sessionID);
|
|
901
|
+
const mdFile = getMdFile(ses);
|
|
902
|
+
if (!mdFile || !ses)
|
|
903
|
+
return;
|
|
843
904
|
const msg = output.message;
|
|
844
905
|
const parts = output.parts ?? [];
|
|
845
906
|
let text = "";
|
|
@@ -857,31 +918,37 @@ Explanation: ${j.explanation}${note}`;
|
|
|
857
918
|
if (/^\[(quiz|quiz_batch|question) (answered|cancelled)\]/i.test(text) || text.startsWith("[quiz answered]") || text.startsWith("[quiz_batch answered]") || text.startsWith("[question answered]"))
|
|
858
919
|
return;
|
|
859
920
|
const mid = msg?.id ? `msg:${msg.id}` : `chat:${Date.now()}`;
|
|
860
|
-
|
|
921
|
+
const mkey = mdKey(ses, mid);
|
|
922
|
+
if (loggedTextPartIds.has(mkey))
|
|
861
923
|
return;
|
|
862
|
-
loggedTextPartIds.add(
|
|
863
|
-
await
|
|
924
|
+
loggedTextPartIds.add(mkey);
|
|
925
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, userBlock(text)));
|
|
864
926
|
} catch {}
|
|
865
927
|
},
|
|
866
928
|
"experimental.text.complete": async (input, output) => {
|
|
867
|
-
if (!mdLogFile)
|
|
868
|
-
return;
|
|
869
929
|
try {
|
|
930
|
+
const ses = extractHookSessionID(input?.sessionID);
|
|
931
|
+
const mdFile = getMdFile(ses);
|
|
932
|
+
if (!mdFile || !ses)
|
|
933
|
+
return;
|
|
870
934
|
const text = output.text?.trim();
|
|
871
935
|
if (!text)
|
|
872
936
|
return;
|
|
873
937
|
const partID = input.partID;
|
|
874
|
-
|
|
938
|
+
const pkey = partID ? mdKey(ses, partID) : undefined;
|
|
939
|
+
if (pkey && loggedTextPartIds.has(pkey))
|
|
875
940
|
return;
|
|
876
|
-
if (
|
|
877
|
-
loggedTextPartIds.add(
|
|
878
|
-
await
|
|
941
|
+
if (pkey)
|
|
942
|
+
loggedTextPartIds.add(pkey);
|
|
943
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))));
|
|
879
944
|
} catch {}
|
|
880
945
|
},
|
|
881
946
|
"tool.execute.before": async (input) => {
|
|
882
|
-
if (!mdLogFile)
|
|
883
|
-
return;
|
|
884
947
|
try {
|
|
948
|
+
const ses = extractHookSessionID(input?.sessionID);
|
|
949
|
+
const mdFile = getMdFile(ses);
|
|
950
|
+
if (!mdFile || !ses)
|
|
951
|
+
return;
|
|
885
952
|
const toolName = input.tool;
|
|
886
953
|
const args = input.args ?? {};
|
|
887
954
|
if (toolName === "question") {
|
|
@@ -889,22 +956,26 @@ Explanation: ${j.explanation}${note}`;
|
|
|
889
956
|
const ctx2 = args.details?.trim() || undefined;
|
|
890
957
|
const opts = Array.isArray(args.options) ? args.options : [];
|
|
891
958
|
const callID = input.callID;
|
|
892
|
-
|
|
959
|
+
const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined;
|
|
960
|
+
if (qkey && loggedToolCallIds.has(qkey))
|
|
893
961
|
return;
|
|
894
|
-
if (
|
|
895
|
-
loggedToolCallIds.add(
|
|
962
|
+
if (qkey)
|
|
963
|
+
loggedToolCallIds.add(qkey);
|
|
896
964
|
if (q)
|
|
897
|
-
await
|
|
965
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout("Question", q, ctx2, opts)));
|
|
898
966
|
}
|
|
899
967
|
} catch {}
|
|
900
968
|
},
|
|
901
969
|
"tool.execute.after": async (input, output) => {
|
|
902
|
-
if (!mdLogFile)
|
|
903
|
-
return;
|
|
904
970
|
try {
|
|
971
|
+
const ses = extractHookSessionID(input?.sessionID);
|
|
972
|
+
const mdFile = getMdFile(ses);
|
|
973
|
+
if (!mdFile || !ses)
|
|
974
|
+
return;
|
|
905
975
|
const toolName = input.tool;
|
|
906
976
|
const callID = input.callID;
|
|
907
|
-
|
|
977
|
+
const akey = callID ? mdKey(ses, `answer:${callID}`) : undefined;
|
|
978
|
+
if (akey && loggedToolCallIds.has(akey))
|
|
908
979
|
return;
|
|
909
980
|
if (toolName === "question") {
|
|
910
981
|
const meta = output.metadata ?? {};
|
|
@@ -912,15 +983,13 @@ Explanation: ${j.explanation}${note}`;
|
|
|
912
983
|
if (!answers.length && output.output)
|
|
913
984
|
answers = [];
|
|
914
985
|
const details = { answers, status: "completed" };
|
|
915
|
-
await
|
|
916
|
-
if (
|
|
917
|
-
loggedToolCallIds.add(
|
|
986
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)));
|
|
987
|
+
if (akey)
|
|
988
|
+
loggedToolCallIds.add(akey);
|
|
918
989
|
}
|
|
919
990
|
} catch {}
|
|
920
991
|
},
|
|
921
992
|
event: async ({ event }) => {
|
|
922
|
-
if (!mdLogFile)
|
|
923
|
-
return;
|
|
924
993
|
const t = event.type;
|
|
925
994
|
const props = event.properties ?? {};
|
|
926
995
|
try {
|
|
@@ -928,18 +997,24 @@ Explanation: ${j.explanation}${note}`;
|
|
|
928
997
|
const info = props.info;
|
|
929
998
|
if (info?.id && info?.role)
|
|
930
999
|
messageIdToRole.set(info.id, info.role);
|
|
1000
|
+
return;
|
|
931
1001
|
} else if (t === "message.part.updated") {
|
|
932
1002
|
const part = props.part;
|
|
933
1003
|
const delta = props.delta;
|
|
934
1004
|
if (!part || !part.id)
|
|
935
1005
|
return;
|
|
1006
|
+
const ses = extractHookSessionID(part.sessionID, props?.sessionID, props.info?.sessionID);
|
|
1007
|
+
const mdFile = getMdFile(ses);
|
|
1008
|
+
if (!mdFile || !ses)
|
|
1009
|
+
return;
|
|
936
1010
|
if (part.type === "text") {
|
|
937
1011
|
if (part.synthetic || part.ignored)
|
|
938
1012
|
return;
|
|
939
1013
|
const isFinal = !!(part.time?.end !== undefined) || delta === undefined;
|
|
940
1014
|
if (!isFinal)
|
|
941
1015
|
return;
|
|
942
|
-
|
|
1016
|
+
const pkey = mdKey(ses, part.id);
|
|
1017
|
+
if (loggedTextPartIds.has(pkey))
|
|
943
1018
|
return;
|
|
944
1019
|
const text = (part.text || "").trim();
|
|
945
1020
|
if (!text)
|
|
@@ -947,8 +1022,8 @@ Explanation: ${j.explanation}${note}`;
|
|
|
947
1022
|
const role = messageIdToRole.get(part.messageID);
|
|
948
1023
|
if (role === "user")
|
|
949
1024
|
return;
|
|
950
|
-
loggedTextPartIds.add(
|
|
951
|
-
await
|
|
1025
|
+
loggedTextPartIds.add(pkey);
|
|
1026
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))));
|
|
952
1027
|
}
|
|
953
1028
|
}
|
|
954
1029
|
} catch {}
|
|
@@ -1018,6 +1093,7 @@ Explanation: ${j.explanation}${note}`;
|
|
|
1018
1093
|
try {
|
|
1019
1094
|
await ctx.metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } });
|
|
1020
1095
|
} catch {}
|
|
1096
|
+
const quizSes = ctx.sessionID;
|
|
1021
1097
|
watchAndInject(client, directory, id, ctx.sessionID, (r) => {
|
|
1022
1098
|
const dk = !!r?.dontKnow;
|
|
1023
1099
|
const sel = (r?.answers || []).map((a) => `${a.index}. ${a.label}`).join(", ") || "(none)";
|
|
@@ -1026,7 +1102,8 @@ Explanation: ${j.explanation}${note}`;
|
|
|
1026
1102
|
const ok = !dk && si.length === correctIndices.length && si.every((i) => cs.has(i));
|
|
1027
1103
|
const note = r?.note ? `
|
|
1028
1104
|
Note: ${r.note}` : "";
|
|
1029
|
-
|
|
1105
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined;
|
|
1106
|
+
if (qf && quizSes) {
|
|
1030
1107
|
const details = {
|
|
1031
1108
|
status: "completed",
|
|
1032
1109
|
answers: r?.answers || [],
|
|
@@ -1036,7 +1113,7 @@ Note: ${r.note}` : "";
|
|
|
1036
1113
|
dontKnow: dk,
|
|
1037
1114
|
note: r?.note
|
|
1038
1115
|
};
|
|
1039
|
-
|
|
1116
|
+
withMdFileLock(qf, () => appendToMdLogForSession(quizSes, answerCalloutQuiz(details)));
|
|
1040
1117
|
}
|
|
1041
1118
|
return dk ? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).
|
|
1042
1119
|
Correct: ${correctStr}
|
|
@@ -1044,10 +1121,13 @@ Explanation: ${eFixed}${note}` : `[quiz answered] "${qFixed}" -> ${sel} = ${ok ?
|
|
|
1044
1121
|
Correct: ${correctStr}
|
|
1045
1122
|
Explanation: ${eFixed}${note}`;
|
|
1046
1123
|
});
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1124
|
+
{
|
|
1125
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined;
|
|
1126
|
+
if (qf && quizSes) {
|
|
1127
|
+
try {
|
|
1128
|
+
await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label })))));
|
|
1129
|
+
} catch {}
|
|
1130
|
+
}
|
|
1051
1131
|
}
|
|
1052
1132
|
if (tuiAlive) {
|
|
1053
1133
|
return `[quiz displayed in TUI \u2014 waiting for your answer in the popup. I'll continue once you respond.]`;
|
|
@@ -1057,21 +1137,21 @@ Explanation: ${eFixed}${note}`;
|
|
|
1057
1137
|
if (isTTY && !insideOpencode) {
|
|
1058
1138
|
const readline = await import("readline");
|
|
1059
1139
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1060
|
-
const abortPromise = new Promise((
|
|
1140
|
+
const abortPromise = new Promise((resolve) => ctx.abort.addEventListener("abort", () => {
|
|
1061
1141
|
try {
|
|
1062
1142
|
rl.close();
|
|
1063
1143
|
} catch {}
|
|
1064
|
-
|
|
1144
|
+
resolve(null);
|
|
1065
1145
|
}, { once: true }));
|
|
1066
1146
|
const promptText = `
|
|
1067
1147
|
[quiz] ${args.question}
|
|
1068
1148
|
${args.details ? args.details + `
|
|
1069
1149
|
` : ""}${display}
|
|
1070
1150
|
${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((
|
|
1151
|
+
const answerPromise = new Promise((resolve) => {
|
|
1072
1152
|
rl.question(promptText, (ans) => {
|
|
1073
1153
|
rl.close();
|
|
1074
|
-
|
|
1154
|
+
resolve(ans);
|
|
1075
1155
|
});
|
|
1076
1156
|
});
|
|
1077
1157
|
const raw = await Promise.race([answerPromise, abortPromise]);
|
|
@@ -1082,8 +1162,11 @@ ${args.multiSelect ? "Select all correct (comma-separated numbers, e.g. 1,3) or
|
|
|
1082
1162
|
const msg = `User selected "I don't know" \u2014 genuine gap, not a guess.
|
|
1083
1163
|
Correct: ${correctStr}
|
|
1084
1164
|
Explanation: ${eFixed}`;
|
|
1085
|
-
|
|
1086
|
-
|
|
1165
|
+
{
|
|
1166
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined;
|
|
1167
|
+
if (qf && quizSes)
|
|
1168
|
+
await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout("question", "Quiz \u2014 I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])));
|
|
1169
|
+
}
|
|
1087
1170
|
return msg;
|
|
1088
1171
|
}
|
|
1089
1172
|
const nums = trimmed.split(/[,\s]+/).map((s) => parseInt(s, 10)).filter((n) => !isNaN(n) && n >= 1 && n <= options.length);
|
|
@@ -1097,8 +1180,11 @@ Selected: ${selectedStr}
|
|
|
1097
1180
|
Correct: ${correctStr}
|
|
1098
1181
|
Explanation: ${eFixed}`;
|
|
1099
1182
|
ctx.metadata?.({ title: correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", metadata: { correct, correctIndices, explanation: eFixed } });
|
|
1100
|
-
|
|
1101
|
-
|
|
1183
|
+
{
|
|
1184
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined;
|
|
1185
|
+
if (qf && quizSes)
|
|
1186
|
+
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])));
|
|
1187
|
+
}
|
|
1102
1188
|
return result;
|
|
1103
1189
|
}
|
|
1104
1190
|
const instruction = [
|
|
@@ -1184,36 +1270,42 @@ Explanation: ${eFixed}`;
|
|
|
1184
1270
|
try {
|
|
1185
1271
|
await ctx.metadata?.({ title: `Quiz batch ${normalized.length}`, metadata: { pendingId: id } });
|
|
1186
1272
|
} 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) {
|
|
1273
|
+
const batchSes = ctx.sessionID;
|
|
1274
|
+
{
|
|
1275
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined;
|
|
1276
|
+
if (bf && batchSes) {
|
|
1199
1277
|
for (let i = 0;i < normalized.length; i++) {
|
|
1200
1278
|
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
1279
|
const label = `Quiz ${i + 1}/${normalized.length}`;
|
|
1212
1280
|
try {
|
|
1213
|
-
|
|
1281
|
+
await withMdFileLock(bf, () => appendToMdLogForSession(batchSes, questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o) => ({ label: o.label })))));
|
|
1214
1282
|
} catch {}
|
|
1215
1283
|
}
|
|
1216
1284
|
}
|
|
1285
|
+
}
|
|
1286
|
+
watchAndInject(client, directory, id, ctx.sessionID, (r) => {
|
|
1287
|
+
const results = r?.results || [];
|
|
1288
|
+
{
|
|
1289
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined;
|
|
1290
|
+
if (bf && batchSes) {
|
|
1291
|
+
for (let i = 0;i < normalized.length; i++) {
|
|
1292
|
+
const q = normalized[i];
|
|
1293
|
+
const x = results[i] || {};
|
|
1294
|
+
const details = {
|
|
1295
|
+
status: "completed",
|
|
1296
|
+
answers: x.answers || [],
|
|
1297
|
+
correct: !!x.correct,
|
|
1298
|
+
correctIndices: q.correctIndices || [],
|
|
1299
|
+
explanation: q.explanation || "",
|
|
1300
|
+
dontKnow: !!x.dontKnow,
|
|
1301
|
+
note: x.note
|
|
1302
|
+
};
|
|
1303
|
+
try {
|
|
1304
|
+
withMdFileLock(bf, () => appendToMdLogForSession(batchSes, answerCalloutQuiz(details)));
|
|
1305
|
+
} catch {}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1217
1309
|
const lines = results.map((x, i) => {
|
|
1218
1310
|
const q = normalized[i];
|
|
1219
1311
|
const cs = (q.correctIndices || []).map((idx) => `${idx}. ${q.options[idx - 1]?.label}`).join(", ");
|
|
@@ -1233,47 +1325,64 @@ Explanation: ${eFixed}`;
|
|
|
1233
1325
|
}
|
|
1234
1326
|
}),
|
|
1235
1327
|
md_log: tool({
|
|
1236
|
-
description: "Mirror
|
|
1328
|
+
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
1329
|
args: {
|
|
1238
1330
|
filepath: tool.schema.string().describe("Existing markdown file to link (relative to worktree or absolute). Must exist.")
|
|
1239
1331
|
},
|
|
1240
1332
|
async execute(args, ctx) {
|
|
1333
|
+
const sessionID = ctx.sessionID;
|
|
1334
|
+
if (!sessionID)
|
|
1335
|
+
return `md_log error: no sessionID in context \u2014 cannot establish 1-1-1 link`;
|
|
1241
1336
|
const resolved = path.isAbsolute(args.filepath) ? args.filepath : path.resolve(ctx.directory, args.filepath);
|
|
1242
1337
|
if (!fs.existsSync(resolved))
|
|
1243
1338
|
return `File does not exist: ${resolved}`;
|
|
1244
1339
|
if (!fs.statSync(resolved).isFile())
|
|
1245
1340
|
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));
|
|
1341
|
+
for (const [ses, meta] of mdLinks) {
|
|
1342
|
+
if (meta.file === resolved && ses !== sessionID) {
|
|
1343
|
+
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
1344
|
}
|
|
1259
1345
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1346
|
+
mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() });
|
|
1347
|
+
saveMdLinksForDirectory(markerPath, directory);
|
|
1348
|
+
let backfilled = 0;
|
|
1349
|
+
try {
|
|
1350
|
+
backfilled = await backfillMdLog(client, sessionID, directory);
|
|
1351
|
+
} catch (e) {
|
|
1352
|
+
slog("backfill error", String(e));
|
|
1353
|
+
}
|
|
1354
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled, sessionID } } });
|
|
1355
|
+
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.`;
|
|
1356
|
+
}
|
|
1357
|
+
}),
|
|
1358
|
+
md_log_status: tool({
|
|
1359
|
+
description: "Show md-log link status for this session and directory.",
|
|
1360
|
+
args: {},
|
|
1361
|
+
async execute(_args, ctx) {
|
|
1362
|
+
const sessionID = ctx.sessionID;
|
|
1363
|
+
const own = sessionID ? mdLinks.get(sessionID) : undefined;
|
|
1364
|
+
let countDir = 0;
|
|
1365
|
+
for (const [, meta] of mdLinks)
|
|
1366
|
+
if (meta.directory === directory)
|
|
1367
|
+
countDir++;
|
|
1368
|
+
return `session ${sessionID?.slice(0, 8) ?? "(none)"} -> ${own?.file ?? "(no link)"} | links in this directory: ${countDir}`;
|
|
1262
1369
|
}
|
|
1263
1370
|
}),
|
|
1264
1371
|
md_unlog: tool({
|
|
1265
|
-
description: "Stop mirroring
|
|
1372
|
+
description: "Stop mirroring THIS session to its markdown file (other sessions unaffected).",
|
|
1266
1373
|
args: {},
|
|
1267
|
-
async execute() {
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1374
|
+
async execute(_args, ctx) {
|
|
1375
|
+
const sessionID = ctx.sessionID;
|
|
1376
|
+
if (!sessionID)
|
|
1377
|
+
return "No session in context";
|
|
1378
|
+
const meta = mdLinks.get(sessionID);
|
|
1379
|
+
if (!meta)
|
|
1380
|
+
return "No file linked for this session";
|
|
1381
|
+
const name = path.basename(meta.file);
|
|
1382
|
+
mdLinks.delete(sessionID);
|
|
1383
|
+
saveMdLinksForDirectory(markerPath, directory);
|
|
1384
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}`, extra: { sessionID } } });
|
|
1385
|
+
return `Unlinked: ${name} from session ${sessionID.slice(0, 8)} (other sessions unaffected)`;
|
|
1277
1386
|
}
|
|
1278
1387
|
}),
|
|
1279
1388
|
write_mermaid: tool({
|