@bojackduy/opencode-learn 1.2.1 → 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/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({
|
package/dist/tui.js
CHANGED
|
@@ -453,14 +453,14 @@ function QuizDialog(props) {
|
|
|
453
453
|
});
|
|
454
454
|
}
|
|
455
455
|
setSelected(m);
|
|
456
|
-
const
|
|
456
|
+
const correct = computeCorrect(eff);
|
|
457
457
|
setFeedback({
|
|
458
|
-
correct
|
|
458
|
+
correct,
|
|
459
459
|
selectedIndices: eff
|
|
460
460
|
});
|
|
461
461
|
if (reason)
|
|
462
462
|
setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
|
|
463
|
-
tlog("QuizDialog classify done", eff.join(","),
|
|
463
|
+
tlog("QuizDialog classify done", eff.join(","), correct, reason || "");
|
|
464
464
|
} else if (inferredValues && inferredValues.length) {
|
|
465
465
|
const byVal = new Map(options().map((o, i) => [o.value, i + 1]));
|
|
466
466
|
let idxs = inferredValues.map((v) => byVal.get(v)).filter(Boolean);
|
|
@@ -480,15 +480,15 @@ function QuizDialog(props) {
|
|
|
480
480
|
});
|
|
481
481
|
}
|
|
482
482
|
setSelected(m);
|
|
483
|
-
const
|
|
483
|
+
const correct = computeCorrect(idxs);
|
|
484
484
|
setFeedback({
|
|
485
|
-
correct
|
|
485
|
+
correct,
|
|
486
486
|
selectedIndices: idxs
|
|
487
487
|
});
|
|
488
488
|
} else {
|
|
489
|
-
const
|
|
489
|
+
const correct = typeof semanticCorrect === "boolean" ? semanticCorrect : false;
|
|
490
490
|
setFeedback({
|
|
491
|
-
correct
|
|
491
|
+
correct,
|
|
492
492
|
selectedIndices: []
|
|
493
493
|
});
|
|
494
494
|
if (reason)
|
|
@@ -1722,13 +1722,13 @@ function QuizBatchDialog(props) {
|
|
|
1722
1722
|
if (eff.length !== inferred.length)
|
|
1723
1723
|
tlog("QuizBatchDialog classify enforce single", inferred.join(","), "->", eff.join(","));
|
|
1724
1724
|
const mm = new Map;
|
|
1725
|
-
for (const
|
|
1726
|
-
const opt = cur().options[
|
|
1725
|
+
for (const idx of eff) {
|
|
1726
|
+
const opt = cur().options[idx - 1];
|
|
1727
1727
|
if (opt)
|
|
1728
|
-
mm.set(`opt:${
|
|
1728
|
+
mm.set(`opt:${idx - 1}`, {
|
|
1729
1729
|
label: opt.label,
|
|
1730
1730
|
value: opt.value,
|
|
1731
|
-
index:
|
|
1731
|
+
index: idx
|
|
1732
1732
|
});
|
|
1733
1733
|
}
|
|
1734
1734
|
setSelected(mm);
|
|
@@ -2667,12 +2667,12 @@ var tui = async (api) => {
|
|
|
2667
2667
|
data.sessionID = curSid;
|
|
2668
2668
|
try {
|
|
2669
2669
|
const cur = api.route?.current;
|
|
2670
|
-
const
|
|
2671
|
-
if (
|
|
2670
|
+
const curSid = cur?.params?.sessionID || cur?.sessionID;
|
|
2671
|
+
if (curSid && data.sessionID && data.sessionID !== curSid) {
|
|
2672
2672
|
const anyState = api.state;
|
|
2673
2673
|
const exists = anyState.session?.get ? anyState.session.get(data.sessionID) : undefined;
|
|
2674
2674
|
if (!exists)
|
|
2675
|
-
data.sessionID =
|
|
2675
|
+
data.sessionID = curSid;
|
|
2676
2676
|
}
|
|
2677
2677
|
} catch {}
|
|
2678
2678
|
current = {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-learn",
|
|
4
|
-
"version": "1.2.
|
|
5
|
-
"description": "Pi learn system for OpenCode
|
|
4
|
+
"version": "1.2.2",
|
|
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",
|
|
8
8
|
"private": false,
|
package/plugins/learn.ts
CHANGED
|
@@ -136,24 +136,76 @@ function decodeQuizText(s: string | undefined): string | undefined {
|
|
|
136
136
|
|
|
137
137
|
// ────────────────────────────────────────────────────────────────────────────
|
|
138
138
|
// md-log helpers (ported from .pi/extensions/md-log.ts)
|
|
139
|
+
// 1-1-1 model: session : link : log file. No global file.
|
|
139
140
|
// ────────────────────────────────────────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
type MdLinkMeta = { file: string; directory: string; linkedAt: number }
|
|
142
|
+
const mdLinks = new Map<string, MdLinkMeta>() // sessionID -> link
|
|
143
|
+
const mdFileLocks = new Map<string, Promise<void>>()
|
|
144
|
+
function withMdFileLock<T>(file: string, fn: () => T | Promise<T>): Promise<T> {
|
|
145
|
+
const prev = mdFileLocks.get(file) ?? Promise.resolve()
|
|
144
146
|
let release!: () => void
|
|
145
|
-
|
|
147
|
+
const next = new Promise<void>(r => { release = r })
|
|
148
|
+
mdFileLocks.set(file, next)
|
|
146
149
|
return prev.then(fn).finally(() => release())
|
|
147
150
|
}
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
// Back-compat alias used by older call sites during migration (per-file lock)
|
|
152
|
+
function withMdLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
153
|
+
// Fallback: no file context — run directly (callers should migrate to withMdFileLock)
|
|
154
|
+
return Promise.resolve().then(fn)
|
|
155
|
+
}
|
|
156
|
+
function getMdFile(sessionID: string | undefined): string | undefined {
|
|
157
|
+
if (!sessionID) return undefined
|
|
158
|
+
return mdLinks.get(sessionID)?.file
|
|
159
|
+
}
|
|
160
|
+
function appendToMdLogForSession(sessionID: string | undefined, text: string) {
|
|
161
|
+
const file = getMdFile(sessionID)
|
|
162
|
+
if (!file || !sessionID) return
|
|
150
163
|
try {
|
|
151
164
|
let current = ""
|
|
152
|
-
if (fs.existsSync(
|
|
165
|
+
if (fs.existsSync(file)) current = fs.readFileSync(file, "utf-8")
|
|
153
166
|
const prefix = current.trim().length > 0 ? "\n\n" : ""
|
|
154
|
-
fs.writeFileSync(
|
|
167
|
+
fs.writeFileSync(file, current + prefix + text + "\n", "utf-8")
|
|
155
168
|
} catch {}
|
|
156
169
|
}
|
|
170
|
+
function loadMdLinks(markerPath: string, directory: string) {
|
|
171
|
+
try {
|
|
172
|
+
if (!fs.existsSync(markerPath)) return 0
|
|
173
|
+
const data = JSON.parse(fs.readFileSync(markerPath, "utf-8"))
|
|
174
|
+
// Legacy shape {file} — do NOT auto-migrate (would bleed). Back up and start empty.
|
|
175
|
+
if (data && typeof data.file === "string" && !data.links) {
|
|
176
|
+
try { fs.writeFileSync(markerPath + ".bak", JSON.stringify(data), "utf-8") } catch {}
|
|
177
|
+
try { fs.writeFileSync(markerPath, JSON.stringify({ version: 1, links: {} }), "utf-8") } catch {}
|
|
178
|
+
try { slog("md-log legacy marker backed up, starting empty 1-1-1", markerPath) } catch {}
|
|
179
|
+
return 0
|
|
180
|
+
}
|
|
181
|
+
const links = (data as any)?.links ?? {}
|
|
182
|
+
let n = 0
|
|
183
|
+
for (const [ses, v] of Object.entries<any>(links)) {
|
|
184
|
+
const f = (v as any)?.file ?? (typeof v === "string" ? v : undefined)
|
|
185
|
+
if (typeof ses === "string" && typeof f === "string" && fs.existsSync(f)) {
|
|
186
|
+
mdLinks.set(ses, { file: f, directory: (v as any)?.directory || directory, linkedAt: (v as any)?.linkedAt || Date.now() })
|
|
187
|
+
n++
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return n
|
|
191
|
+
} catch { return 0 }
|
|
192
|
+
}
|
|
193
|
+
function saveMdLinksForDirectory(markerPath: string, directory: string) {
|
|
194
|
+
try {
|
|
195
|
+
const out: Record<string, { file: string; directory: string; linkedAt: number }> = {}
|
|
196
|
+
for (const [ses, meta] of mdLinks) {
|
|
197
|
+
if (meta.directory === directory) out[ses] = meta
|
|
198
|
+
}
|
|
199
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true })
|
|
200
|
+
fs.writeFileSync(markerPath, JSON.stringify({ version: 1, links: out }), "utf-8")
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
function extractHookSessionID(...candidates: any[]): string | undefined {
|
|
204
|
+
for (const c of candidates) {
|
|
205
|
+
if (typeof c === "string" && c.length > 0) return c
|
|
206
|
+
}
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
157
209
|
function callout(type: string, title: string, bodyLines: string[]) {
|
|
158
210
|
const lines = [`> [!${type}] ${title}`]
|
|
159
211
|
for (const line of bodyLines) lines.push(line.length === 0 ? ">" : `> ${line}`)
|
|
@@ -202,7 +254,8 @@ function answerCalloutAsk(details: any): string {
|
|
|
202
254
|
return callout("example", "Answer", body)
|
|
203
255
|
}
|
|
204
256
|
async function backfillMdLog(client: any, sessionID: string, directory: string): Promise<number> {
|
|
205
|
-
|
|
257
|
+
const mdFile = getMdFile(sessionID)
|
|
258
|
+
if (!mdFile || !sessionID) return 0
|
|
206
259
|
try {
|
|
207
260
|
const res: any = await client.session.messages({ path: { id: sessionID }, query: { directory } })
|
|
208
261
|
const data: any = res?.data ?? res
|
|
@@ -286,15 +339,16 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
|
|
|
286
339
|
}
|
|
287
340
|
}
|
|
288
341
|
if (blocks.length) {
|
|
342
|
+
const mdFile2 = getMdFile(sessionID) || mdFile
|
|
289
343
|
let current = ""
|
|
290
|
-
try { if (fs.existsSync(
|
|
344
|
+
try { if (fs.existsSync(mdFile2)) current = fs.readFileSync(mdFile2, "utf-8") } catch {}
|
|
291
345
|
// If file empty, overwrite; else append with separator (preserve user notes)
|
|
292
346
|
if (current.trim().length === 0) {
|
|
293
|
-
fs.writeFileSync(
|
|
347
|
+
fs.writeFileSync(mdFile2, blocks.join("\n\n") + "\n", "utf-8")
|
|
294
348
|
} else {
|
|
295
349
|
// Avoid duplicating if already contains same session text
|
|
296
350
|
const prefix = current.trim().length > 0 ? "\n\n" : ""
|
|
297
|
-
fs.writeFileSync(
|
|
351
|
+
fs.writeFileSync(mdFile2, current + prefix + blocks.join("\n\n") + "\n", "utf-8")
|
|
298
352
|
}
|
|
299
353
|
}
|
|
300
354
|
return blocks.length
|
|
@@ -445,23 +499,22 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
445
499
|
// Plugin definition
|
|
446
500
|
// ────────────────────────────────────────────────────────────────────────────
|
|
447
501
|
const server: Plugin = async ({ client, directory }) => {
|
|
448
|
-
//
|
|
502
|
+
// 1-1-1: restore session->file links for this directory (same session resumes, different session stays silent)
|
|
449
503
|
const markerPath = path.join(directory, ".opencode", "learn-md-log.json")
|
|
450
504
|
try {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (data?.file && fs.existsSync(data.file)) mdLogFile = data.file
|
|
454
|
-
}
|
|
505
|
+
const n = loadMdLinks(markerPath, directory)
|
|
506
|
+
if (n) slog("md-log links restored", n, markerPath)
|
|
455
507
|
} catch {}
|
|
456
508
|
|
|
457
509
|
// Session-scoped visual state (one per plugin instance; subagents get separate plugin instances per session, so isolation is natural)
|
|
458
510
|
let mermaidSession: { workDir: string; bodyPath: string } | null = null
|
|
459
511
|
let svgSession: { workDir: string; bodyPath: string } | null = null
|
|
460
512
|
|
|
461
|
-
// md-log dedup state
|
|
513
|
+
// md-log dedup state keyed by session to avoid cross-session suppression (1-1-1)
|
|
462
514
|
const loggedTextPartIds = new Set<string>()
|
|
463
515
|
const loggedToolCallIds = new Set<string>()
|
|
464
516
|
const messageIdToRole = new Map<string, string>()
|
|
517
|
+
const mdKey = (ses: string | undefined, id: string) => `${ses || "?"}:${id}`
|
|
465
518
|
|
|
466
519
|
// ── Classify watcher: note → inferred options (learner-easy) — LLM-backed, not heuristic-only
|
|
467
520
|
function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>, multiSelect?: boolean): number[] {
|
|
@@ -704,19 +757,23 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
704
757
|
const dk = !!r?.dontKnow
|
|
705
758
|
const ok = !dk && si.length === (j.correctIndices||[]).length && si.every((i:number)=>cs.has(i))
|
|
706
759
|
const note = r?.note ? `\nNote: ${r.note}` : ""
|
|
707
|
-
if (
|
|
760
|
+
if (getMdFile(j.sessionID)) {
|
|
708
761
|
const details = { status: "completed" as const, answers: r?.answers || [], correct: ok, correctIndices: j.correctIndices || [], explanation: j.explanation, dontKnow: dk, note: r?.note }
|
|
709
|
-
|
|
762
|
+
const sesJ = j.sessionID as string
|
|
763
|
+
const fJ = getMdFile(sesJ)!
|
|
764
|
+
void withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)))
|
|
710
765
|
}
|
|
711
766
|
return dk ? `[quiz answered] "${j.question}" -> I don't know.\nCorrect: ${cstr}\nExplanation: ${j.explanation}${note}` : `[quiz answered] "${j.question}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${cstr}\nExplanation: ${j.explanation}${note}`
|
|
712
767
|
} else if (j.type === "quiz_batch") {
|
|
713
768
|
const results = (r as any)?.results || []
|
|
714
|
-
if (
|
|
769
|
+
if (getMdFile(j.sessionID)) {
|
|
715
770
|
for (let i = 0; i < (j.quizzes||[]).length; i++) {
|
|
716
771
|
const qq = j.quizzes[i]
|
|
717
772
|
const x = results[i] || {}
|
|
718
773
|
const details = { status: "completed" as const, answers: x.answers || [], correct: !!x.correct, correctIndices: qq.correctIndices || [], explanation: qq.explanation || "", dontKnow: !!x.dontKnow, note: x.note }
|
|
719
|
-
|
|
774
|
+
const sesJ = j.sessionID as string
|
|
775
|
+
const fJ = getMdFile(sesJ)!
|
|
776
|
+
void withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)))
|
|
720
777
|
}
|
|
721
778
|
}
|
|
722
779
|
const lines = (j.quizzes || []).map((qq:any, i:number) => {
|
|
@@ -756,9 +813,11 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
756
813
|
await client.app.log({ body: { service: "learn", level: "info", message: "learn plugin initialized", extra: { directory } } })
|
|
757
814
|
},
|
|
758
815
|
|
|
759
|
-
"chat.message": async (
|
|
760
|
-
if (!mdLogFile) return
|
|
816
|
+
"chat.message": async (input, output) => {
|
|
761
817
|
try {
|
|
818
|
+
const ses = extractHookSessionID((input as any)?.sessionID, (output as any)?.message?.sessionID)
|
|
819
|
+
const mdFile = getMdFile(ses)
|
|
820
|
+
if (!mdFile || !ses) return
|
|
762
821
|
const msg: any = (output as any).message
|
|
763
822
|
const parts: any[] = (output as any).parts ?? []
|
|
764
823
|
let text = ""
|
|
@@ -770,25 +829,31 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
770
829
|
// Skip system-injected quiz/batch answer prompts — they are mirrored as beautiful callouts via watchAndInject, not as plain user quotes
|
|
771
830
|
if (/^\[(quiz|quiz_batch|question) (answered|cancelled)\]/i.test(text) || text.startsWith("[quiz answered]") || text.startsWith("[quiz_batch answered]") || text.startsWith("[question answered]")) return
|
|
772
831
|
const mid = msg?.id ? `msg:${msg.id}` : `chat:${Date.now()}`
|
|
773
|
-
|
|
774
|
-
loggedTextPartIds.
|
|
775
|
-
|
|
832
|
+
const mkey = mdKey(ses, mid)
|
|
833
|
+
if (loggedTextPartIds.has(mkey)) return
|
|
834
|
+
loggedTextPartIds.add(mkey)
|
|
835
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, userBlock(text)))
|
|
776
836
|
} catch {}
|
|
777
837
|
},
|
|
778
838
|
"experimental.text.complete": async (input, output) => {
|
|
779
|
-
if (!mdLogFile) return
|
|
780
839
|
try {
|
|
840
|
+
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
841
|
+
const mdFile = getMdFile(ses)
|
|
842
|
+
if (!mdFile || !ses) return
|
|
781
843
|
const text = (output as any).text?.trim()
|
|
782
844
|
if (!text) return
|
|
783
845
|
const partID = (input as any).partID
|
|
784
|
-
|
|
785
|
-
if (
|
|
786
|
-
|
|
846
|
+
const pkey = partID ? mdKey(ses, partID) : undefined
|
|
847
|
+
if (pkey && loggedTextPartIds.has(pkey)) return
|
|
848
|
+
if (pkey) loggedTextPartIds.add(pkey)
|
|
849
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
|
|
787
850
|
} catch {}
|
|
788
851
|
},
|
|
789
852
|
"tool.execute.before": async (input) => {
|
|
790
|
-
if (!mdLogFile) return
|
|
791
853
|
try {
|
|
854
|
+
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
855
|
+
const mdFile = getMdFile(ses)
|
|
856
|
+
if (!mdFile || !ses) return
|
|
792
857
|
const toolName = (input as any).tool
|
|
793
858
|
const args = (input as any).args ?? {}
|
|
794
859
|
// Built-in `question` tool is used as fallback when TUI not alive; mirror it.
|
|
@@ -798,53 +863,61 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
798
863
|
const ctx2 = args.details?.trim() || undefined
|
|
799
864
|
const opts = Array.isArray(args.options) ? args.options : []
|
|
800
865
|
const callID = (input as any).callID
|
|
801
|
-
|
|
802
|
-
if (
|
|
803
|
-
if (
|
|
866
|
+
const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined
|
|
867
|
+
if (qkey && loggedToolCallIds.has(qkey)) return
|
|
868
|
+
if (qkey) loggedToolCallIds.add(qkey)
|
|
869
|
+
if (q) await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout("Question", q, ctx2, opts)))
|
|
804
870
|
}
|
|
805
871
|
} catch {}
|
|
806
872
|
},
|
|
807
873
|
"tool.execute.after": async (input, output) => {
|
|
808
|
-
if (!mdLogFile) return
|
|
809
874
|
try {
|
|
875
|
+
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
876
|
+
const mdFile = getMdFile(ses)
|
|
877
|
+
if (!mdFile || !ses) return
|
|
810
878
|
const toolName = (input as any).tool
|
|
811
879
|
const callID = (input as any).callID
|
|
812
|
-
|
|
880
|
+
const akey = callID ? mdKey(ses, `answer:${callID}`) : undefined
|
|
881
|
+
if (akey && loggedToolCallIds.has(akey)) return
|
|
813
882
|
if (toolName === "question") {
|
|
814
883
|
const meta: any = (output as any).metadata ?? {}
|
|
815
884
|
let answers: any[] = meta.answers ?? []
|
|
816
885
|
if (!answers.length && (output as any).output) answers = []
|
|
817
886
|
const details: any = { answers, status: "completed" }
|
|
818
|
-
await
|
|
819
|
-
if (
|
|
887
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)))
|
|
888
|
+
if (akey) loggedToolCallIds.add(akey)
|
|
820
889
|
}
|
|
821
890
|
} catch {}
|
|
822
891
|
},
|
|
823
|
-
// Mirror session to markdown file (best-effort, mirrors pi's md-log)
|
|
892
|
+
// Mirror session to markdown file (best-effort, mirrors pi's md-log) — 1-1-1 gated
|
|
824
893
|
event: async ({ event }) => {
|
|
825
|
-
if (!mdLogFile) return
|
|
826
894
|
const t = (event as any).type as string
|
|
827
895
|
const props = (event as any).properties ?? {}
|
|
828
896
|
try {
|
|
829
897
|
if (t === "message.updated") {
|
|
830
898
|
const info: any = props.info
|
|
831
899
|
if (info?.id && info?.role) messageIdToRole.set(info.id, info.role)
|
|
900
|
+
return
|
|
832
901
|
} else if (t === "message.part.updated") {
|
|
833
902
|
const part: any = props.part
|
|
834
903
|
const delta: string | undefined = props.delta
|
|
835
904
|
if (!part || !part.id) return
|
|
905
|
+
const ses = extractHookSessionID(part.sessionID, (props as any)?.sessionID, (props.info as any)?.sessionID)
|
|
906
|
+
const mdFile = getMdFile(ses)
|
|
907
|
+
if (!mdFile || !ses) return
|
|
836
908
|
if (part.type === "text") {
|
|
837
909
|
if (part.synthetic || part.ignored) return
|
|
838
910
|
const isFinal = !!(part.time?.end !== undefined) || delta === undefined
|
|
839
911
|
if (!isFinal) return
|
|
840
|
-
|
|
912
|
+
const pkey = mdKey(ses, part.id)
|
|
913
|
+
if (loggedTextPartIds.has(pkey)) return
|
|
841
914
|
const text = (part.text || "").trim()
|
|
842
915
|
if (!text) return
|
|
843
916
|
const role = messageIdToRole.get(part.messageID)
|
|
844
917
|
if (role === "user") return
|
|
845
918
|
// Fallback for assistant when experimental.text.complete not fired
|
|
846
|
-
loggedTextPartIds.add(
|
|
847
|
-
await
|
|
919
|
+
loggedTextPartIds.add(pkey)
|
|
920
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
|
|
848
921
|
}
|
|
849
922
|
}
|
|
850
923
|
} catch {}
|
|
@@ -905,6 +978,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
905
978
|
}
|
|
906
979
|
try { fs.writeFileSync(pendingPath, JSON.stringify(payload), "utf8"); slog("quiz wrote durably", pendingPath, "alive", tuiAlive) } catch (e) { slog("quiz write failed", String(e)) }
|
|
907
980
|
try { await (ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
|
|
981
|
+
const quizSes = (ctx as any).sessionID as string | undefined
|
|
908
982
|
watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
|
|
909
983
|
const dk = !!r?.dontKnow
|
|
910
984
|
const sel = (r?.answers || []).map((a: any) => `${a.index}. ${a.label}`).join(", ") || "(none)"
|
|
@@ -912,7 +986,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
912
986
|
const si = (r?.answers || []).map((a: any) => a.index)
|
|
913
987
|
const ok = !dk && si.length === correctIndices.length && si.every((i: number) => cs.has(i))
|
|
914
988
|
const note = r?.note ? `\nNote: ${r.note}` : ""
|
|
915
|
-
|
|
989
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
990
|
+
if (qf && quizSes) {
|
|
916
991
|
const details = {
|
|
917
992
|
status: "completed" as const,
|
|
918
993
|
answers: r?.answers || [],
|
|
@@ -922,15 +997,18 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
922
997
|
dontKnow: dk,
|
|
923
998
|
note: r?.note,
|
|
924
999
|
}
|
|
925
|
-
void
|
|
1000
|
+
void withMdFileLock(qf, () => appendToMdLogForSession(quizSes, answerCalloutQuiz(details)))
|
|
926
1001
|
}
|
|
927
1002
|
return dk
|
|
928
1003
|
? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
929
1004
|
: `[quiz answered] "${qFixed}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
930
1005
|
})
|
|
931
|
-
// Always mirror question with TRUE shuffled order (pi: tool_execution_update)
|
|
932
|
-
|
|
933
|
-
|
|
1006
|
+
// Always mirror question with TRUE shuffled order (pi: tool_execution_update) — 1-1-1 gated
|
|
1007
|
+
{
|
|
1008
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1009
|
+
if (qf && quizSes) {
|
|
1010
|
+
try { await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
|
|
1011
|
+
}
|
|
934
1012
|
}
|
|
935
1013
|
if (tuiAlive) {
|
|
936
1014
|
return `[quiz displayed in TUI — waiting for your answer in the popup. I'll continue once you respond.]`
|
|
@@ -950,7 +1028,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
950
1028
|
const trimmed = (raw as string).trim()
|
|
951
1029
|
if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
|
|
952
1030
|
const msg = `User selected "I don't know" — genuine gap, not a guess.\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
953
|
-
|
|
1031
|
+
{
|
|
1032
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1033
|
+
if (qf && quizSes) await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout("question", "Quiz — I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])))
|
|
1034
|
+
}
|
|
954
1035
|
return msg
|
|
955
1036
|
}
|
|
956
1037
|
const nums = trimmed.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n >= 1 && n <= options.length)
|
|
@@ -961,7 +1042,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
961
1042
|
const verdict = correct ? "correctly" : "incorrectly"
|
|
962
1043
|
const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
963
1044
|
;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation: eFixed } })
|
|
964
|
-
|
|
1045
|
+
{
|
|
1046
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1047
|
+
if (qf && quizSes) await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])))
|
|
1048
|
+
}
|
|
965
1049
|
return result
|
|
966
1050
|
}
|
|
967
1051
|
const instruction = [
|
|
@@ -1029,36 +1113,42 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1029
1113
|
const file = path.join(pendingDirPath, `quiz_batch-${id}.json`)
|
|
1030
1114
|
try { fs.writeFileSync(file, JSON.stringify(payload), "utf8"); slog("quiz_batch wrote durably", file, "alive", isAlive) } catch (e) { slog("quiz_batch write failed", String(e)) }
|
|
1031
1115
|
try { await (ctx as any).metadata?.({ title: `Quiz batch ${normalized.length}`, metadata: { pendingId: id } }) } catch {}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1116
|
+
const batchSes = (ctx as any).sessionID as string | undefined
|
|
1117
|
+
// Mirror each question in batch as a beautiful callout (like single quiz) — 1-1-1 gated
|
|
1118
|
+
{
|
|
1119
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined
|
|
1120
|
+
if (bf && batchSes) {
|
|
1121
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
1122
|
+
const q = normalized[i]
|
|
1123
|
+
const label = `Quiz ${i + 1}/${normalized.length}`
|
|
1124
|
+
try { await withMdFileLock(bf, () => appendToMdLogForSession(batchSes, questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o: any) => ({ label: o.label }))))) } catch {}
|
|
1125
|
+
}
|
|
1038
1126
|
}
|
|
1039
1127
|
}
|
|
1040
1128
|
watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
|
|
1041
1129
|
const results = r?.results || []
|
|
1042
1130
|
// Mirror each answer as a beautiful callout (like single quiz) — not just plain text
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1131
|
+
{
|
|
1132
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined
|
|
1133
|
+
if (bf && batchSes) {
|
|
1134
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
1135
|
+
const q = normalized[i]
|
|
1136
|
+
const x = results[i] || {}
|
|
1137
|
+
const details = {
|
|
1138
|
+
status: "completed" as const,
|
|
1139
|
+
answers: x.answers || [],
|
|
1140
|
+
correct: !!x.correct,
|
|
1141
|
+
correctIndices: q.correctIndices || [],
|
|
1142
|
+
explanation: q.explanation || "",
|
|
1143
|
+
dontKnow: !!x.dontKnow,
|
|
1144
|
+
note: x.note,
|
|
1145
|
+
}
|
|
1146
|
+
// Use same callout helper as single quiz but with batch label context
|
|
1147
|
+
try {
|
|
1148
|
+
// withMdFileLock is async, but watchAndInject buildText is sync — queue without await and let it flush
|
|
1149
|
+
void withMdFileLock(bf, () => appendToMdLogForSession(batchSes, answerCalloutQuiz(details)))
|
|
1150
|
+
} catch {}
|
|
1055
1151
|
}
|
|
1056
|
-
const label = `Quiz ${i + 1}/${normalized.length}`
|
|
1057
|
-
// Use same callout helper as single quiz but with batch label context
|
|
1058
|
-
try {
|
|
1059
|
-
// withMdLock is async, but watchAndInject buildText is sync — queue without await and let it flush
|
|
1060
|
-
void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
|
|
1061
|
-
} catch {}
|
|
1062
1152
|
}
|
|
1063
1153
|
}
|
|
1064
1154
|
const lines = results.map((x: any, i: number) => {
|
|
@@ -1076,39 +1166,59 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1076
1166
|
}
|
|
1077
1167
|
}),
|
|
1078
1168
|
|
|
1079
|
-
// ── md_log: link a markdown file
|
|
1169
|
+
// ── md_log: link a markdown file — 1-1-1 session:link:file ──────────
|
|
1080
1170
|
md_log: tool({
|
|
1081
|
-
description: "Mirror
|
|
1171
|
+
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.",
|
|
1082
1172
|
args: {
|
|
1083
1173
|
filepath: tool.schema.string().describe("Existing markdown file to link (relative to worktree or absolute). Must exist."),
|
|
1084
1174
|
},
|
|
1085
1175
|
async execute(args, ctx) {
|
|
1176
|
+
const sessionID = (ctx as any).sessionID as string | undefined
|
|
1177
|
+
if (!sessionID) return `md_log error: no sessionID in context — cannot establish 1-1-1 link`
|
|
1086
1178
|
const resolved = path.isAbsolute(args.filepath) ? args.filepath : path.resolve(ctx.directory, args.filepath)
|
|
1087
1179
|
if (!fs.existsSync(resolved)) return `File does not exist: ${resolved}`
|
|
1088
1180
|
if (!fs.statSync(resolved).isFile()) return `Not a file: ${resolved}`
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1181
|
+
// Enforce 1-1-1: one file linked to at most one session
|
|
1182
|
+
for (const [ses, meta] of mdLinks) {
|
|
1183
|
+
if (meta.file === resolved && ses !== sessionID) {
|
|
1184
|
+
return `File already linked to session ${ses.slice(0,8)} — 1-1-1 violation. Copy to a new file or md_unlog that session first.`
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() })
|
|
1188
|
+
saveMdLinksForDirectory(markerPath, directory)
|
|
1189
|
+
// Backfill history for this session only
|
|
1092
1190
|
let backfilled = 0
|
|
1191
|
+
try { backfilled = await backfillMdLog(client, sessionID, directory) } catch (e) { slog("backfill error", String(e)) }
|
|
1192
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled, sessionID } } })
|
|
1193
|
+
return `Linked: ${resolved} to session ${sessionID.slice(0,8)} — ${backfilled ? `${backfilled} entries backfilled — ` : ""}future messages for THIS session will be mirrored. Other sessions stay silent.`
|
|
1194
|
+
},
|
|
1195
|
+
}),
|
|
1196
|
+
|
|
1197
|
+
md_log_status: tool({
|
|
1198
|
+
description: "Show md-log link status for this session and directory.",
|
|
1199
|
+
args: {},
|
|
1200
|
+
async execute(_args, ctx) {
|
|
1093
1201
|
const sessionID = (ctx as any).sessionID as string | undefined
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
return `Linked: ${resolved} — ${backfilled ? `${backfilled} entries backfilled — ` : ""}future messages will be mirrored. View it rendered in Obsidian for LaTeX/math.`
|
|
1202
|
+
const own = sessionID ? mdLinks.get(sessionID) : undefined
|
|
1203
|
+
let countDir = 0
|
|
1204
|
+
for (const [, meta] of mdLinks) if (meta.directory === directory) countDir++
|
|
1205
|
+
return `session ${sessionID?.slice(0,8) ?? "(none)"} -> ${own?.file ?? "(no link)"} | links in this directory: ${countDir}`
|
|
1099
1206
|
},
|
|
1100
1207
|
}),
|
|
1101
1208
|
|
|
1102
1209
|
md_unlog: tool({
|
|
1103
|
-
description: "Stop mirroring
|
|
1210
|
+
description: "Stop mirroring THIS session to its markdown file (other sessions unaffected).",
|
|
1104
1211
|
args: {},
|
|
1105
|
-
async execute() {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1212
|
+
async execute(_args, ctx) {
|
|
1213
|
+
const sessionID = (ctx as any).sessionID as string | undefined
|
|
1214
|
+
if (!sessionID) return "No session in context"
|
|
1215
|
+
const meta = mdLinks.get(sessionID)
|
|
1216
|
+
if (!meta) return "No file linked for this session"
|
|
1217
|
+
const name = path.basename(meta.file)
|
|
1218
|
+
mdLinks.delete(sessionID)
|
|
1219
|
+
saveMdLinksForDirectory(markerPath, directory)
|
|
1220
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}`, extra: { sessionID } } })
|
|
1221
|
+
return `Unlinked: ${name} from session ${sessionID.slice(0,8)} (other sessions unaffected)`
|
|
1112
1222
|
},
|
|
1113
1223
|
}),
|
|
1114
1224
|
|