@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/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 mdLogFile = null;
163
- var mdLogWriteLock = Promise.resolve();
164
- function withMdLock(fn) {
165
- const prev = mdLogWriteLock;
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
- mdLogWriteLock = new Promise((r) => {
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 appendToMdLog(text) {
173
- if (!mdLogFile)
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(mdLogFile))
178
- current = fs.readFileSync(mdLogFile, "utf-8");
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(mdLogFile, current + prefix + text + `
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
- if (!mdLogFile || !sessionID)
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(mdLogFile))
378
- current = fs.readFileSync(mdLogFile, "utf-8");
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(mdLogFile, blocks.join(`
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(mdLogFile, current + prefix + blocks.join(`
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
- if (fs.existsSync(markerPath)) {
518
- const data = JSON.parse(fs.readFileSync(markerPath, "utf-8"));
519
- if (data?.file && fs.existsSync(data.file))
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(client2, directory2, note, options, question, parentSessionID, multiSelect) {
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 client2.session.create({ body, query: { directory: directory2 } });
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 client2.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text", text: prompt }], agent: "classify" } });
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 client2.session.messages({ path: { id: sid } });
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 = (arr2) => {
598
- if (!multiSelect && arr2.length > 1) {
599
- const trimmed = [arr2[0]];
600
- slog("llmClassify enforce single", arr2.join(","), "->", trimmed.join(","), multiSelect ? "multi" : "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 arr2;
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(client2, directory2) {
661
- const dir = pendingDir(directory2);
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(client2, directory2, data.note, data.options, data.question, data.sessionID, multi);
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((f2) => f2.startsWith("classify-") && !f2.startsWith("classify-response-"))) {
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 (mdLogFile) {
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
- withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
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 (mdLogFile) {
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
- withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
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 (_input, output) => {
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
- if (loggedTextPartIds.has(mid))
921
+ const mkey = mdKey(ses, mid);
922
+ if (loggedTextPartIds.has(mkey))
861
923
  return;
862
- loggedTextPartIds.add(mid);
863
- await withMdLock(() => appendToMdLog(userBlock(text)));
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
- if (partID && loggedTextPartIds.has(partID))
938
+ const pkey = partID ? mdKey(ses, partID) : undefined;
939
+ if (pkey && loggedTextPartIds.has(pkey))
875
940
  return;
876
- if (partID)
877
- loggedTextPartIds.add(partID);
878
- await withMdLock(() => appendToMdLog(assistantBlock(stripSkillBlocks(text))));
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
- if (callID && loggedToolCallIds.has(`q:${callID}`))
959
+ const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined;
960
+ if (qkey && loggedToolCallIds.has(qkey))
893
961
  return;
894
- if (callID)
895
- loggedToolCallIds.add(`q:${callID}`);
962
+ if (qkey)
963
+ loggedToolCallIds.add(qkey);
896
964
  if (q)
897
- await withMdLock(() => appendToMdLog(questionCallout("Question", q, ctx2, opts)));
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
- if (callID && loggedToolCallIds.has(`answer:${callID}`))
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 withMdLock(() => appendToMdLog(answerCalloutAsk(details)));
916
- if (callID)
917
- loggedToolCallIds.add(`answer:${callID}`);
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
- if (loggedTextPartIds.has(part.id))
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(part.id);
951
- await withMdLock(() => appendToMdLog(assistantBlock(stripSkillBlocks(text))));
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
- if (mdLogFile) {
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
- withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
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
- if (mdLogFile) {
1048
- try {
1049
- await withMdLock(() => appendToMdLog(questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label })))));
1050
- } catch {}
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((resolve2) => ctx.abort.addEventListener("abort", () => {
1140
+ const abortPromise = new Promise((resolve) => ctx.abort.addEventListener("abort", () => {
1061
1141
  try {
1062
1142
  rl.close();
1063
1143
  } catch {}
1064
- resolve2(null);
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((resolve2) => {
1151
+ const answerPromise = new Promise((resolve) => {
1072
1152
  rl.question(promptText, (ans) => {
1073
1153
  rl.close();
1074
- resolve2(ans);
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
- if (mdLogFile)
1086
- await withMdLock(() => appendToMdLog(callout("question", "Quiz \u2014 I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])));
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
- if (mdLogFile)
1101
- await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])));
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
- if (mdLogFile) {
1188
- for (let i = 0;i < normalized.length; i++) {
1189
- const q = normalized[i];
1190
- const label = `Quiz ${i + 1}/${normalized.length}`;
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
- withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
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 the session to a markdown file for comfortable reading in Obsidian. The file mirrors user prompts, assistant text, and quiz/question Q&A. Use an existing file; it will be backfilled with history. Use `md_unlog` to stop.",
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
- mdLogFile = resolved;
1247
- try {
1248
- fs.mkdirSync(path.dirname(markerPath), { recursive: true });
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
- await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled } } });
1261
- return `Linked: ${resolved} \u2014 ${backfilled ? `${backfilled} entries backfilled \u2014 ` : ""}future messages will be mirrored. View it rendered in Obsidian for LaTeX/math.`;
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 the session to a markdown file.",
1372
+ description: "Stop mirroring THIS session to its markdown file (other sessions unaffected).",
1266
1373
  args: {},
1267
- async execute() {
1268
- if (!mdLogFile)
1269
- return "No file linked";
1270
- const name = path.basename(mdLogFile);
1271
- mdLogFile = null;
1272
- try {
1273
- fs.writeFileSync(markerPath, JSON.stringify({ file: null }), "utf-8");
1274
- } catch {}
1275
- await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}` } });
1276
- return `Unlinked: ${name}`;
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({