@bojackduy/opencode-learn 1.4.0 → 1.4.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 +66 -6
- package/dist/tui.js +67 -3
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +54 -3
- package/plugins/learn.ts +57 -7
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
|
|
366
|
-
if (!
|
|
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
|
-
|
|
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,69 @@ 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)
|
|
2645
|
-
|
|
2679
|
+
if (current) {
|
|
2680
|
+
const pendingStillExists = (() => {
|
|
2681
|
+
try {
|
|
2682
|
+
return fs.readdirSync(pendingDir).some((f) => f === `quiz-${current.id}.json` || f === `quiz_batch-${current.id}.json`);
|
|
2683
|
+
} catch {
|
|
2684
|
+
return true;
|
|
2685
|
+
}
|
|
2686
|
+
})();
|
|
2687
|
+
if (!pendingStillExists) {
|
|
2688
|
+
tlog("processPending stale current cleared (pending gone)", current.id);
|
|
2689
|
+
releasePopupClaim(current.id);
|
|
2690
|
+
currentBySession.delete(curSid);
|
|
2691
|
+
current = undefined;
|
|
2692
|
+
} else if (api.ui.dialog.open) {
|
|
2693
|
+
refreshPopupClaim(current.id);
|
|
2694
|
+
return;
|
|
2695
|
+
} else {
|
|
2696
|
+
tlog("processPending stale current cleared (dialog no longer open)", current.id);
|
|
2697
|
+
releasePopupClaim(current.id);
|
|
2698
|
+
currentBySession.delete(curSid);
|
|
2699
|
+
current = undefined;
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2646
2702
|
if (api.ui.dialog.open)
|
|
2647
2703
|
return;
|
|
2648
2704
|
let files = [];
|
|
@@ -2662,7 +2718,8 @@ var tui = async (api) => {
|
|
|
2662
2718
|
return null;
|
|
2663
2719
|
}
|
|
2664
2720
|
}).filter(Boolean).filter((x) => !hasAnswerArtifact(x.j.id));
|
|
2665
|
-
const
|
|
2721
|
+
const byNewest = [...matching].sort((a, b) => (b.j?.timestamp || 0) - (a.j?.timestamp || 0));
|
|
2722
|
+
const pick = byNewest.find((x) => x.j.sessionID === curSid) || byNewest.find((x) => !x.j.sessionID);
|
|
2666
2723
|
if (!pick)
|
|
2667
2724
|
return;
|
|
2668
2725
|
const file = pick.f;
|
|
@@ -2694,6 +2751,10 @@ var tui = async (api) => {
|
|
|
2694
2751
|
data.sessionID = curSid;
|
|
2695
2752
|
}
|
|
2696
2753
|
} catch {}
|
|
2754
|
+
if (!tryClaimPopup(data.id)) {
|
|
2755
|
+
tlog("processPending popup already claimed elsewhere", data.id);
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2697
2758
|
current = {
|
|
2698
2759
|
id: data.id,
|
|
2699
2760
|
type: data.type
|
|
@@ -2799,6 +2860,7 @@ var tui = async (api) => {
|
|
|
2799
2860
|
}
|
|
2800
2861
|
}
|
|
2801
2862
|
} catch {}
|
|
2863
|
+
releasePopupClaim(answerId);
|
|
2802
2864
|
api.ui.dialog.clear();
|
|
2803
2865
|
currentBySession.delete(curSid);
|
|
2804
2866
|
setTimeout(processPending, 150);
|
|
@@ -2858,6 +2920,7 @@ var tui = async (api) => {
|
|
|
2858
2920
|
} catch {}
|
|
2859
2921
|
}
|
|
2860
2922
|
} catch {}
|
|
2923
|
+
releasePopupClaim(data.id);
|
|
2861
2924
|
api.ui.dialog.clear();
|
|
2862
2925
|
currentBySession.delete(curSid);
|
|
2863
2926
|
setTimeout(processPending, 150);
|
|
@@ -2880,6 +2943,7 @@ var tui = async (api) => {
|
|
|
2880
2943
|
}));
|
|
2881
2944
|
} else {
|
|
2882
2945
|
tlog("processPending unknown", data.type, data.id);
|
|
2946
|
+
releasePopupClaim(data.id);
|
|
2883
2947
|
try {
|
|
2884
2948
|
fs.unlinkSync(full);
|
|
2885
2949
|
} 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.
|
|
4
|
+
"version": "1.4.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
7
|
"license": "AGPL-3.0-or-later",
|
package/plugins/learn-tui.tsx
CHANGED
|
@@ -787,18 +787,61 @@ 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)
|
|
815
|
+
if (current) {
|
|
816
|
+
// Self-heal: the tracked dialog may have been dismissed through a path other than
|
|
817
|
+
// done()/cancel() (session/tab switch, TUI reconnect, dialog replaced externally),
|
|
818
|
+
// or its pending file may have been consumed externally. A stuck entry would block
|
|
819
|
+
// ALL future quizzes for this session forever — release it and rediscover below.
|
|
820
|
+
const pendingStillExists = (() => { try { return fs.readdirSync(pendingDir).some((f) => f === `quiz-${current!.id}.json` || f === `quiz_batch-${current!.id}.json`) } catch { return true } })()
|
|
821
|
+
if (!pendingStillExists) {
|
|
822
|
+
tlog("processPending stale current cleared (pending gone)", current.id)
|
|
823
|
+
releasePopupClaim(current.id)
|
|
824
|
+
currentBySession.delete(curSid)
|
|
825
|
+
current = undefined
|
|
826
|
+
} else if (api.ui.dialog.open) { refreshPopupClaim(current.id); return }
|
|
827
|
+
else {
|
|
828
|
+
tlog("processPending stale current cleared (dialog no longer open)", current.id)
|
|
829
|
+
releasePopupClaim(current.id)
|
|
830
|
+
currentBySession.delete(curSid)
|
|
831
|
+
current = undefined
|
|
832
|
+
}
|
|
833
|
+
}
|
|
795
834
|
if (api.ui.dialog.open) return
|
|
796
835
|
let files: string[] = []
|
|
797
836
|
try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
|
|
798
837
|
// Session-distinct: only show pending for current session.
|
|
799
838
|
// Skip answered-pending (a response file exists, server is consuming): prevents re-popup after answer.
|
|
800
839
|
const matching = files.map(f => { try { const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8")) as any; return { f, j } } catch { return null } }).filter(Boolean).filter(x => !hasAnswerArtifact(x!.j.id)) as Array<{f: string, j: any}>
|
|
801
|
-
|
|
840
|
+
// Newest-first: when several quizzes stack up for the same session (e.g. earlier ones
|
|
841
|
+
// answered manually in chat after a popup failure), the LATEST quiz is the live one the
|
|
842
|
+
// agent is waiting on. Alphabetical order would keep re-showing the oldest stuck quiz.
|
|
843
|
+
const byNewest = [...matching].sort((a, b) => (((b as any).j?.timestamp || 0) as number) - (((a as any).j?.timestamp || 0) as number))
|
|
844
|
+
const pick = byNewest.find(x => x.j.sessionID === curSid) || byNewest.find(x => !x.j.sessionID)
|
|
802
845
|
if (!pick) return
|
|
803
846
|
const file = pick.f
|
|
804
847
|
const full = path.join(pendingDir, file)
|
|
@@ -817,6 +860,12 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
817
860
|
if (!exists) (data as any).sessionID = curSid
|
|
818
861
|
}
|
|
819
862
|
} catch {}
|
|
863
|
+
if (!tryClaimPopup(data.id)) {
|
|
864
|
+
// Another opencode window (same session open elsewhere) already owns this popup —
|
|
865
|
+
// don't show a second copy of it here, and don't answer it from this process.
|
|
866
|
+
tlog("processPending popup already claimed elsewhere", data.id)
|
|
867
|
+
return
|
|
868
|
+
}
|
|
820
869
|
current = { id: data.id, type: data.type }
|
|
821
870
|
currentBySession.set(curSid, current)
|
|
822
871
|
const done = async (result: any) => {
|
|
@@ -884,6 +933,7 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
884
933
|
} catch {}
|
|
885
934
|
// Keep the pending payload until the server confirms injection. It is the recovery context
|
|
886
935
|
// needed to rebuild the prompt if either process exits after the answer is written.
|
|
936
|
+
releasePopupClaim(answerId)
|
|
887
937
|
api.ui.dialog.clear()
|
|
888
938
|
currentBySession.delete(curSid)
|
|
889
939
|
setTimeout(processPending, 150)
|
|
@@ -918,13 +968,14 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
918
968
|
}
|
|
919
969
|
} catch {}
|
|
920
970
|
// Keep the pending payload until the server consumes the cancellation response.
|
|
971
|
+
releasePopupClaim(data!.id)
|
|
921
972
|
api.ui.dialog.clear()
|
|
922
973
|
currentBySession.delete(curSid)
|
|
923
974
|
setTimeout(processPending, 150)
|
|
924
975
|
}
|
|
925
976
|
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
977
|
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 }
|
|
978
|
+
else { tlog("processPending unknown", (data as any).type, data.id); releasePopupClaim(data.id); try { fs.unlinkSync(full) } catch {}; currentBySession.delete(curSid); return }
|
|
928
979
|
try { api.ui.dialog.setSize("large") } catch {}
|
|
929
980
|
}
|
|
930
981
|
|
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
|
|
282
|
-
if (!
|
|
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
|
-
|
|
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
|
},
|