@bojackduy/opencode-learn 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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")
168
+ } catch {}
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")
155
201
  } catch {}
156
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}`)
@@ -192,17 +244,37 @@ function answerCalloutQuiz(details: any): string {
192
244
  if (details?.explanation) { body.push(""); for (const line of String(details.explanation).split("\n")) body.push(line) }
193
245
  return callout(type, title, body)
194
246
  }
247
+ function answerCalloutQuestion(questions: any[], answers: string[][]): string {
248
+ const qs = Array.isArray(questions) ? questions : []
249
+ const ans = Array.isArray(answers) ? answers : []
250
+ if (!qs.length) {
251
+ const flat = ans.map(a => Array.isArray(a) ? a.join(", ") : String(a ?? "")).filter(Boolean)
252
+ return callout("example", "Answer", flat.length ? flat : ["(no answer)"])
253
+ }
254
+ const body = qs.map((q: any, i: number) => {
255
+ const header = q?.header || `Q${i + 1}`
256
+ const sel: string[] = Array.isArray(ans[i]) ? ans[i] as string[] : []
257
+ return `${header}: ${sel.length ? sel.join(", ") : "(no answer)"}`
258
+ })
259
+ return callout("example", "Answer", body)
260
+ }
195
261
  function answerCalloutAsk(details: any): string {
196
262
  const status = details?.status
197
263
  if (status === "cancelled") return callout("warning", "Question — cancelled", ["(user skipped)"])
198
264
  if (status === "unavailable") return callout("warning", "Question — unavailable", [details?.message || ""])
199
265
  const answers: any[] = details?.answers || []
200
- const body: string[] = answers.map((a) => { if (a.type === "other") return `Other: ${a.label}`; if (a.type === "text") return a.label; return `${a.index}. ${a.label}` })
266
+ // Native opencode shape: string[][] (per-question selected labels)
267
+ if (answers.length && (answers as any[]).every(a => Array.isArray(a))) {
268
+ const questions: any[] = details?.questions || []
269
+ return answerCalloutQuestion(questions, answers as string[][])
270
+ }
271
+ const body: string[] = answers.map((a) => { if (a.type === "other") return `Other: ${a.label}`; if (a.type === "text") return a.label; if (typeof a === "string") return a; return `${a.index}. ${a.label}` })
201
272
  if (body.length === 0) body.push("(no answer)")
202
273
  return callout("example", "Answer", body)
203
274
  }
204
275
  async function backfillMdLog(client: any, sessionID: string, directory: string): Promise<number> {
205
- if (!mdLogFile || !sessionID) return 0
276
+ const mdFile = getMdFile(sessionID)
277
+ if (!mdFile || !sessionID) return 0
206
278
  try {
207
279
  const res: any = await client.session.messages({ path: { id: sessionID }, query: { directory } })
208
280
  const data: any = res?.data ?? res
@@ -257,6 +329,23 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
257
329
  }
258
330
  continue
259
331
  }
332
+ if (toolName === "question" && Array.isArray((input as any).questions)) {
333
+ const qs: any[] = (input as any).questions
334
+ if (st.status === "pending" || st.status === "running") {
335
+ qs.forEach((q: any, i: number) => {
336
+ if (!q?.question) return
337
+ blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []))
338
+ })
339
+ } else if (st.status === "completed") {
340
+ qs.forEach((q: any, i: number) => {
341
+ if (!q?.question) return
342
+ blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []))
343
+ })
344
+ const ans = Array.isArray(meta.answers) ? meta.answers : []
345
+ blocks.push(answerCalloutAsk({ answers: ans, questions: qs, status: "completed" }))
346
+ }
347
+ continue
348
+ }
260
349
  if (st.status === "pending" || st.status === "running") {
261
350
  if (input.question) {
262
351
  const opts = Array.isArray(input.options) ? input.options : []
@@ -286,15 +375,16 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
286
375
  }
287
376
  }
288
377
  if (blocks.length) {
378
+ const mdFile2 = getMdFile(sessionID) || mdFile
289
379
  let current = ""
290
- try { if (fs.existsSync(mdLogFile)) current = fs.readFileSync(mdLogFile, "utf-8") } catch {}
380
+ try { if (fs.existsSync(mdFile2)) current = fs.readFileSync(mdFile2, "utf-8") } catch {}
291
381
  // If file empty, overwrite; else append with separator (preserve user notes)
292
382
  if (current.trim().length === 0) {
293
- fs.writeFileSync(mdLogFile, blocks.join("\n\n") + "\n", "utf-8")
383
+ fs.writeFileSync(mdFile2, blocks.join("\n\n") + "\n", "utf-8")
294
384
  } else {
295
385
  // Avoid duplicating if already contains same session text
296
386
  const prefix = current.trim().length > 0 ? "\n\n" : ""
297
- fs.writeFileSync(mdLogFile, current + prefix + blocks.join("\n\n") + "\n", "utf-8")
387
+ fs.writeFileSync(mdFile2, current + prefix + blocks.join("\n\n") + "\n", "utf-8")
298
388
  }
299
389
  }
300
390
  return blocks.length
@@ -445,23 +535,22 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
445
535
  // Plugin definition
446
536
  // ────────────────────────────────────────────────────────────────────────────
447
537
  const server: Plugin = async ({ client, directory }) => {
448
- // Try to restore md-log file from a marker file if exists
538
+ // 1-1-1: restore session->file links for this directory (same session resumes, different session stays silent)
449
539
  const markerPath = path.join(directory, ".opencode", "learn-md-log.json")
450
540
  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
- }
541
+ const n = loadMdLinks(markerPath, directory)
542
+ if (n) slog("md-log links restored", n, markerPath)
455
543
  } catch {}
456
544
 
457
545
  // Session-scoped visual state (one per plugin instance; subagents get separate plugin instances per session, so isolation is natural)
458
546
  let mermaidSession: { workDir: string; bodyPath: string } | null = null
459
547
  let svgSession: { workDir: string; bodyPath: string } | null = null
460
548
 
461
- // md-log dedup state (per plugin instance, survives across sessions but mdLogFile is global)
549
+ // md-log dedup state keyed by session to avoid cross-session suppression (1-1-1)
462
550
  const loggedTextPartIds = new Set<string>()
463
551
  const loggedToolCallIds = new Set<string>()
464
552
  const messageIdToRole = new Map<string, string>()
553
+ const mdKey = (ses: string | undefined, id: string) => `${ses || "?"}:${id}`
465
554
 
466
555
  // ── Classify watcher: note → inferred options (learner-easy) — LLM-backed, not heuristic-only
467
556
  function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>, multiSelect?: boolean): number[] {
@@ -704,19 +793,23 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
704
793
  const dk = !!r?.dontKnow
705
794
  const ok = !dk && si.length === (j.correctIndices||[]).length && si.every((i:number)=>cs.has(i))
706
795
  const note = r?.note ? `\nNote: ${r.note}` : ""
707
- if (mdLogFile) {
796
+ if (getMdFile(j.sessionID)) {
708
797
  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)))
798
+ const sesJ = j.sessionID as string
799
+ const fJ = getMdFile(sesJ)!
800
+ void withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)))
710
801
  }
711
802
  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
803
  } else if (j.type === "quiz_batch") {
713
804
  const results = (r as any)?.results || []
714
- if (mdLogFile) {
805
+ if (getMdFile(j.sessionID)) {
715
806
  for (let i = 0; i < (j.quizzes||[]).length; i++) {
716
807
  const qq = j.quizzes[i]
717
808
  const x = results[i] || {}
718
809
  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)))
810
+ const sesJ = j.sessionID as string
811
+ const fJ = getMdFile(sesJ)!
812
+ void withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)))
720
813
  }
721
814
  }
722
815
  const lines = (j.quizzes || []).map((qq:any, i:number) => {
@@ -756,9 +849,11 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
756
849
  await client.app.log({ body: { service: "learn", level: "info", message: "learn plugin initialized", extra: { directory } } })
757
850
  },
758
851
 
759
- "chat.message": async (_input, output) => {
760
- if (!mdLogFile) return
852
+ "chat.message": async (input, output) => {
761
853
  try {
854
+ const ses = extractHookSessionID((input as any)?.sessionID, (output as any)?.message?.sessionID)
855
+ const mdFile = getMdFile(ses)
856
+ if (!mdFile || !ses) return
762
857
  const msg: any = (output as any).message
763
858
  const parts: any[] = (output as any).parts ?? []
764
859
  let text = ""
@@ -770,81 +865,109 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
770
865
  // Skip system-injected quiz/batch answer prompts — they are mirrored as beautiful callouts via watchAndInject, not as plain user quotes
771
866
  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
867
  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)))
868
+ const mkey = mdKey(ses, mid)
869
+ if (loggedTextPartIds.has(mkey)) return
870
+ loggedTextPartIds.add(mkey)
871
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, userBlock(text)))
776
872
  } catch {}
777
873
  },
778
874
  "experimental.text.complete": async (input, output) => {
779
- if (!mdLogFile) return
780
875
  try {
876
+ const ses = extractHookSessionID((input as any)?.sessionID)
877
+ const mdFile = getMdFile(ses)
878
+ if (!mdFile || !ses) return
781
879
  const text = (output as any).text?.trim()
782
880
  if (!text) return
783
881
  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))))
882
+ const pkey = partID ? mdKey(ses, partID) : undefined
883
+ if (pkey && loggedTextPartIds.has(pkey)) return
884
+ if (pkey) loggedTextPartIds.add(pkey)
885
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
787
886
  } catch {}
788
887
  },
789
- "tool.execute.before": async (input) => {
790
- if (!mdLogFile) return
888
+ "tool.execute.before": async (input, output) => {
791
889
  try {
890
+ const ses = extractHookSessionID((input as any)?.sessionID)
891
+ const mdFile = getMdFile(ses)
892
+ if (!mdFile || !ses) return
792
893
  const toolName = (input as any).tool
793
- const args = (input as any).args ?? {}
794
- // Built-in `question` tool is used as fallback when TUI not alive; mirror it.
795
- // `ask_user_question` is already mirrored inside its own execute (with correct TUI handling), so skip to avoid duplicate.
894
+ // NOTE: per Hooks type, before-hook args live in output.args (input only has tool/sessionID/callID).
895
+ const args = (output as any)?.args ?? (input as any).args ?? {}
896
+ // Native `question` tool shape: {questions: [{question, header, options, multiple}]}
796
897
  if (toolName === "question") {
797
- const q = args.question || args.header || ""
798
- const ctx2 = args.details?.trim() || undefined
799
- const opts = Array.isArray(args.options) ? args.options : []
800
898
  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)))
899
+ const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined
900
+ if (qkey && loggedToolCallIds.has(qkey)) return
901
+ if (qkey) loggedToolCallIds.add(qkey)
902
+ const qs: any[] = Array.isArray(args.questions) ? args.questions
903
+ : (args.question ? [{ question: args.question, header: args.header, options: args.options ?? [] }] : [])
904
+ for (let i = 0; i < qs.length; i++) {
905
+ const q = qs[i]
906
+ if (!q?.question) continue
907
+ const label = q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question")
908
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout(label, q.question, undefined, q.options ?? [])))
909
+ }
804
910
  }
805
911
  } catch {}
806
912
  },
807
913
  "tool.execute.after": async (input, output) => {
808
- if (!mdLogFile) return
809
914
  try {
915
+ const ses = extractHookSessionID((input as any)?.sessionID)
916
+ const mdFile = getMdFile(ses)
917
+ if (!mdFile || !ses) return
810
918
  const toolName = (input as any).tool
811
919
  const callID = (input as any).callID
812
- if (callID && loggedToolCallIds.has(`answer:${callID}`)) return
920
+ const akey = callID ? mdKey(ses, `answer:${callID}`) : undefined
921
+ if (akey && loggedToolCallIds.has(akey)) return
813
922
  if (toolName === "question") {
814
- const meta: any = (output as any).metadata ?? {}
815
- let answers: any[] = meta.answers ?? []
816
- if (!answers.length && (output as any).output) answers = []
817
- const details: any = { answers, status: "completed" }
818
- await withMdLock(() => appendToMdLog(answerCalloutAsk(details)))
819
- if (callID) loggedToolCallIds.add(`answer:${callID}`)
923
+ const meta: any = (output as any)?.metadata ?? {}
924
+ const inArgs: any = (input as any)?.args ?? {}
925
+ const qs: any[] = Array.isArray(inArgs.questions) ? inArgs.questions : []
926
+ let answers: any = meta.answers
927
+ if (!Array.isArray(answers) || !answers.length) {
928
+ const outText = typeof (output as any)?.output === "string" ? ((output as any).output as string).trim() : ""
929
+ if (outText) {
930
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, callout("example", "Answer", [outText.slice(0, 500)])))
931
+ if (akey) loggedToolCallIds.add(akey)
932
+ return
933
+ }
934
+ answers = []
935
+ }
936
+ const details: any = { answers, questions: qs, status: "completed" }
937
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)))
938
+ if (akey) loggedToolCallIds.add(akey)
820
939
  }
821
940
  } catch {}
822
941
  },
823
- // Mirror session to markdown file (best-effort, mirrors pi's md-log)
942
+ // Mirror session to markdown file (best-effort, mirrors pi's md-log) — 1-1-1 gated
824
943
  event: async ({ event }) => {
825
- if (!mdLogFile) return
826
944
  const t = (event as any).type as string
827
945
  const props = (event as any).properties ?? {}
828
946
  try {
829
947
  if (t === "message.updated") {
830
948
  const info: any = props.info
831
949
  if (info?.id && info?.role) messageIdToRole.set(info.id, info.role)
950
+ return
832
951
  } else if (t === "message.part.updated") {
833
952
  const part: any = props.part
834
953
  const delta: string | undefined = props.delta
835
954
  if (!part || !part.id) return
955
+ const ses = extractHookSessionID(part.sessionID, (props as any)?.sessionID, (props.info as any)?.sessionID)
956
+ const mdFile = getMdFile(ses)
957
+ if (!mdFile || !ses) return
836
958
  if (part.type === "text") {
837
959
  if (part.synthetic || part.ignored) return
838
960
  const isFinal = !!(part.time?.end !== undefined) || delta === undefined
839
961
  if (!isFinal) return
840
- if (loggedTextPartIds.has(part.id)) return
962
+ const pkey = mdKey(ses, part.id)
963
+ if (loggedTextPartIds.has(pkey)) return
841
964
  const text = (part.text || "").trim()
842
965
  if (!text) return
843
966
  const role = messageIdToRole.get(part.messageID)
844
967
  if (role === "user") return
845
968
  // Fallback for assistant when experimental.text.complete not fired
846
- loggedTextPartIds.add(part.id)
847
- await withMdLock(() => appendToMdLog(assistantBlock(stripSkillBlocks(text))))
969
+ loggedTextPartIds.add(pkey)
970
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
848
971
  }
849
972
  }
850
973
  } catch {}
@@ -905,6 +1028,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
905
1028
  }
906
1029
  try { fs.writeFileSync(pendingPath, JSON.stringify(payload), "utf8"); slog("quiz wrote durably", pendingPath, "alive", tuiAlive) } catch (e) { slog("quiz write failed", String(e)) }
907
1030
  try { await (ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
1031
+ const quizSes = (ctx as any).sessionID as string | undefined
908
1032
  watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
909
1033
  const dk = !!r?.dontKnow
910
1034
  const sel = (r?.answers || []).map((a: any) => `${a.index}. ${a.label}`).join(", ") || "(none)"
@@ -912,7 +1036,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
912
1036
  const si = (r?.answers || []).map((a: any) => a.index)
913
1037
  const ok = !dk && si.length === correctIndices.length && si.every((i: number) => cs.has(i))
914
1038
  const note = r?.note ? `\nNote: ${r.note}` : ""
915
- if (mdLogFile) {
1039
+ const qf = quizSes ? getMdFile(quizSes) : undefined
1040
+ if (qf && quizSes) {
916
1041
  const details = {
917
1042
  status: "completed" as const,
918
1043
  answers: r?.answers || [],
@@ -922,15 +1047,18 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
922
1047
  dontKnow: dk,
923
1048
  note: r?.note,
924
1049
  }
925
- void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
1050
+ void withMdFileLock(qf, () => appendToMdLogForSession(quizSes, answerCalloutQuiz(details)))
926
1051
  }
927
1052
  return dk
928
1053
  ? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
929
1054
  : `[quiz answered] "${qFixed}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
