@bojackduy/opencode-learn 1.4.0 → 1.4.1

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
@@ -210,7 +210,7 @@ function loadMdLinks(markerPath, directory) {
210
210
  for (const [ses, v] of Object.entries(links)) {
211
211
  const f = v?.file ?? (typeof v === "string" ? v : undefined);
212
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() });
213
+ mdLinks.set(ses, { file: f, directory: v?.directory || directory, linkedAt: v?.linkedAt || Date.now(), backfilledUntil: v?.backfilledUntil });
214
214
  n++;
215
215
  }
216
216
  }
@@ -355,19 +355,33 @@ function answerCalloutAsk(details) {
355
355
  body.push("(no answer)");
356
356
  return callout("example", "Answer", body);
357
357
  }
358
- async function backfillMdLog(client, sessionID, directory) {
358
+ async function backfillMdLog(client, sessionID, directory, markerPath) {
359
359
  const mdFile = getMdFile(sessionID);
360
360
  if (!mdFile || !sessionID)
361
361
  return 0;
362
+ const linkMeta = mdLinks.get(sessionID);
363
+ const already = linkMeta?.backfilledUntil;
362
364
  try {
363
365
  const res = await client.session.messages({ path: { id: sessionID }, query: { directory } });
364
366
  const data = res?.data ?? res;
365
- const entries = Array.isArray(data) ? data : [];
366
- if (!entries.length)
367
+ const allEntries = Array.isArray(data) ? data : [];
368
+ if (!allEntries.length)
367
369
  return 0;
370
+ let entries = allEntries;
371
+ if (already) {
372
+ const idx = allEntries.findIndex((e) => e?.info?.id === already);
373
+ if (idx < 0)
374
+ return 0;
375
+ entries = allEntries.slice(idx + 1);
376
+ if (!entries.length)
377
+ return 0;
378
+ }
368
379
  const blocks = [];
380
+ let lastID = already;
369
381
  for (const entry of entries) {
370
382
  const info = entry.info;
383
+ if (info?.id)
384
+ lastID = info.id;
371
385
  const parts = entry.parts ?? [];
372
386
  if (!info || !info.role)
373
387
  continue;
@@ -487,6 +501,10 @@ async function backfillMdLog(client, sessionID, directory) {
487
501
  `, "utf-8");
488
502
  }
489
503
  }
504
+ if (lastID && lastID !== already) {
505
+ mdLinks.set(sessionID, { ...linkMeta, file: mdFile, directory, backfilledUntil: lastID });
506
+ saveMdLinksForDirectory(markerPath, directory);
507
+ }
490
508
  return blocks.length;
491
509
  } catch (e) {
492
510
  slog("backfill failed", String(e));
@@ -522,6 +540,40 @@ function randomId() {
522
540
  } catch {}
523
541
  return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
524
542
  }
543
+ function ownerLockPath(dir, id) {
544
+ return path.join(dir, `owner-${id}.lock`);
545
+ }
546
+ function acquireOwnerLock(dir, id) {
547
+ const p = ownerLockPath(dir, id);
548
+ const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
549
+ try {
550
+ fs.writeFileSync(p, payload, { flag: "wx" });
551
+ return true;
552
+ } catch {}
553
+ try {
554
+ const age = Date.now() - fs.statSync(p).mtimeMs;
555
+ if (age > 15000) {
556
+ fs.writeFileSync(p, payload, "utf8");
557
+ return true;
558
+ }
559
+ } catch {
560
+ try {
561
+ fs.writeFileSync(p, payload, { flag: "wx" });
562
+ return true;
563
+ } catch {}
564
+ }
565
+ return false;
566
+ }
567
+ function refreshOwnerLock(dir, id) {
568
+ try {
569
+ fs.writeFileSync(ownerLockPath(dir, id), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8");
570
+ } catch {}
571
+ }
572
+ function releaseOwnerLock(dir, id) {
573
+ try {
574
+ fs.unlinkSync(ownerLockPath(dir, id));
575
+ } catch {}
576
+ }
525
577
  var activeWatchers = new Map;
526
578
  function watchAndInject(client, directory, id, sessionID, buildText) {
527
579
  slog("watchAndInject start", id, sessionID);
@@ -531,6 +583,10 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
531
583
  }
532
584
  activeWatchers.get(id)?.();
533
585
  const dir = pendingDir(directory);
586
+ if (!acquireOwnerLock(dir, id)) {
587
+ slog("watchAndInject lock busy, another process owns this id", id);
588
+ return;
589
+ }
534
590
  const respPath = path.join(dir, `response-${id}.json`);
535
591
  const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`);
536
592
  const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)];
@@ -548,6 +604,7 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
548
604
  clearInterval(pollTimer);
549
605
  if (activeWatchers.get(id) === closeWatcher)
550
606
  activeWatchers.delete(id);
607
+ releaseOwnerLock(dir, id);
551
608
  };
552
609
  activeWatchers.set(id, closeWatcher);
553
610
  const fire = async (attempt = 0) => {
@@ -666,6 +723,7 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
666
723
  watcher.on("error", () => {});
667
724
  } catch {}
668
725
  pollTimer = setInterval(() => {
726
+ refreshOwnerLock(dir, id);
669
727
  if (fs.existsSync(respPath)) {
670
728
  fire();
671
729
  return;
@@ -1495,11 +1553,13 @@ Explanation: ${eFixed}`;
1495
1553
  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.`;
1496
1554
  }
1497
1555
  }
1498
- mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() });
1556
+ const existingMeta = mdLinks.get(sessionID);
1557
+ const preservedWatermark = existingMeta?.file === resolved ? existingMeta.backfilledUntil : undefined;
1558
+ mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now(), backfilledUntil: preservedWatermark });
1499
1559
  saveMdLinksForDirectory(markerPath, directory);
