@bojackduy/opencode-learn 1.2.1 → 1.2.3

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