opencode-mempalace-persistence 2.5.1 → 2.5.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/README.md CHANGED
@@ -177,7 +177,8 @@ The model responds
177
177
  → Model records new KG facts via MCP tools (only when something new emerged)
178
178
 
179
179
  Session goes idle / process exits
180
- → Background mine of everything new since last sync
180
+ → Background mine of everything new since last sync (per-wing cursors:
181
+ each wing advances independently, so one slow wing never stalls the rest)
181
182
  → TUI toast confirms what was mined (disable with `"toasts": false`)
182
183
 
183
184
  Every MemPalace call — plugin searches, model MCP calls (search, diary,
package/dist/index.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  declare const _default: ({ client }: any) => Promise<{
2
+ tool: {
3
+ mempalace_sync: {
4
+ description: string;
5
+ args: {};
6
+ execute(args: Record<string, never>, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
7
+ };
8
+ };
2
9
  "chat.message": (input: {
3
10
  sessionID: string;
4
11
  agent?: string;
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { homedir } from "os";
4
4
  import { join, dirname } from "path";
5
5
  import { createHash } from "crypto";
6
6
  import { fileURLToPath } from "url";
7
+ import { tool } from "@opencode-ai/plugin";
7
8
  const HOME = homedir();
8
9
  const MEMPALACE_BIN = join(HOME, ".local/bin/mempalace");
9
10
  const OPENCODE_DB = join(HOME, ".local/share/opencode/opencode.db");
@@ -82,21 +83,35 @@ function toastsEnabled() {
82
83
  catch { }
83
84
  return true;
84
85
  }
85
- // Messages arrived after the sync cursor: still waiting for the next run.
86
- function countPendingMessages(sinceMs) {
86
+ // Messages arrived after each wing's sync cursor: still waiting for the
87
+ // next run. Per-wing, so one slow wing never masks the others.
88
+ function countPendingMessages() {
87
89
  try {
90
+ const st = readSyncState();
88
91
  const out = runPython(`
89
- import sqlite3
92
+ import sqlite3, json, re
90
93
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
91
- n = db.execute("SELECT COUNT(*) FROM message WHERE time_created > ${sinceMs}").fetchone()[0]
94
+ cursors = json.loads(${JSON.stringify(JSON.stringify(st.wings || {}))})
95
+ default = ${st.last_sync_ms || 0}
96
+ rows = db.execute("""
97
+ SELECT s.directory, m.time_created FROM message m
98
+ INNER JOIN session s ON s.id = m.session_id
99
+ """).fetchall()
92
100
  db.close()
93
- print(n)
101
+ by = {}
102
+ for (directory, mts) in rows:
103
+ base = ((directory or "").rstrip("/").split("/") or ["global"])[-1] or "global"
104
+ wing = re.sub("[^a-zA-Z0-9_-]", "_", base)[:40] or "global"
105
+ if mts > cursors.get(wing, default):
106
+ by[wing] = by.get(wing, 0) + 1
107
+ print(json.dumps(by))
94
108
  `);
95
- const n = parseInt(out.trim(), 10);
96
- return isNaN(n) ? 0 : n;
109
+ const byWing = JSON.parse(out);
110
+ const total = Object.values(byWing).reduce((a, b) => a + b, 0);
111
+ return { total, byWing };
97
112
  }
98
113
  catch {
99
- return 0;
114
+ return { total: 0, byWing: {} };
100
115
  }
101
116
  }
102
117
  let cachedName = undefined;
@@ -147,7 +162,52 @@ function countPendingSuffix() {
147
162
  const pending = countPendingFiles();
148
163
  return pending > 0 ? `, ${pending} file(s) waiting to mine` : ", queue empty";
149
164
  }
150
- // TUI toast client (set by the factory). Fire-and-forget: headless runs
165
+ // On-demand sync snapshot for the /memory-toast command: same facts as
166
+ // the startup toast, plus live-mine detection. Fires a toast AND returns
167
+ // the text (visible in transcript too).
168
+ function liveMiners() {
169
+ const found = [];
170
+ let dir = [];
171
+ try {
172
+ dir = readdirSync(join(HOME, ".mempalace/locks"));
173
+ }
174
+ catch {
175
+ return found;
176
+ }
177
+ for (const f of dir) {
178
+ if (!f.endsWith(".lock"))
179
+ continue;
180
+ try {
181
+ const content = readFileSync(join(HOME, ".mempalace/locks", f), "utf-8");
182
+ const pid = parseInt((content.match(/(\d+)/) || [])[1] || "", 10);
183
+ if (!pid)
184
+ continue;
185
+ try {
186
+ process.kill(pid, 0);
187
+ found.push(`PID ${pid} (${f.replace("mine_palace_", "").replace(".lock", "").slice(0, 8)}…)`);
188
+ }
189
+ catch { }
190
+ }
191
+ catch { }
192
+ }
193
+ return found;
194
+ }
195
+ function syncSnapshot() {
196
+ const st = readSyncState();
197
+ const pending = countPendingFiles();
198
+ const miners = liveMiners();
199
+ const agoMin = st.last_sync_ms > 0 ? Math.round((Date.now() - st.last_sync_ms) / 60000) : -1;
200
+ const syncAge = agoMin < 0 ? "never" : agoMin === 0 ? "<1 min ago" : `${agoMin} min ago`;
201
+ const mining = miners.length > 0 ? `mining NOW (${miners.join(", ")})` : "no mine running";
202
+ const text = `MemPalace sync — plugin v${pluginVersion()}\n` +
203
+ `Last sync: ${syncAge}\n` +
204
+ `Backlog: ${pending} file(s) waiting\n` +
205
+ `Status: ${mining}`;
206
+ const toastMsg = miners.length > 0
207
+ ? `mining now (${miners.length}), ${pending} file(s) waiting, last sync ${syncAge}`
208
+ : `idle, ${pending} file(s) waiting, last sync ${syncAge}`;
209
+ return { text, toastMsg };
210
+ }
151
211
  // (`opencode run`, no TUI attached) must never break on this.
152
212
  let tuiClient = null;
153
213
  // Throttle for routine skip notices (busy palace): at most one toast
@@ -379,15 +439,26 @@ function summarizeToolCall(tool, args, out) {
379
439
  const answered = (out || "").replace(/\s+/g, " ").slice(0, 80) || "(empty)";
380
440
  return `${short} · asked: ${asked} → ${answered}`.slice(0, 220);
381
441
  }
382
- function getLastSync() {
383
- if (!existsSync(STATE_FILE))
384
- return 0;
442
+ function readSyncState() {
385
443
  try {
386
- return JSON.parse(readFileSync(STATE_FILE, "utf-8")).last_sync_ms || 0;
444
+ const raw = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
445
+ if (typeof raw?.last_sync_ms === "number") {
446
+ return { last_sync_ms: raw.last_sync_ms, wings: raw.wings && typeof raw.wings === "object" ? raw.wings : {} };
447
+ }
387
448
  }
388
- catch {
449
+ catch { }
450
+ return { last_sync_ms: 0, wings: {} };
451
+ }
452
+ // Per-wing cursors (see PR #1524 follow-up): a global cursor stalls
453
+ // forever when one wing keeps failing while others succeed. Each wing
454
+ // advances independently; last_sync_ms stays the min for compatibility.
455
+ function getLastSync(wing) {
456
+ if (!existsSync(STATE_FILE))
389
457
  return 0;
390
- }
458
+ const st = readSyncState();
459
+ if (wing && st.wings && typeof st.wings[wing] === "number")
460
+ return st.wings[wing];
461
+ return st.last_sync_ms || 0;
391
462
  }
392
463
  function dbSync() {
393
464
  if (miningLock)
@@ -402,11 +473,12 @@ function dbSync() {
402
473
  function backfillRequested() {
403
474
  return !!process.env.OPENCODE_MEMPALACE_BACKFILL;
404
475
  }
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) {
476
+ // Export sessions with new messages as flat transcripts, grouped by
477
+ // project wing. cursorFor(wing) gives each wing its own cursor (null =
478
+ // discovery floor: sessions with anything newer anywhere). Filenames
479
+ // embed a content hash, so re-exports are naturally idempotent.
480
+ function exportNewSessions(cursorFor) {
481
+ const sinceMs = cursorFor(null);
410
482
  const sessions = runPython(`
411
483
  import sqlite3, json
412
484
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
@@ -442,12 +514,13 @@ print(json.dumps(rows))
442
514
  .replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "global";
443
515
  const label = (title || "").replace(/[^a-zA-Z0-9 _-]/g, "_") || (sessId || "").slice(0, 12);
444
516
  const prefix = `${new Date().toISOString().slice(0, 10)}_${label.slice(0, 30)}_${(sessId || "").slice(0, 8)}`;
517
+ const wingSince = cursorFor(wing);
445
518
  const msgs = runPython(`
446
519
  import sqlite3, json
447
520
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
448
521
  rows = db.execute("""
449
522
  SELECT m.id, m.time_created, m.data FROM message m
450
- WHERE m.session_id = ${JSON.stringify(sessId)} AND m.time_created > ${sinceMs}
523
+ WHERE m.session_id = ${JSON.stringify(sessId)} AND m.time_created > ${wingSince}
451
524
  ORDER BY m.time_created
452
525
  """).fetchall()
453
526
  texts = []
@@ -460,11 +533,24 @@ for (mid, mts, mdata_raw) in rows:
460
533
  # is created when a reply STARTS, parts stream in afterwards, and
461
534
  # finish is set only on completion. Exporting mid-reply would
462
535
  # snapshot partial parts while the cursor advances past the message
463
- # timestamp — losing the rest of the reply forever. So assistant
464
- # messages without finish are skipped and revisited next sync.
536
+ # timestamp — losing the rest of the reply forever. So unfinished
537
+ # replies are skipped and revisited next sync — BUT only while
538
+ # recently active. A reply with no new parts for a while is dead
539
+ # (killed session, crashed run): treating it as perpetually
540
+ # in-flight would pin the cursor forever (seen live: a stillborn
541
+ # message froze sync for 7h). Dead replies are exported as-is.
542
+ now_ms = int(__import__("time").time() * 1000)
543
+ STALE_PART_MS = 30 * 60 * 1000
544
+ STALE_EMPTY_MS = 10 * 60 * 1000
465
545
  if role == "assistant" and not mdata.get("finish"):
466
- incomplete.append(mts)
467
- continue
546
+ max_part = db.execute("SELECT MAX(time_created) FROM part WHERE message_id = ?", (mid,)).fetchone()[0]
547
+ if max_part is None:
548
+ alive = (now_ms - mts) < STALE_EMPTY_MS
549
+ else:
550
+ alive = (now_ms - max_part) < STALE_PART_MS
551
+ if alive:
552
+ incomplete.append(mts)
553
+ continue
468
554
  for (pdata_raw,) in db.execute("SELECT data FROM part WHERE message_id = ? ORDER BY time_created", (mid,)).fetchall():
469
555
  try:
470
556
  pdata = json.loads(pdata_raw)
@@ -516,8 +602,23 @@ print(json.dumps({"texts": texts, "incomplete": incomplete}))
516
602
  }
517
603
  return { wings, now: cursor };
518
604
  }
519
- function markSynced(now) {
520
- writeFileSync(STATE_FILE, JSON.stringify({ last_sync_ms: now }));
605
+ function markSynced(now, wing) {
606
+ try {
607
+ const st = readSyncState();
608
+ if (wing) {
609
+ st.wings = st.wings || {};
610
+ st.wings[wing] = now;
611
+ const vals = Object.values(st.wings);
612
+ st.last_sync_ms = vals.length > 0 ? Math.min(...vals) : now;
613
+ }
614
+ else {
615
+ st.last_sync_ms = now;
616
+ }
617
+ writeFileSync(STATE_FILE, JSON.stringify(st));
618
+ }
619
+ catch (e) {
620
+ log("state write err: " + String(e));
621
+ }
521
622
  lastSyncTs = Date.now();
522
623
  }
523
624
  // Default `exchange` extraction: one drawer per exchange pair, verbatim,
@@ -531,15 +632,18 @@ function mineArgs(wingDir, wing) {
531
632
  return ["mine", wingDir, "--mode", "convos", "--agent", "opencode", "--wing", wing];
532
633
  }
533
634
  function cleanupExport(wings) {
534
- for (const files of wings.values()) {
535
- for (const f of files) {
536
- try {
537
- unlinkSync(f);
635
+ // Wipe whole wing dirs: a successful mine filed everything in them,
636
+ // including orphan files from previously failed runs.
637
+ for (const wing of wings.keys()) {
638
+ try {
639
+ for (const f of readdirSync(join(OUT_DIR, wing))) {
640
+ try {
641
+ unlinkSync(join(OUT_DIR, wing, f));
642
+ }
643
+ catch { }
538
644
  }
539
- catch { }
540
645
  }
541
- }
542
- for (const wing of wings.keys()) {
646
+ catch { }
543
647
  try {
544
648
  rmdirSync(join(OUT_DIR, wing));
545
649
  }
@@ -562,7 +666,10 @@ function doDbSync() {
562
666
  const sinceMs = backfillRequested() ? 0 : getLastSync();
563
667
  if (backfillRequested())
564
668
  log("backfill requested: exporting full history");
565
- const { wings, now } = exportNewSessions(sinceMs);
669
+ const cursorFor = backfillRequested()
670
+ ? (_wing) => 0
671
+ : (wing) => (wing ? getLastSync(wing) : getLastSync());
672
+ const { wings, now } = exportNewSessions(cursorFor);
566
673
  if (wings.size === 0)
567
674
  return;
568
675
  miningLock = true;
@@ -583,20 +690,16 @@ function doDbSync() {
583
690
  const mineNext = (i, attempt = 0) => {
584
691
  if (i >= entries.length) {
585
692
  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
693
  cleanupExport(wings);
591
694
  log("mine done");
592
695
  const names = [...wings.keys()].join(", ");
593
696
  const totalDrawers = [...wingDrawers.values()].reduce((a, b) => a + b, 0);
594
697
  const detail = totalDrawers > 0 ? ` (${totalDrawers} drawers)` : "";
595
698
  // 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";
699
+ const remaining = countPendingMessages();
700
+ const tail = remaining.total > 0 ? `, ${remaining.total} message(s) still waiting` : ", queue empty";
598
701
  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 });
702
+ ilog("mine", { outcome: "ok", sessions: wingCount(wings), wings: [...wings.keys()], drawers: totalDrawers, remaining: remaining.total });
600
703
  return;
601
704
  }
602
705
  const [wing, files] = entries[i];
@@ -644,6 +747,19 @@ function doDbSync() {
644
747
  }
645
748
  log(`mined wing ${wing} (${files.length} sessions)`);
646
749
  wingDrawers.set(wing, parseDrawers(stdout));
750
+ // Per-wing cursor: this wing's progress is banked even if a later
751
+ // wing fails — the counter never stalls on one slow wing again.
752
+ markSynced(now, wing);
753
+ for (const f of files) {
754
+ try {
755
+ unlinkSync(f);
756
+ }
757
+ catch { }
758
+ }
759
+ try {
760
+ rmdirSync(join(OUT_DIR, wing));
761
+ }
762
+ catch { }
647
763
  // Truthful progress: one toast per completed wing (an exact % is
648
764
  // impossible — the mine CLI is a black box with ~4s startup cost
649
765
  // per invocation, so per-file mines would only add overhead).
@@ -665,10 +781,11 @@ function exitSync() {
665
781
  const bin = resolveBin();
666
782
  if (!bin)
667
783
  return;
668
- const { wings, now } = exportNewSessions(getLastSync());
784
+ const { wings, now } = exportNewSessions((wing) => (wing ? getLastSync(wing) : getLastSync()));
669
785
  if (wings.size === 0)
670
786
  return;
671
787
  const deadline = Date.now() + EXIT_BUDGET_MS;
788
+ const done = [];
672
789
  for (const [wing] of wings) {
673
790
  const remaining = deadline - Date.now();
674
791
  if (remaining <= 0) {
@@ -681,12 +798,29 @@ function exitSync() {
681
798
  timeout: Math.min(EXIT_WING_TIMEOUT_MS, remaining),
682
799
  });
683
800
  if (res.error || res.status !== 0) {
684
- errLog(`exit mine err (${wing}): ${String(res.error || res.status)}`);
801
+ const why = res.error?.message || res.signal || res.status;
802
+ errLog(`exit mine err (${wing}): ${String(why)}`);
685
803
  return;
686
804
  }
805
+ markSynced(now, wing);
806
+ done.push(wing);
807
+ }
808
+ for (const wing of done) {
809
+ for (const f of wings.get(wing) || []) {
810
+ try {
811
+ unlinkSync(f);
812
+ }
813
+ catch { }
814
+ }
815
+ try {
816
+ rmdirSync(join(OUT_DIR, wing));
817
+ }
818
+ catch { }
687
819
  }
688
- markSynced(now);
689
- cleanupExport(wings);
820
+ try {
821
+ rmdirSync(OUT_DIR);
822
+ }
823
+ catch { }
690
824
  log("exit save done");
691
825
  }
692
826
  catch (e) {
@@ -727,6 +861,18 @@ export default (async ({ client }) => {
727
861
  process.once("SIGHUP", onExit);
728
862
  process.once("exit", onExit);
729
863
  return {
864
+ tool: {
865
+ mempalace_sync: tool({
866
+ description: "Show live MemPalace sync state (backlog, last sync, running mines) as a TUI toast and text. Use when the user asks how mining is going.",
867
+ args: {},
868
+ async execute() {
869
+ const snap = syncSnapshot();
870
+ toast("info", "MemPalace", snap.toastMsg);
871
+ ilog("status", { via: "tool" });
872
+ return snap.text;
873
+ },
874
+ }),
875
+ },
730
876
  "chat.message": async (input, output) => {
731
877
  const role = output.message.role;
732
878
  if (role !== "user")
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.3",
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",