1500
1560
  let backfilled = 0;
1501
1561
  try {
1502
- backfilled = await backfillMdLog(client, sessionID, directory);
1562
+ backfilled = await backfillMdLog(client, sessionID, directory, markerPath);
1503
1563
  } catch (e) {
1504
1564
  slog("backfill error", String(e));
1505
1565
  }
package/dist/tui.js CHANGED
@@ -2636,13 +2636,50 @@ var tui = async (api) => {
2636
2636
  return false;
2637
2637
  }
2638
2638
  };
2639
+ const popupLockPath = (id) => path.join(pendingDir, `opening-${id}.lock`);
2640
+ const tryClaimPopup = (id) => {
2641
+ const p = popupLockPath(id);
2642
+ try {
2643
+ fs.writeFileSync(p, String(process.pid), {
2644
+ flag: "wx"
2645
+ });
2646
+ return true;
2647
+ } catch {}
2648
+ try {
2649
+ const age = Date.now() - fs.statSync(p).mtimeMs;
2650
+ if (age > 20000) {
2651
+ fs.writeFileSync(p, String(process.pid), "utf8");
2652
+ return true;
2653
+ }
2654
+ } catch {
2655
+ try {
2656
+ fs.writeFileSync(p, String(process.pid), {
2657
+ flag: "wx"
2658
+ });
2659
+ return true;
2660
+ } catch {}
2661
+ }
2662
+ return false;
2663
+ };
2664
+ const refreshPopupClaim = (id) => {
2665
+ try {
2666
+ fs.writeFileSync(popupLockPath(id), String(process.pid), "utf8");
2667
+ } catch {}
2668
+ };
2669
+ const releasePopupClaim = (id) => {
2670
+ try {
2671
+ fs.unlinkSync(popupLockPath(id));
2672
+ } catch {}
2673
+ };
2639
2674
  const processPending = () => {
2640
2675
  const curSid = getCurrentSessionID();
2641
2676
  if (!curSid)
2642
2677
  return;
2643
2678
  let current = currentBySession.get(curSid);
2644
- if (current)
2679
+ if (current) {
2680
+ refreshPopupClaim(current.id);
2645
2681
  return;
2682
+ }
2646
2683
  if (api.ui.dialog.open)
2647
2684
  return;
2648
2685
  let files = [];
@@ -2694,6 +2731,10 @@ var tui = async (api) => {
2694
2731
  data.sessionID = curSid;
2695
2732
  }
2696
2733
  } catch {}
2734
+ if (!tryClaimPopup(data.id)) {
2735
+ tlog("processPending popup already claimed elsewhere", data.id);
2736
+ return;
2737
+ }
2697
2738
  current = {
2698
2739
  id: data.id,
2699
2740
  type: data.type
@@ -2799,6 +2840,7 @@ var tui = async (api) => {
2799
2840
  }
2800
2841
  }
2801
2842
  } catch {}
2843
+ releasePopupClaim(answerId);
2802
2844
  api.ui.dialog.clear();
2803
2845
  currentBySession.delete(curSid);
2804
2846
  setTimeout(processPending, 150);
@@ -2858,6 +2900,7 @@ var tui = async (api) => {
2858
2900
  } catch {}
2859
2901
  }
2860
2902
  } catch {}
2903
+ releasePopupClaim(data.id);
2861
2904
  api.ui.dialog.clear();
