opencode-mempalace-persistence 2.5.1 → 2.5.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.
Files changed (2) hide show
  1. package/dist/index.js +116 -41
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -82,21 +82,35 @@ function toastsEnabled() {
82
82
  catch { }
83
83
  return true;
84
84
  }
85
- // Messages arrived after the sync cursor: still waiting for the next run.
86
- function countPendingMessages(sinceMs) {
85
+ // Messages arrived after each wing's sync cursor: still waiting for the
86
+ // next run. Per-wing, so one slow wing never masks the others.
87
+ function countPendingMessages() {
87
88
  try {
89
+ const st = readSyncState();
88
90
  const out = runPython(`
89
- import sqlite3
91
+ import sqlite3, json, re
90
92
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
91
- n = db.execute("SELECT COUNT(*) FROM message WHERE time_created > ${sinceMs}").fetchone()[0]
93
+ cursors = json.loads(${JSON.stringify(JSON.stringify(st.wings || {}))})
94
+ default = ${st.last_sync_ms || 0}
95
+ rows = db.execute("""
96
+ SELECT s.directory, m.time_created FROM message m
97
+ INNER JOIN session s ON s.id = m.session_id
98
+ """).fetchall()
92
99
  db.close()
93
- print(n)
100
+ by = {}
101
+ for (directory, mts) in rows:
102
+ base = ((directory or "").rstrip("/").split("/") or ["global"])[-1] or "global"
103
+ wing = re.sub("[^a-zA-Z0-9_-]", "_", base)[:40] or "global"
104
+ if mts > cursors.get(wing, default):
105
+ by[wing] = by.get(wing, 0) + 1
106
+ print(json.dumps(by))
94
107
  `);
95
- const n = parseInt(out.trim(), 10);
96
- return isNaN(n) ? 0 : n;
108
+ const byWing = JSON.parse(out);
109
+ const total = Object.values(byWing).reduce((a, b) => a + b, 0);
110
+ return { total, byWing };
97
111
  }
98
112
  catch {
99
- return 0;
113
+ return { total: 0, byWing: {} };
100
114
  }
101
115
  }
102
116
  let cachedName = undefined;
@@ -379,15 +393,26 @@ function summarizeToolCall(tool, args, out) {
379
393
  const answered = (out || "").replace(/\s+/g, " ").slice(0, 80) || "(empty)";
380
394
  return `${short} · asked: ${asked} → ${answered}`.slice(0, 220);
381
395
  }
382
- function getLastSync() {
383
- if (!existsSync(STATE_FILE))
384
- return 0;
396
+ function readSyncState() {
385
397
  try {
386
- return JSON.parse(readFileSync(STATE_FILE, "utf-8")).last_sync_ms || 0;
398
+ const raw = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
399
+ if (typeof raw?.last_sync_ms === "number") {
400
+ return { last_sync_ms: raw.last_sync_ms, wings: raw.wings && typeof raw.wings === "object" ? raw.wings : {} };
401
+ }
387
402
  }
388
- catch {
403
+ catch { }
404
+ return { last_sync_ms: 0, wings: {} };
405
+ }
406
+ // Per-wing cursors (see PR #1524 follow-up): a global cursor stalls
407
+ // forever when one wing keeps failing while others succeed. Each wing
408
+ // advances independently; last_sync_ms stays the min for compatibility.
409
+ function getLastSync(wing) {
410
+ if (!existsSync(STATE_FILE))
389
411
  return 0;
390
- }
412
+ const st = readSyncState();
413
+ if (wing && st.wings && typeof st.wings[wing] === "number")
414
+ return st.wings[wing];
415
+ return st.last_sync_ms || 0;
391
416
  }
392
417
  function dbSync() {
393
418
  if (miningLock)
@@ -402,11 +427,12 @@ function dbSync() {
402
427
  function backfillRequested() {
403
428
  return !!process.env.OPENCODE_MEMPALACE_BACKFILL;
404
429
  }
405
- // Export all sessions with new messages since `sinceMs` as flat transcripts,
406
- // grouped by project wing (official multi-project pattern: one wing per
407
- // project, so memories never leak across projects). Filenames embed a
408
- // content hash, so re-exports are naturally idempotent.
409
- function exportNewSessions(sinceMs) {
430
+ // Export sessions with new messages as flat transcripts, grouped by
431
+ // project wing. cursorFor(wing) gives each wing its own cursor (null =
432
+ // discovery floor: sessions with anything newer anywhere). Filenames
433
+ // embed a content hash, so re-exports are naturally idempotent.
434
+ function exportNewSessions(cursorFor) {
435
+ const sinceMs = cursorFor(null);
410
436
  const sessions = runPython(`
411
437
  import sqlite3, json
412
438
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
@@ -442,12 +468,13 @@ print(json.dumps(rows))
442
468
  .replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "global";
443
469
  const label = (title || "").replace(/[^a-zA-Z0-9 _-]/g, "_") || (sessId || "").slice(0, 12);
444
470
  const prefix = `${new Date().toISOString().slice(0, 10)}_${label.slice(0, 30)}_${(sessId || "").slice(0, 8)}`;
471
+ const wingSince = cursorFor(wing);
445
472
  const msgs = runPython(`
446
473
  import sqlite3, json
447
474
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
448
475
  rows = db.execute("""
449
476
  SELECT m.id, m.time_created, m.data FROM message m
450
- WHERE m.session_id = ${JSON.stringify(sessId)} AND m.time_created > ${sinceMs}
477
+ WHERE m.session_id = ${JSON.stringify(sessId)} AND m.time_created > ${wingSince}
451
478
  ORDER BY m.time_created
452
479
  """).fetchall()
453
480
  texts = []
@@ -516,8 +543,23 @@ print(json.dumps({"texts": texts, "incomplete": incomplete}))
516
543
  }
517
544
  return { wings, now: cursor };
518
545
  }
519
- function markSynced(now) {
520
- writeFileSync(STATE_FILE, JSON.stringify({ last_sync_ms: now }));
546
+ function markSynced(now, wing) {
547
+ try {
548
+ const st = readSyncState();
549
+ if (wing) {
550
+ st.wings = st.wings || {};
551
+ st.wings[wing] = now;
552
+ const vals = Object.values(st.wings);
553
+ st.last_sync_ms = vals.length > 0 ? Math.min(...vals) : now;
554
+ }
555
+ else {
556
+ st.last_sync_ms = now;
557
+ }
558
+ writeFileSync(STATE_FILE, JSON.stringify(st));
559
+ }
560
+ catch (e) {
561
+ log("state write err: " + String(e));
562
+ }
521
563
  lastSyncTs = Date.now();
522
564
  }
523
565
  // Default `exchange` extraction: one drawer per exchange pair, verbatim,
@@ -531,15 +573,18 @@ function mineArgs(wingDir, wing) {
531
573
  return ["mine", wingDir, "--mode", "convos", "--agent", "opencode", "--wing", wing];
532
574
  }
533
575
  function cleanupExport(wings) {
534
- for (const files of wings.values()) {
535
- for (const f of files) {
536
- try {
537
- unlinkSync(f);
576
+ // Wipe whole wing dirs: a successful mine filed everything in them,
577
+ // including orphan files from previously failed runs.
578
+ for (const wing of wings.keys()) {
579
+ try {
580
+ for (const f of readdirSync(join(OUT_DIR, wing))) {
581
+ try {
582
+ unlinkSync(join(OUT_DIR, wing, f));
583
+ }
584
+ catch { }
538
585
  }
539
- catch { }
540
586
  }
541
- }
542
- for (const wing of wings.keys()) {
587
+ catch { }
543
588
  try {
544
589
  rmdirSync(join(OUT_DIR, wing));
545
590
  }
@@ -562,7 +607,10 @@ function doDbSync() {
562
607
  const sinceMs = backfillRequested() ? 0 : getLastSync();
563
608
  if (backfillRequested())
564
609
  log("backfill requested: exporting full history");
565
- const { wings, now } = exportNewSessions(sinceMs);
610
+ const cursorFor = backfillRequested()
611
+ ? (_wing) => 0
612
+ : (wing) => (wing ? getLastSync(wing) : getLastSync());
613
+ const { wings, now } = exportNewSessions(cursorFor);
566
614
  if (wings.size === 0)
567
615
  return;
568
616
  miningLock = true;
@@ -583,20 +631,16 @@ function doDbSync() {
583
631
  const mineNext = (i, attempt = 0) => {
584
632
  if (i >= entries.length) {
585
633
  miningLock = false;
586
- // Advance state only on full success: on failure the same
587
- // content-hashed files are re-exported and retried at the next
588
- // sync (mine is idempotent).
589
- markSynced(now);
590
634
  cleanupExport(wings);
591
635
  log("mine done");
592
636
  const names = [...wings.keys()].join(", ");
593
637
  const totalDrawers = [...wingDrawers.values()].reduce((a, b) => a + b, 0);
594
638
  const detail = totalDrawers > 0 ? ` (${totalDrawers} drawers)` : "";
595
639
  // Anything that arrived while this mine was running stays pending.
596
- const remaining = countPendingMessages(now);
597
- const tail = remaining > 0 ? `, ${remaining} message(s) still waiting` : ", queue empty";
640
+ const remaining = countPendingMessages();
641
+ const tail = remaining.total > 0 ? `, ${remaining.total} message(s) still waiting` : ", queue empty";
598
642
  toast("success", "MemPalace", `mined ${wingCount(wings)} session(s) → ${names}${detail}${tail}`);
599
- ilog("mine", { outcome: "ok", sessions: wingCount(wings), wings: [...wings.keys()], drawers: totalDrawers, remaining });
643
+ ilog("mine", { outcome: "ok", sessions: wingCount(wings), wings: [...wings.keys()], drawers: totalDrawers, remaining: remaining.total });
600
644
  return;
601
645
  }
602
646
  const [wing, files] = entries[i];
@@ -644,6 +688,19 @@ function doDbSync() {
644
688
  }
645
689
  log(`mined wing ${wing} (${files.length} sessions)`);
646
690
  wingDrawers.set(wing, parseDrawers(stdout));
691
+ // Per-wing cursor: this wing's progress is banked even if a later
692
+ // wing fails — the counter never stalls on one slow wing again.
693
+ markSynced(now, wing);
694
+ for (const f of files) {
695
+ try {
696
+ unlinkSync(f);
697
+ }
698
+ catch { }
699
+ }
700
+ try {
701
+ rmdirSync(join(OUT_DIR, wing));
702
+ }
703
+ catch { }
647
704
  // Truthful progress: one toast per completed wing (an exact % is
648
705
  // impossible — the mine CLI is a black box with ~4s startup cost
649
706
  // per invocation, so per-file mines would only add overhead).
@@ -665,10 +722,11 @@ function exitSync() {
665
722
  const bin = resolveBin();
666
723
  if (!bin)
667
724
  return;
668
- const { wings, now } = exportNewSessions(getLastSync());
725
+ const { wings, now } = exportNewSessions((wing) => (wing ? getLastSync(wing) : getLastSync()));
669
726
  if (wings.size === 0)
670
727
  return;
671
728
  const deadline = Date.now() + EXIT_BUDGET_MS;
729
+ const done = [];
672
730
  for (const [wing] of wings) {
673
731
  const remaining = deadline - Date.now();
674
732
  if (remaining <= 0) {
@@ -681,12 +739,29 @@ function exitSync() {
681
739
  timeout: Math.min(EXIT_WING_TIMEOUT_MS, remaining),
682
740
  });
683
741
  if (res.error || res.status !== 0) {
684
- errLog(`exit mine err (${wing}): ${String(res.error || res.status)}`);
742
+ const why = res.error?.message || res.signal || res.status;
743
+ errLog(`exit mine err (${wing}): ${String(why)}`);
685
744
  return;
686
745
  }
746
+ markSynced(now, wing);
747
+ done.push(wing);
687
748
  }
688
- markSynced(now);
689
- cleanupExport(wings);
749
+ for (const wing of done) {
750
+ for (const f of wings.get(wing) || []) {
751
+ try {
752
+ unlinkSync(f);
753
+ }
754
+ catch { }
755
+ }
756
+ try {
757
+ rmdirSync(join(OUT_DIR, wing));
758
+ }
759
+ catch { }
760
+ }
761
+ try {
762
+ rmdirSync(OUT_DIR);
763
+ }
764
+ catch { }
690
765
  log("exit save done");
691
766
  }
692
767
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-mempalace-persistence",
3
- "version": "2.5.1",
3
+ "version": "2.5.2",
4
4
  "description": "OpenCode plugin — auto-sync conversations to MemPalace memory in real-time. No forced wings, KG extraction via MCP tools.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",