@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 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({
package/dist/tui.js CHANGED
@@ -453,14 +453,14 @@ function QuizDialog(props) {
453
453
  });
454
454
  }
455
455
  setSelected(m);
456
- const correct2 = computeCorrect(eff);
456
+ const correct = computeCorrect(eff);
457
457
  setFeedback({
458
- correct: correct2,
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(","), correct2, reason || "");
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 correct2 = computeCorrect(idxs);
483
+ const correct = computeCorrect(idxs);
484
484
  setFeedback({
485
- correct: correct2,
485
+ correct,
486
486
  selectedIndices: idxs
487
487
  });
488
488
  } else {
489
- const correct2 = typeof semanticCorrect === "boolean" ? semanticCorrect : false;
489
+ const correct = typeof semanticCorrect === "boolean" ? semanticCorrect : false;
490
490
  setFeedback({
491
- correct: correct2,
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 idx2 of eff) {
1726
- const opt = cur().options[idx2 - 1];
1725
+ for (const idx of eff) {
1726
+ const opt = cur().options[idx - 1];
1727
1727
  if (opt)
1728
- mm.set(`opt:${idx2 - 1}`, {
1728
+ mm.set(`opt:${idx - 1}`, {
1729
1729
  label: opt.label,
1730
1730
  value: opt.value,
1731
- index: idx2
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 curSid2 = cur?.params?.sessionID || cur?.sessionID;
2671
- if (curSid2 && data.sessionID && data.sessionID !== curSid2) {
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 = curSid2;
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.1",
5
- "description": "Pi learn system for OpenCode \u2014 Socratic teaching, graded quiz, Obsidian md_log, and visual makers. Port of amosblomqvist/learn (video: How I Use AI to Learn Things) to 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
- let mdLogFile: string | null = null
141
- let mdLogWriteLock: Promise<void> = Promise.resolve()
142
- function withMdLock<T>(fn: () => T | Promise<T>): Promise<T> {
143
- const prev = mdLogWriteLock
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
- mdLogWriteLock = new Promise<void>(r => { release = r })
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
- function appendToMdLog(text: string) {
149
- if (!mdLogFile) return
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(mdLogFile)) current = fs.readFileSync(mdLogFile, "utf-8")
165
+ if (fs.existsSync(file)) current = fs.readFileSync(file, "utf-8")
153
166
  const prefix = current.trim().length > 0 ? "\n\n" : ""
154
- fs.writeFileSync(mdLogFile, current + prefix + text + "\n", "utf-8")
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
- if (!mdLogFile || !sessionID) return 0
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(mdLogFile)) current = fs.readFileSync(mdLogFile, "utf-8") } catch {}
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(mdLogFile, blocks.join("\n\n") + "\n", "utf-8")
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(mdLogFile, current + prefix + blocks.join("\n\n") + "\n", "utf-8")
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
- // Try to restore md-log file from a marker file if exists
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
- if (fs.existsSync(markerPath)) {
452
- const data = JSON.parse(fs.readFileSync(markerPath, "utf-8"))
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 (per plugin instance, survives across sessions but mdLogFile is global)
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 (mdLogFile) {
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
- void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
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 (mdLogFile) {
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
- void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
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 (_input, output) => {
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
- if (loggedTextPartIds.has(mid)) return
774
- loggedTextPartIds.add(mid)
775
- await withMdLock(() => appendToMdLog(userBlock(text)))
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
- if (partID && loggedTextPartIds.has(partID)) return
785
- if (partID) loggedTextPartIds.add(partID)
786
- await withMdLock(() => appendToMdLog(assistantBlock(stripSkillBlocks(text))))
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
- if (callID && loggedToolCallIds.has(`q:${callID}`)) return
802
- if (callID) loggedToolCallIds.add(`q:${callID}`)
803
- if (q) await withMdLock(() => appendToMdLog(questionCallout("Question", q, ctx2, opts)))
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
- if (callID && loggedToolCallIds.has(`answer:${callID}`)) return
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 withMdLock(() => appendToMdLog(answerCalloutAsk(details)))
819
- if (callID) loggedToolCallIds.add(`answer:${callID}`)
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
- if (loggedTextPartIds.has(part.id)) return
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(part.id)
847
- await withMdLock(() => appendToMdLog(assistantBlock(stripSkillBlocks(text))))
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
- if (mdLogFile) {
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 withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
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
- if (mdLogFile) {
933
- try { await withMdLock(() => appendToMdLog(questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
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
- if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])))
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
- if (mdLogFile) await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])))
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
- // Mirror each question in batch as a beautiful callout (like single quiz)
1033
- if (mdLogFile) {
1034
- for (let i = 0; i < normalized.length; i++) {
1035
- const q = normalized[i]
1036
- const label = `Quiz ${i + 1}/${normalized.length}`
1037
- try { await withMdLock(() => appendToMdLog(questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o: any) => ({ label: o.label })))) ) } catch {}
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
- if (mdLogFile) {
1044
- for (let i = 0; i < normalized.length; i++) {
1045
- const q = normalized[i]
1046
- const x = results[i] || {}
1047
- const details = {
1048
- status: "completed" as const,
1049
- answers: x.answers || [],
1050
- correct: !!x.correct,
1051
- correctIndices: q.correctIndices || [],
1052
- explanation: q.explanation || "",
1053
- dontKnow: !!x.dontKnow,
1054
- note: x.note,
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 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.",
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
- mdLogFile = resolved
1090
- try { fs.mkdirSync(path.dirname(markerPath), { recursive: true }); fs.writeFileSync(markerPath, JSON.stringify({ file: resolved }), "utf-8") } catch {}
1091
- // Backfill history for this session (like pi: ctx.sessionManager.getEntries() parent chain)
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
- if (sessionID) {
1095
- try { backfilled = await backfillMdLog(client, sessionID, directory) } catch (e) { slog("backfill error", String(e)) }
1096
- }
1097
- await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled } } })
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 the session to a markdown file.",
1210
+ description: "Stop mirroring THIS session to its markdown file (other sessions unaffected).",
1104
1211
  args: {},
1105
- async execute() {
1106
- if (!mdLogFile) return "No file linked"
1107
- const name = path.basename(mdLogFile)
1108
- mdLogFile = null
1109
- try { fs.writeFileSync(markerPath, JSON.stringify({ file: null }), "utf-8") } catch {}
1110
- await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}` } })
1111
- return `Unlinked: ${name}`
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