2862
2905
  currentBySession.delete(curSid);
2863
2906
  setTimeout(processPending, 150);
@@ -2880,6 +2923,7 @@ var tui = async (api) => {
2880
2923
  }));
2881
2924
  } else {
2882
2925
  tlog("processPending unknown", data.type, data.id);
2926
+ releasePopupClaim(data.id);
2883
2927
  try {
2884
2928
  fs.unlinkSync(full);
2885
2929
  } catch {}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-learn",
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
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
7
  "license": "AGPL-3.0-or-later",
@@ -787,11 +787,32 @@ export const tui: TuiPlugin = async (api) => {
787
787
  }
788
788
  }
789
789
 
790
+ // Cross-process single-popup lock: if the same session is open in more than one opencode
791
+ // window, each runs its own copy of this TUI plugin and independently polls the same
792
+ // pendingDir. Without this, two windows could both open the SAME quiz dialog and let the
793
+ // user answer twice — the second answer overwrites the (already-consumed) response file with
794
+ // nobody left watching it, so it silently rots as an orphan and never reaches the session
795
+ // ("the next quiz doesn't inject"). Only the window that wins this lock may show the dialog.
796
+ const popupLockPath = (id: string) => path.join(pendingDir, `opening-${id}.lock`)
797
+ const tryClaimPopup = (id: string): boolean => {
798
+ const p = popupLockPath(id)
799
+ try { fs.writeFileSync(p, String(process.pid), { flag: "wx" }); return true } catch {}
800
+ try {
801
+ const age = Date.now() - fs.statSync(p).mtimeMs
802
+ if (age > 20000) { fs.writeFileSync(p, String(process.pid), "utf8"); return true }
803
+ } catch {
804
+ try { fs.writeFileSync(p, String(process.pid), { flag: "wx" }); return true } catch {}
805
+ }
806
+ return false
807
+ }
808
+ const refreshPopupClaim = (id: string) => { try { fs.writeFileSync(popupLockPath(id), String(process.pid), "utf8") } catch {} }
809
+ const releasePopupClaim = (id: string) => { try { fs.unlinkSync(popupLockPath(id)) } catch {} }
810
+
790
811
  const processPending = () => {
791
812
  const curSid = getCurrentSessionID()
792
813
  if (!curSid) return
793
814
  let current = currentBySession.get(curSid) as { id: string; type: string } | undefined
794
- if (current) return
815
+ if (current) { refreshPopupClaim(current.id); return }
795
816
  if (api.ui.dialog.open) return
796
817
  let files: string[] = []
797
818
  try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
@@ -817,6 +838,12 @@ export const tui: TuiPlugin = async (api) => {
817
838
  if (!exists) (data as any).sessionID = curSid
818
839
  }
819
840
  } catch {}
841
+ if (!tryClaimPopup(data.id)) {
842
+ // Another opencode window (same session open elsewhere) already owns this popup —
843
+ // don't show a second copy of it here, and don't answer it from this process.
844
+ tlog("processPending popup already claimed elsewhere", data.id)
845
+ return
846
+ }
820
847
  current = { id: data.id, type: data.type }
821
848
  currentBySession.set(curSid, current)
822
849
  const done = async (result: any) => {
@@ -884,6 +911,7 @@ export const tui: TuiPlugin = async (api) => {
884
911
  } catch {}
885
912
  // Keep the pending payload until the server confirms injection. It is the recovery context
886
913
  // needed to rebuild the prompt if either process exits after the answer is written.
914
+ releasePopupClaim(answerId)
887
915
  api.ui.dialog.clear()
888
916
  currentBySession.delete(curSid)
889
917
  setTimeout(processPending, 150)
@@ -918,13 +946,14 @@ export const tui: TuiPlugin = async (api) => {
918
946
  }
919
947
  } catch {}
920
948
  // Keep the pending payload until the server consumes the cancellation response.
949
+ releasePopupClaim(data!.id)
921
950
  api.ui.dialog.clear()
922
951
  currentBySession.delete(curSid)
923
952
  setTimeout(processPending, 150)
924
953
  }
925
954
  if (data.type === "quiz") { tlog("processPending quiz", data.id); api.ui.dialog.replace(() => <QuizDialog api={api} request={data as QuizPending} onSubmit={done} onCancel={cancel} />) }
926
955
  else if (data.type === "quiz_batch") { tlog("processPending quiz_batch", data.id, (data as QuizBatchPending).quizzes.length); api.ui.dialog.replace(() => <QuizBatchDialog api={api} request={data as QuizBatchPending} onSubmit={done} onCancel={cancel} />) }