930
1055
  })
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 {}
1056
+ // Always mirror question with TRUE shuffled order (pi: tool_execution_update) — 1-1-1 gated
1057
+ {
1058
+ const qf = quizSes ? getMdFile(quizSes) : undefined
1059
+ if (qf && quizSes) {
1060
+ try { await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
1061
+ }
934
1062
  }
935
1063
  if (tuiAlive) {
936
1064
  return `[quiz displayed in TUI — waiting for your answer in the popup. I'll continue once you respond.]`
@@ -950,7 +1078,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
950
1078
  const trimmed = (raw as string).trim()
951
1079
  if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
952
1080
  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])))
1081
+ {
1082
+ const qf = quizSes ? getMdFile(quizSes) : undefined
1083
+ if (qf && quizSes) await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout("question", "Quiz — I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])))
1084
+ }
954
1085
  return msg
955
1086
  }
956
1087
  const nums = trimmed.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n >= 1 && n <= options.length)
@@ -961,7 +1092,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
961
1092
  const verdict = correct ? "correctly" : "incorrectly"
962
1093
  const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
963
1094
  ;(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])))
1095
+ {
1096
+ const qf = quizSes ? getMdFile(quizSes) : undefined
1097
+ if (qf && quizSes) await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])))
1098
+ }
965
1099
  return result
