@bojackduy/opencode-learn 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,10 +1,10 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-learn",
4
- "version": "1.2.0",
4
+ "version": "1.2.2",
5
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
- "license": "MIT",
7
+ "license": "AGPL-3.0-or-later",
8
8
  "private": false,
9
9
  "repository": {
10
10
  "type": "git",
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