927
- else { tlog("processPending unknown", (data as any).type, data.id); try { fs.unlinkSync(full) } catch {}; currentBySession.delete(curSid); return }
956
+ else { tlog("processPending unknown", (data as any).type, data.id); releasePopupClaim(data.id); try { fs.unlinkSync(full) } catch {}; currentBySession.delete(curSid); return }
928
957
  try { api.ui.dialog.setSize("large") } catch {}
929
958
  }
930
959
 
package/plugins/learn.ts CHANGED
@@ -138,7 +138,7 @@ function decodeQuizText(s: string | undefined): string | undefined {
138
138
  // md-log helpers (ported from .pi/extensions/md-log.ts)
139
139
  // 1-1-1 model: session : link : log file. No global file.
140
140
  // ────────────────────────────────────────────────────────────────────────────
141
- type MdLinkMeta = { file: string; directory: string; linkedAt: number }
141
+ type MdLinkMeta = { file: string; directory: string; linkedAt: number; backfilledUntil?: string }
142
142
  const mdLinks = new Map<string, MdLinkMeta>() // sessionID -> link
143
143
  const mdFileLocks = new Map<string, Promise<void>>()
144
144
  function withMdFileLock<T>(file: string, fn: () => T | Promise<T>): Promise<T> {
@@ -183,7 +183,7 @@ function loadMdLinks(markerPath: string, directory: string) {
183
183
  for (const [ses, v] of Object.entries<any>(links)) {
184
184
  const f = (v as any)?.file ?? (typeof v === "string" ? v : undefined)
185
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() })
186
+ mdLinks.set(ses, { file: f, directory: (v as any)?.directory || directory, linkedAt: (v as any)?.linkedAt || Date.now(), backfilledUntil: (v as any)?.backfilledUntil })
187
187
  n++
188
188
  }
189
189
  }
@@ -272,17 +272,32 @@ function answerCalloutAsk(details: any): string {
272
272
  if (body.length === 0) body.push("(no answer)")
273
273
  return callout("example", "Answer", body)
274
274
  }