966
1100
  }
967
1101
  const instruction = [
@@ -1029,36 +1163,42 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1029
1163
  const file = path.join(pendingDirPath, `quiz_batch-${id}.json`)
1030
1164
  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
1165
  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 {}
1166
+ const batchSes = (ctx as any).sessionID as string | undefined
1167
+ // Mirror each question in batch as a beautiful callout (like single quiz) — 1-1-1 gated
1168
+ {
1169
+ const bf = batchSes ? getMdFile(batchSes) : undefined
1170
+ if (bf && batchSes) {
1171
+ for (let i = 0; i < normalized.length; i++) {
1172
+ const q = normalized[i]
1173
+ const label = `Quiz ${i + 1}/${normalized.length}`
1174
+ try { await withMdFileLock(bf, () => appendToMdLogForSession(batchSes, questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o: any) => ({ label: o.label }))))) } catch {}
1175
+ }
1038
1176
  }
1039
1177
  }
1040
1178
  watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
1041
1179
  const results = r?.results || []
1042
1180
  // 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,
1181
+ {
1182
+ const bf = batchSes ? getMdFile(batchSes) : undefined
1183
+ if (bf && batchSes) {
1184
+ for (let i = 0; i < normalized.length; i++) {
1185
+ const q = normalized[i]
1186
+ const x = results[i] || {}
1187
+ const details = {
1188
+ status: "completed" as const,
1189
+ answers: x.answers || [],
1190
+ correct: !!x.correct,
1191
+ correctIndices: q.correctIndices || [],
1192
+ explanation: q.explanation || "",
1193
+ dontKnow: !!x.dontKnow,
1194
+ note: x.note,
1195
+ }
1196
+ // Use same callout helper as single quiz but with batch label context
1197
+ try {
1198
+ // withMdFileLock is async, but watchAndInject buildText is sync — queue without await and let it flush
1199
+ void withMdFileLock(bf, () => appendToMdLogForSession(batchSes, answerCalloutQuiz(details)))
1200
+ } catch {}
1055
1201
  }
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
1202
  }
1063
1203
  }
