opencode-mempalace-persistence 2.5.0 → 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 +127 -34
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -82,8 +82,37 @@ function toastsEnabled() {
82
82
  catch { }
83
83
  return true;
84
84
  }
85
- // Plugin's own name + version (shown in the startup toast and
86
- // /memory-status, so you always know which build is loaded).
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() {
88
+ try {
89
+ const st = readSyncState();
90
+ const out = runPython(`
91
+ import sqlite3, json, re
92
+ db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
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()
99
+ db.close()
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))
107
+ `);
108
+ const byWing = JSON.parse(out);
109
+ const total = Object.values(byWing).reduce((a, b) => a + b, 0);
110
+ return { total, byWing };
111
+ }
112
+ catch {
113
+ return { total: 0, byWing: {} };
114
+ }
115
+ }
87
116
  let cachedName = undefined;
88
117
  let cachedVersion = undefined;
89
118
  function pluginName() {
@@ -364,15 +393,26 @@ function summarizeToolCall(tool, args, out) {
364
393
  const answered = (out || "").replace(/\s+/g, " ").slice(0, 80) || "(empty)";
365
394
  return `${short} · asked: ${asked} → ${answered}`.slice(0, 220);
366
395
  }
367
- function getLastSync() {
368
- if (!existsSync(STATE_FILE))
369
- return 0;
396
+ function readSyncState() {
370
397
  try {
371
- 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
+ }
372
402
  }
373
- 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))
374
411
  return 0;
375
- }
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;
376
416
  }
377
417
  function dbSync() {
378
418
  if (miningLock)
@@ -387,11 +427,12 @@ function dbSync() {
387
427
  function backfillRequested() {
388
428
  return !!process.env.OPENCODE_MEMPALACE_BACKFILL;
389
429
  }
390
- // Export all sessions with new messages since `sinceMs` as flat transcripts,
391
- // grouped by project wing (official multi-project pattern: one wing per
392
- // project, so memories never leak across projects). Filenames embed a
393
- // content hash, so re-exports are naturally idempotent.
394
- 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);
395
436
  const sessions = runPython(`
396
437
  import sqlite3, json
397
438
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
@@ -427,12 +468,13 @@ print(json.dumps(rows))
427
468
  .replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "global";
428
469
  const label = (title || "").replace(/[^a-zA-Z0-9 _-]/g, "_") || (sessId || "").slice(0, 12);
429
470
  const prefix = `${new Date().toISOString().slice(0, 10)}_${label.slice(0, 30)}_${(sessId || "").slice(0, 8)}`;
471
+ const wingSince = cursorFor(wing);
430
472
  const msgs = runPython(`
431
473
  import sqlite3, json
432
474
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
433
475
  rows = db.execute("""
434
476
  SELECT m.id, m.time_created, m.data FROM message m
435
- 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}
436
478
  ORDER BY m.time_created
437
479
  """).fetchall()
438
480
  texts = []
@@ -501,8 +543,23 @@ print(json.dumps({"texts": texts, "incomplete": incomplete}))
501
543
  }
502
544
  return { wings, now: cursor };
503
545
  }
504
- function markSynced(now) {
505
- 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
+ }
506
563
  lastSyncTs = Date.now();
507
564
  }
508
565
  // Default `exchange` extraction: one drawer per exchange pair, verbatim,
@@ -516,15 +573,18 @@ function mineArgs(wingDir, wing) {
516
573
  return ["mine", wingDir, "--mode", "convos", "--agent", "opencode", "--wing", wing];
517
574
  }
518
575
  function cleanupExport(wings) {
519
- for (const files of wings.values()) {
520
- for (const f of files) {
521
- try {
522
- 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 { }
523
585
  }
524
- catch { }
525
586
  }
526
- }
527
- for (const wing of wings.keys()) {
587
+ catch { }
528
588
  try {
529
589
  rmdirSync(join(OUT_DIR, wing));
530
590
  }
@@ -547,7 +607,10 @@ function doDbSync() {
547
607
  const sinceMs = backfillRequested() ? 0 : getLastSync();
548
608
  if (backfillRequested())
549
609
  log("backfill requested: exporting full history");
550
- const { wings, now } = exportNewSessions(sinceMs);
610
+ const cursorFor = backfillRequested()
611
+ ? (_wing) => 0
612
+ : (wing) => (wing ? getLastSync(wing) : getLastSync());
613
+ const { wings, now } = exportNewSessions(cursorFor);
551
614
  if (wings.size === 0)
552
615
  return;
553
616
  miningLock = true;
@@ -568,17 +631,16 @@ function doDbSync() {
568
631
  const mineNext = (i, attempt = 0) => {
569
632
  if (i >= entries.length) {
570
633
  miningLock = false;
571
- // Advance state only on full success: on failure the same
572
- // content-hashed files are re-exported and retried at the next
573
- // sync (mine is idempotent).
574
- markSynced(now);
575
634
  cleanupExport(wings);
576
635
  log("mine done");
577
636
  const names = [...wings.keys()].join(", ");
578
637
  const totalDrawers = [...wingDrawers.values()].reduce((a, b) => a + b, 0);
579
638
  const detail = totalDrawers > 0 ? ` (${totalDrawers} drawers)` : "";
580
- toast("success", "MemPalace", `mined ${wingCount(wings)} session(s) ${names}${detail}`);
581
- ilog("mine", { outcome: "ok", sessions: wingCount(wings), wings: [...wings.keys()], drawers: totalDrawers });
639
+ // Anything that arrived while this mine was running stays pending.
640
+ const remaining = countPendingMessages();
641
+ const tail = remaining.total > 0 ? `, ${remaining.total} message(s) still waiting` : ", queue empty";
642
+ toast("success", "MemPalace", `mined ${wingCount(wings)} session(s) → ${names}${detail}${tail}`);
643
+ ilog("mine", { outcome: "ok", sessions: wingCount(wings), wings: [...wings.keys()], drawers: totalDrawers, remaining: remaining.total });
582
644
  return;
583
645
  }
584
646
  const [wing, files] = entries[i];
@@ -626,6 +688,19 @@ function doDbSync() {
626
688
  }
627
689
  log(`mined wing ${wing} (${files.length} sessions)`);
628
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 { }
629
704
  // Truthful progress: one toast per completed wing (an exact % is
630
705
  // impossible — the mine CLI is a black box with ~4s startup cost
631
706
  // per invocation, so per-file mines would only add overhead).
@@ -647,10 +722,11 @@ function exitSync() {
647
722
  const bin = resolveBin();
648
723
  if (!bin)
649
724
  return;
650
- const { wings, now } = exportNewSessions(getLastSync());
725
+ const { wings, now } = exportNewSessions((wing) => (wing ? getLastSync(wing) : getLastSync()));
651
726
  if (wings.size === 0)
652
727
  return;
653
728
  const deadline = Date.now() + EXIT_BUDGET_MS;
729
+ const done = [];
654
730
  for (const [wing] of wings) {
655
731
  const remaining = deadline - Date.now();
656
732
  if (remaining <= 0) {
@@ -663,12 +739,29 @@ function exitSync() {
663
739
  timeout: Math.min(EXIT_WING_TIMEOUT_MS, remaining),
664
740
  });
665
741
  if (res.error || res.status !== 0) {
666
- 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)}`);
667
744
  return;
668
745
  }
746
+ markSynced(now, wing);
747
+ done.push(wing);
669
748
  }
670
- markSynced(now);
671
- 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 { }
672
765
  log("exit save done");
673
766
  }
674
767
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-mempalace-persistence",
3
- "version": "2.5.0",
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",