275
- async function backfillMdLog(client: any, sessionID: string, directory: string): Promise<number> {
275
+ async function backfillMdLog(client: any, sessionID: string, directory: string, markerPath: string): Promise<number> {
276
276
  const mdFile = getMdFile(sessionID)
277
277
  if (!mdFile || !sessionID) return 0
278
+ const linkMeta = mdLinks.get(sessionID)
279
+ const already = linkMeta?.backfilledUntil
278
280
  try {
279
281
  const res: any = await client.session.messages({ path: { id: sessionID }, query: { directory } })
280
282
  const data: any = res?.data ?? res
281
- const entries: any[] = Array.isArray(data) ? data : []
282
- if (!entries.length) return 0
283
+ const allEntries: any[] = Array.isArray(data) ? data : []
284
+ if (!allEntries.length) return 0
285
+ // Idempotency: skip everything up to (and including) the last message already backfilled,
286
+ // so re-linking the same file (md_log called again) never re-dumps history already written.
287
+ let entries = allEntries
288
+ if (already) {
289
+ const idx = allEntries.findIndex((e) => e?.info?.id === already)
290
+ // If the watermark message can't be found (pruned/compacted history), bail out rather
291
+ // than risk re-appending everything — live hooks still capture new messages going forward.
292
+ if (idx < 0) return 0
293
+ entries = allEntries.slice(idx + 1)
294
+ if (!entries.length) return 0
295
+ }
283
296
  const blocks: string[] = []
297
+ let lastID: string | undefined = already
284
298
  for (const entry of entries) {
285
299
  const info: any = entry.info
300
+ if (info?.id) lastID = info.id
286
301
  const parts: any[] = entry.parts ?? []
287
302
  if (!info || !info.role) continue
288
303
  if (info.role === "user") {
@@ -387,6 +402,12 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
387
402
  fs.writeFileSync(mdFile2, current + prefix + blocks.join("\n\n") + "\n", "utf-8")
388
403
  }
389
404
  }
405
+ // Persist the watermark whenever we've examined new entries, even if none produced a block,
406
+ // so a repeated md_log call never re-scans (and never re-appends) the same history again.
407
+ if (lastID && lastID !== already) {
408
+ mdLinks.set(sessionID, { ...(linkMeta as MdLinkMeta), file: mdFile, directory, backfilledUntil: lastID })
409
+ saveMdLinksForDirectory(markerPath, directory)
410
+ }
390
411
  return blocks.length
391
412
  } catch (e) {
392
413
  slog("backfill failed", String(e))
@@ -475,6 +496,27 @@ async function waitForResponse(directory: string, id: string, abort: AbortSignal
475
496
  })
476
497
  }
477
498
 
499
+ // Cross-process single-writer lock: multiple opencode processes (separate windows/tabs) can be
500
+ // attached to the same project directory, and each independently re-arms durable pending quizzes
501
+ // on its own startup. Without this, every process spins up its own fs.watch/poll for the same id,
502
+ // which is wasteful and — combined with the TUI-side popup lock below — is what caused a stray
503
+ // duplicate answer to go unwatched (the "second recovered quiz never injects" bug).
504
+ function ownerLockPath(dir: string, id: string) { return path.join(dir, `owner-${id}.lock`) }
505
+ function acquireOwnerLock(dir: string, id: string): boolean {
506
+ const p = ownerLockPath(dir, id)
507
+ const payload = JSON.stringify({ pid: process.pid, at: Date.now() })
508
+ try { fs.writeFileSync(p, payload, { flag: "wx" }); return true } catch {}
509
+ try {
510
+ const age = Date.now() - fs.statSync(p).mtimeMs
511
+ if (age > 15000) { fs.writeFileSync(p, payload, "utf8"); return true }
512
+ } catch {
513
+ try { fs.writeFileSync(p, payload, { flag: "wx" }); return true } catch {}
514
+ }
515
+ return false
516
+ }
517
+ function refreshOwnerLock(dir: string, id: string) { try { fs.writeFileSync(ownerLockPath(dir, id), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8") } catch {} }
518
+ function releaseOwnerLock(dir: string, id: string) { try { fs.unlinkSync(ownerLockPath(dir, id)) } catch {} }
519
+
478
520
  // Server-side inject (loopd pattern: host-adapter.ts:100 promptAsync + path.id + body.parts)
479
521
  const activeWatchers = new Map<string, () => void>()
480
522
  function watchAndInject(client: any, directory: string, id: string, sessionID: string, buildText: (result: any) => string) {
@@ -482,6 +524,7 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
482
524
  if (!sessionID) { slog("watchAndInject no sessionID", id); return }
483
525
  activeWatchers.get(id)?.()
484
526
  const dir = pendingDir(directory)
527
+ if (!acquireOwnerLock(dir, id)) { slog("watchAndInject lock busy, another process owns this id", id); return }
485
528
  const respPath = path.join(dir, `response-${id}.json`)
486
529
  const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`)
487
530
  const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)]
@@ -494,6 +537,7 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
494
537
  try { watcher?.close() } catch {}
495
538
  if (pollTimer) clearInterval(pollTimer)
496
539
  if (activeWatchers.get(id) === closeWatcher) activeWatchers.delete(id)
540
+ releaseOwnerLock(dir, id)
497
541
  }
498
542
  activeWatchers.set(id, closeWatcher)
499
543
  const fire = async (attempt = 0): Promise<void> => {
@@ -586,6 +630,7 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
586
630
  // fs.watch is lossy by design. Polling keeps persisted answers moving after a TUI/server restart
587
631
  // even when the filesystem event is dropped.
588
632
  pollTimer = setInterval(() => {
633
+ refreshOwnerLock(dir, id)
589
634
  if (fs.existsSync(respPath)) { void fire(); return }
590
635
  if (!pendingCandidates.some((p) => fs.existsSync(p)) && !fs.existsSync(claimPath)) closeWatcher()
591
636
  }, 500)
@@ -1330,11 +1375,16 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1330
1375
  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.`
1331
1376
  }
1332
1377
  }
1333
- mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() })
1378
+ // Re-linking the SAME file for this session (e.g. md_log called again after a
1379
+ // restart/reconnect) must preserve the backfill watermark — otherwise every re-link
1380
+ // would re-dump the entire session history into the file a second time.
1381
+ const existingMeta = mdLinks.get(sessionID)
1382
+ const preservedWatermark = existingMeta?.file === resolved ? existingMeta.backfilledUntil : undefined
1383
+ mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now(), backfilledUntil: preservedWatermark })
1334
1384
  saveMdLinksForDirectory(markerPath, directory)
1335
1385
  // Backfill history for this session only
1336
1386
  let backfilled = 0
1337
- try { backfilled = await backfillMdLog(client, sessionID, directory) } catch (e) { slog("backfill error", String(e)) }
1387
+ try { backfilled = await backfillMdLog(client, sessionID, directory, markerPath) } catch (e) { slog("backfill error", String(e)) }
1338
1388
  await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled, sessionID } } })
1339
1389
  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.`
1340
1390
  },