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 +2 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +192 -46
- package/package.json +1 -1
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
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
96
|
-
|
|
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
|
-
//
|
|
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
|
|
383
|
-
if (!existsSync(STATE_FILE))
|
|
384
|
-
return 0;
|
|
442
|
+
function readSyncState() {
|
|
385
443
|
try {
|
|
386
|
-
|
|
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
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// content hash, so re-exports are naturally idempotent.
|
|
409
|
-
function exportNewSessions(
|
|
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 > ${
|
|
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
|
|
464
|
-
#
|
|
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
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
689
|
-
|
|
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.
|
|
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",
|