1064
1204
  const lines = results.map((x: any, i: number) => {
@@ -1076,39 +1216,59 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1076
1216
  }
1077
1217
  }),
1078
1218
 
1079
- // ── md_log: link a markdown file ───────────────────────────────────
1219
+ // ── md_log: link a markdown file — 1-1-1 session:link:file ──────────
1080
1220
  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.",
1221
+ 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
1222
  args: {
1083
1223
  filepath: tool.schema.string().describe("Existing markdown file to link (relative to worktree or absolute). Must exist."),
1084
1224
  },
1085
1225
  async execute(args, ctx) {
1226
+ const sessionID = (ctx as any).sessionID as string | undefined
1227
+ if (!sessionID) return `md_log error: no sessionID in context — cannot establish 1-1-1 link`
1086
1228
  const resolved = path.isAbsolute(args.filepath) ? args.filepath : path.resolve(ctx.directory, args.filepath)
1087
1229
  if (!fs.existsSync(resolved)) return `File does not exist: ${resolved}`
1088
1230
  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)
1231
+ // Enforce 1-1-1: one file linked to at most one session
1232
+ for (const [ses, meta] of mdLinks) {
1233
+ if (meta.file === resolved && ses !== sessionID) {
1234
+ 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.`
1235
+ }
1236
+ }
1237
+ mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() })
1238
+ saveMdLinksForDirectory(markerPath, directory)
1239
+ // Backfill history for this session only
1092
1240
  let backfilled = 0
1241
+ try { backfilled = await backfillMdLog(client, sessionID, directory) } catch (e) { slog("backfill error", String(e)) }
1242
+ await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled, sessionID } } })
1243
+ 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.`
1244
+ },
1245
+ }),
1246
+
1247
+ md_log_status: tool({
1248
+ description: "Show md-log link status for this session and directory.",
1249
+ args: {},
1250
+ async execute(_args, ctx) {
1093
1251
  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.`
1252
+ const own = sessionID ? mdLinks.get(sessionID) : undefined
1253
+ let countDir = 0
1254
+ for (const [, meta] of mdLinks) if (meta.directory === directory) countDir++
1255
+ return `session ${sessionID?.slice(0,8) ?? "(none)"} -> ${own?.file ?? "(no link)"} | links in this directory: ${countDir}`
1099
1256
  },
1100
1257
  }),
1101
1258
 
1102
1259
  md_unlog: tool({
1103
- description: "Stop mirroring the session to a markdown file.",
1260
+ description: "Stop mirroring THIS session to its markdown file (other sessions unaffected).",
1104
1261
  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}`
1262
+ async execute(_args, ctx) {
1263
+ const sessionID = (ctx as any).sessionID as string | undefined
1264
+ if (!sessionID) return "No session in context"
1265
+ const meta = mdLinks.get(sessionID)
1266
+ if (!meta) return "No file linked for this session"
1267
+ const name = path.basename(meta.file)
1268
+ mdLinks.delete(sessionID)
1269
+ saveMdLinksForDirectory(markerPath, directory)
1270
+ await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}`, extra: { sessionID } } })
1271
+ return `Unlinked: ${name} from session ${sessionID.slice(0,8)} (other sessions unaffected)`
1112
1272
  },
1113
1273
  }),
1114
1274