opencode-mempalace-persistence 2.1.0 → 2.2.0
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 +18 -16
- package/dist/index.js +111 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,8 +6,12 @@ An OpenCode plugin that automatically saves every conversation to MemPalace and
|
|
|
6
6
|
|
|
7
7
|
Follows the official MemPalace automation pattern (same as the Claude Code hooks): the plugin decides **when** to save, the model decides **what** to file via the MemPalace MCP tools.
|
|
8
8
|
|
|
9
|
+
[](https://www.npmjs.com/package/opencode-mempalace-persistence)
|
|
10
|
+
[](https://www.npmjs.com/package/opencode-mempalace-persistence)
|
|
9
11
|
[](LICENSE)
|
|
10
12
|
|
|
13
|
+

|
|
14
|
+
|
|
11
15
|
---
|
|
12
16
|
|
|
13
17
|
## How it works in 3 seconds
|
|
@@ -170,8 +174,7 @@ You ask a question
|
|
|
170
174
|
→ Model files topics/decisions/quotes via MCP tools, then answers
|
|
171
175
|
|
|
172
176
|
The model responds
|
|
173
|
-
→
|
|
174
|
-
→ Saves the conversation to MemPalace (flat export, no hardcoded wings)
|
|
177
|
+
→ Once the turn completes, the next idle/exit/startup mines it to MemPalace (flat export, no hardcoded wings)
|
|
175
178
|
→ Model records new KG facts via MCP tools (only when something new emerged)
|
|
176
179
|
|
|
177
180
|
Session goes idle / process exits
|
|
@@ -190,7 +193,7 @@ Next time you ask
|
|
|
190
193
|
|
|
191
194
|
## What gets saved
|
|
192
195
|
|
|
193
|
-
Every turn (question + answer) is saved as a drawer in MemPalace.
|
|
196
|
+
Every turn (question + answer) is saved as a drawer in MemPalace. Mining runs with `--mode convos` (default `exchange` extraction: one drawer per exchange pair, verbatim, no paraphrasing). Exports are grouped one wing per project (official multi-project pattern: `bot-oc` sessions land in wing `bot-oc`, never leaking across projects). Only completed turns are exported (in-flight replies are revisited by the next sync). The model additionally records KG facts (decisions, milestones, preferences) during conversation and at each checkpoint via MCP tools.
|
|
194
197
|
|
|
195
198
|
### Backfill existing sessions
|
|
196
199
|
|
|
@@ -218,18 +221,16 @@ The plugin exports everything in the opencode database on the next sync, then re
|
|
|
218
221
|
│ ↓ │
|
|
219
222
|
│ Model sees context → answers │
|
|
220
223
|
│ ↓ │
|
|
221
|
-
Answer done ──►│ chat.message (count) + session.idle
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
│ single serialized call │
|
|
232
|
-
└──────────────────────────────┘
|
|
224
|
+
Answer done ──►│ chat.message (count) + session.idle │
|
|
225
|
+
│ mine on idle / exit / startup │
|
|
226
|
+
│ ↓ │
|
|
227
|
+
│ Query OpenCode DB (completed turns) │
|
|
228
|
+
│ ↓ │
|
|
229
|
+
│ Export → flat text files (0700) │
|
|
230
|
+
│ ↓ │
|
|
231
|
+
│ mempalace mine --mode convos │
|
|
232
|
+
│ single serialized call │
|
|
233
|
+
└──────────────────────────────────────────┘
|
|
233
234
|
│
|
|
234
235
|
▼
|
|
235
236
|
┌──────────────────────────┐
|
|
@@ -258,7 +259,8 @@ The plugin exports everything in the opencode database on the next sync, then re
|
|
|
258
259
|
| `~/.config/opencode/skills/mempalace-recall/SKILL.md` | Bundled recall skill (copy from `skills/` in this repo) |
|
|
259
260
|
| `~/.mempalace/identity.txt` | Your identity (injected by plugin) |
|
|
260
261
|
| `~/.mempalace/hook_state/opencode_counters.json` | Per-session message counters (checkpoint cadence) |
|
|
261
|
-
| `~/.mempalace/hook_state/hook.log` | Checkpoint / pre-compact event log |
|
|
262
|
+
| `~/.mempalace/hook_state/hook.log` | Checkpoint / pre-compact event log (errors always land here) |
|
|
263
|
+
| `~/.mempalace/oc-sessions/` | Private (0700) export workspace for pending transcripts |
|
|
262
264
|
| `~/.mempalace/config.json` | MemPalace config (palace path) |
|
|
263
265
|
| `~/.mempalace/knowledge_graph.sqlite3` | Knowledge Graph (structured facts) |
|
|
264
266
|
| `~/opencode-memory/` | MemPalace vector DB (all drawers) |
|
package/dist/index.js
CHANGED
|
@@ -12,8 +12,11 @@ const IDENTITY_FILE = join(HOME, ".mempalace/identity.txt");
|
|
|
12
12
|
const HOOK_STATE_DIR = join(HOME, ".mempalace/hook_state");
|
|
13
13
|
const COUNTERS_FILE = join(HOOK_STATE_DIR, "opencode_counters.json");
|
|
14
14
|
const HOOK_LOG = join(HOOK_STATE_DIR, "hook.log");
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Private sync workspace (0700): transcripts contain conversation text,
|
|
16
|
+
// so they must never sit world-readable in /tmp (see PR #1524 review).
|
|
17
|
+
const SYNC_DIR = join(HOME, ".mempalace/oc-sessions");
|
|
18
|
+
const OUT_DIR = SYNC_DIR;
|
|
19
|
+
const TMP_SCRIPT = join(SYNC_DIR, "oc-plugin-query.py");
|
|
17
20
|
const DEBUG = !!process.env.OPENCODE_MEMPALACE_DEBUG;
|
|
18
21
|
const LOG_FILE = "/tmp/opencode-mempalace.log";
|
|
19
22
|
const MAX_INJECT_CHARS = 900;
|
|
@@ -87,7 +90,8 @@ function runPython(code) {
|
|
|
87
90
|
const python = resolvePython();
|
|
88
91
|
if (!python)
|
|
89
92
|
throw new Error("no working Python interpreter (see hook.log)");
|
|
90
|
-
|
|
93
|
+
mkdirSync(SYNC_DIR, { recursive: true, mode: 0o700 });
|
|
94
|
+
writeFileSync(TMP_SCRIPT, code, { mode: 0o600 });
|
|
91
95
|
try {
|
|
92
96
|
// argv array, no shell (see PR #2): paths here are fixed, never user input.
|
|
93
97
|
return execFileSync(python, [TMP_SCRIPT], { encoding: "utf-8", timeout: 30000 }).trim();
|
|
@@ -99,6 +103,39 @@ function runPython(code) {
|
|
|
99
103
|
catch { }
|
|
100
104
|
}
|
|
101
105
|
}
|
|
106
|
+
// Resolve the mempalace CLI without hardcoding one installer layout:
|
|
107
|
+
// explicit env override, PATH lookup (cross-platform, incl. Windows),
|
|
108
|
+
// legacy ~/.local/bin fallback.
|
|
109
|
+
let resolvedBin = undefined;
|
|
110
|
+
function resolveBin() {
|
|
111
|
+
if (resolvedBin !== undefined)
|
|
112
|
+
return resolvedBin;
|
|
113
|
+
const envBin = process.env.MEMPALACE_BIN;
|
|
114
|
+
if (envBin && existsSync(envBin)) {
|
|
115
|
+
resolvedBin = envBin;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
try {
|
|
119
|
+
const found = execSync(process.platform === "win32" ? "where mempalace" : "command -v mempalace", {
|
|
120
|
+
encoding: "utf-8", timeout: 10000,
|
|
121
|
+
}).trim().split(/\r?\n/)[0]?.trim();
|
|
122
|
+
if (found)
|
|
123
|
+
resolvedBin = found;
|
|
124
|
+
}
|
|
125
|
+
catch { }
|
|
126
|
+
if (!resolvedBin && existsSync(MEMPALACE_BIN))
|
|
127
|
+
resolvedBin = MEMPALACE_BIN;
|
|
128
|
+
if (!resolvedBin)
|
|
129
|
+
resolvedBin = null;
|
|
130
|
+
}
|
|
131
|
+
if (resolvedBin) {
|
|
132
|
+
log("using mempalace: " + resolvedBin);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
errLog("mempalace CLI not found (tried MEMPALACE_BIN env, PATH, ~/.local/bin) — search/wake-up/mine disabled");
|
|
136
|
+
}
|
|
137
|
+
return resolvedBin;
|
|
138
|
+
}
|
|
102
139
|
function hasText(parts) {
|
|
103
140
|
return parts
|
|
104
141
|
.filter((p) => p?.type === "text" && p?.text?.trim())
|
|
@@ -143,9 +180,12 @@ function persistCounters(counters) {
|
|
|
143
180
|
}
|
|
144
181
|
}
|
|
145
182
|
function mempalaceWakeup() {
|
|
183
|
+
const bin = resolveBin();
|
|
184
|
+
if (!bin)
|
|
185
|
+
return "";
|
|
146
186
|
try {
|
|
147
187
|
// argv array, no shell.
|
|
148
|
-
const out = execFileSync(
|
|
188
|
+
const out = execFileSync(bin, ["wake-up"], { encoding: "utf-8", timeout: 15000 }).trim();
|
|
149
189
|
if (!out)
|
|
150
190
|
return "";
|
|
151
191
|
return out.slice(0, MAX_WAKEUP_CHARS);
|
|
@@ -178,10 +218,13 @@ function readIdentity() {
|
|
|
178
218
|
}
|
|
179
219
|
}
|
|
180
220
|
function mempalaceSearch(query) {
|
|
221
|
+
const bin = resolveBin();
|
|
222
|
+
if (!bin)
|
|
223
|
+
return "";
|
|
181
224
|
try {
|
|
182
225
|
// argv array, no shell (see PR #2): the query is raw user message
|
|
183
226
|
// text, so it must never pass through /bin/sh. No manual escaping needed.
|
|
184
|
-
const out = execFileSync(
|
|
227
|
+
const out = execFileSync(bin, ["search", query, "--results", String(MAX_SEARCH_RESULTS)], {
|
|
185
228
|
encoding: "utf-8",
|
|
186
229
|
timeout: 15000,
|
|
187
230
|
}).trim();
|
|
@@ -245,8 +288,11 @@ print(json.dumps(rows))
|
|
|
245
288
|
if (!sessionsArr || sessionsArr.length === 0)
|
|
246
289
|
return { wings: new Map(), now: Date.now() };
|
|
247
290
|
const now = Date.now();
|
|
291
|
+
// Never advance the cursor past an in-flight reply: anything skipped
|
|
292
|
+
// as incomplete is revisited by the next sync (idle/exit/startup).
|
|
293
|
+
let cursor = now;
|
|
248
294
|
const wings = new Map();
|
|
249
|
-
mkdirSync(OUT_DIR, { recursive: true });
|
|
295
|
+
mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
|
|
250
296
|
for (const sess of sessionsArr) {
|
|
251
297
|
const [sessId, title, , directory] = sess;
|
|
252
298
|
const wing = ((directory || "").split("/").filter(Boolean).pop() || "global")
|
|
@@ -262,10 +308,20 @@ rows = db.execute("""
|
|
|
262
308
|
ORDER BY m.time_created
|
|
263
309
|
""").fetchall()
|
|
264
310
|
texts = []
|
|
311
|
+
incomplete = []
|
|
265
312
|
for (mid, mts, mdata_raw) in rows:
|
|
266
313
|
try: mdata = json.loads(mdata_raw)
|
|
267
314
|
except: mdata = {}
|
|
268
315
|
role = mdata.get("role", "unknown")
|
|
316
|
+
# Completion tracking (see PR #1524 review): the assistant message row
|
|
317
|
+
# is created when a reply STARTS, parts stream in afterwards, and
|
|
318
|
+
# finish is set only on completion. Exporting mid-reply would
|
|
319
|
+
# snapshot partial parts while the cursor advances past the message
|
|
320
|
+
# timestamp — losing the rest of the reply forever. So assistant
|
|
321
|
+
# messages without finish are skipped and revisited next sync.
|
|
322
|
+
if role == "assistant" and not mdata.get("finish"):
|
|
323
|
+
incomplete.append(mts)
|
|
324
|
+
continue
|
|
269
325
|
for (pdata_raw,) in db.execute("SELECT data FROM part WHERE message_id = ? ORDER BY time_created", (mid,)).fetchall():
|
|
270
326
|
try:
|
|
271
327
|
pdata = json.loads(pdata_raw)
|
|
@@ -273,16 +329,22 @@ for (mid, mts, mdata_raw) in rows:
|
|
|
273
329
|
texts.append({"role": role, "text": pdata.get("text").strip(), "ts": mts})
|
|
274
330
|
except: pass
|
|
275
331
|
db.close()
|
|
276
|
-
print(json.dumps(texts))
|
|
332
|
+
print(json.dumps({"texts": texts, "incomplete": incomplete}))
|
|
277
333
|
`);
|
|
278
334
|
let msgList;
|
|
335
|
+
let incompleteTs = [];
|
|
279
336
|
try {
|
|
280
|
-
|
|
337
|
+
const parsed = JSON.parse(msgs);
|
|
338
|
+
msgList = parsed.texts;
|
|
339
|
+
incompleteTs = parsed.incomplete || [];
|
|
281
340
|
}
|
|
282
341
|
catch {
|
|
283
342
|
continue;
|
|
284
343
|
}
|
|
285
|
-
if (
|
|
344
|
+
if (incompleteTs.length > 0) {
|
|
345
|
+
cursor = Math.min(cursor, Math.min(...incompleteTs) - 1);
|
|
346
|
+
}
|
|
347
|
+
if (msgList.length < 2 && incompleteTs.length === 0)
|
|
286
348
|
continue;
|
|
287
349
|
const lines = [
|
|
288
350
|
`# ${title || label}`,
|
|
@@ -302,26 +364,28 @@ print(json.dumps(texts))
|
|
|
302
364
|
continue;
|
|
303
365
|
const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 12);
|
|
304
366
|
const wingDir = join(OUT_DIR, wing);
|
|
305
|
-
mkdirSync(wingDir, { recursive: true });
|
|
367
|
+
mkdirSync(wingDir, { recursive: true, mode: 0o700 });
|
|
306
368
|
const fname = `sync_${prefix}_${contentHash}.txt`;
|
|
307
|
-
writeFileSync(join(wingDir, fname), content + "\n");
|
|
369
|
+
writeFileSync(join(wingDir, fname), content + "\n", { mode: 0o600 });
|
|
308
370
|
if (!wings.has(wing))
|
|
309
371
|
wings.set(wing, []);
|
|
310
372
|
wings.get(wing).push(join(wingDir, fname));
|
|
311
373
|
}
|
|
312
|
-
return { wings, now };
|
|
374
|
+
return { wings, now: cursor };
|
|
313
375
|
}
|
|
314
376
|
function markSynced(now) {
|
|
315
377
|
writeFileSync(STATE_FILE, JSON.stringify({ last_sync_ms: now }));
|
|
316
378
|
lastSyncTs = Date.now();
|
|
317
379
|
}
|
|
318
|
-
//
|
|
319
|
-
//
|
|
380
|
+
// Default `exchange` extraction: one drawer per exchange pair, verbatim,
|
|
381
|
+
// no paraphrasing (see PR #1524 review). Intelligent filing (decisions,
|
|
382
|
+
// KG facts, diary) happens through AI checkpoints, not the miner.
|
|
383
|
+
// Agent tag keeps opencode-mined drawers attributable.
|
|
320
384
|
// One wing per project (official multi-project pattern).
|
|
321
385
|
// Argv array, no shell (see PR #2): wing names are sanitized, but the
|
|
322
386
|
// spawn path stays shell-free regardless.
|
|
323
387
|
function mineArgs(wingDir, wing) {
|
|
324
|
-
return ["mine", wingDir, "--mode", "convos", "--
|
|
388
|
+
return ["mine", wingDir, "--mode", "convos", "--agent", "opencode", "--wing", wing];
|
|
325
389
|
}
|
|
326
390
|
function cleanupExport(wings) {
|
|
327
391
|
for (const files of wings.values()) {
|
|
@@ -377,7 +441,13 @@ function doDbSync() {
|
|
|
377
441
|
// wrapper shell and orphan the python mine process, which keeps
|
|
378
442
|
// holding the palace lock while the next mine piles up. miningLock
|
|
379
443
|
// already serializes concurrent mines; long mines run to completion.
|
|
380
|
-
|
|
444
|
+
const bin = resolveBin();
|
|
445
|
+
if (!bin) {
|
|
446
|
+
miningLock = false;
|
|
447
|
+
errLog("mine skipped: mempalace CLI not found");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
execFile(bin, mineArgs(join(OUT_DIR, wing), wing), {
|
|
381
451
|
encoding: "utf-8",
|
|
382
452
|
}, (err) => {
|
|
383
453
|
if (err) {
|
|
@@ -392,17 +462,31 @@ function doDbSync() {
|
|
|
392
462
|
mineNext(0);
|
|
393
463
|
}
|
|
394
464
|
// Best-effort synchronous save for process exit (SIGINT/SIGTERM/exit):
|
|
395
|
-
// only synchronous calls are allowed here.
|
|
465
|
+
// only synchronous calls are allowed here. Bounded by EXIT_BUDGET_MS so
|
|
466
|
+
// shutdown stays fast; miningLock is deliberately ignored here because
|
|
467
|
+
// any in-flight async mine dies with the process — at exit this sync
|
|
468
|
+
// mine takes ownership (see PR #1524 review).
|
|
469
|
+
const EXIT_BUDGET_MS = 45000;
|
|
470
|
+
const EXIT_WING_TIMEOUT_MS = 30000;
|
|
396
471
|
function exitSync() {
|
|
397
472
|
try {
|
|
473
|
+
const bin = resolveBin();
|
|
474
|
+
if (!bin)
|
|
475
|
+
return;
|
|
398
476
|
const { wings, now } = exportNewSessions(getLastSync());
|
|
399
477
|
if (wings.size === 0)
|
|
400
478
|
return;
|
|
479
|
+
const deadline = Date.now() + EXIT_BUDGET_MS;
|
|
401
480
|
for (const [wing] of wings) {
|
|
481
|
+
const remaining = deadline - Date.now();
|
|
482
|
+
if (remaining <= 0) {
|
|
483
|
+
log("exit save: budget exhausted, rest covered next startup");
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
402
486
|
log(`exit save: mining wing ${wing}`);
|
|
403
|
-
const res = spawnSync(
|
|
487
|
+
const res = spawnSync(bin, mineArgs(join(OUT_DIR, wing), wing), {
|
|
404
488
|
encoding: "utf-8",
|
|
405
|
-
timeout:
|
|
489
|
+
timeout: Math.min(EXIT_WING_TIMEOUT_MS, remaining),
|
|
406
490
|
});
|
|
407
491
|
if (res.error || res.status !== 0) {
|
|
408
492
|
errLog(`exit mine err (${wing}): ${String(res.error || res.status)}`);
|
|
@@ -418,12 +502,15 @@ function exitSync() {
|
|
|
418
502
|
}
|
|
419
503
|
}
|
|
420
504
|
export default (async () => {
|
|
421
|
-
mkdirSync(OUT_DIR, { recursive: true });
|
|
505
|
+
mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
|
|
422
506
|
mkdirSync(HOOK_STATE_DIR, { recursive: true });
|
|
423
507
|
const autoInject = isAutoInjectEnabled();
|
|
424
508
|
const identity = readIdentity();
|
|
425
509
|
const interval = saveInterval();
|
|
426
510
|
log(`loaded (autoInjectContext: ${autoInject}, saveInterval: ${interval})`);
|
|
511
|
+
// Catch anything missed by a previous run (e.g. content skipped when
|
|
512
|
+
// the exit budget ran out). Fires once per server lifetime.
|
|
513
|
+
setTimeout(() => dbSync(), 10000);
|
|
427
514
|
// Crash safety: best-effort synchronous save on hard exit.
|
|
428
515
|
// Mirrors the official emergency-save intent (nothing async allowed here).
|
|
429
516
|
let exitHandled = false;
|
|
@@ -448,6 +535,10 @@ export default (async () => {
|
|
|
448
535
|
// Official Save-hook cadence: count human messages per session,
|
|
449
536
|
// persist like ~/.mempalace/hook_state/, arm ONE AI checkpoint
|
|
450
537
|
// per boundary. The model decides WHAT to file.
|
|
538
|
+
// NOTE: no mine here by design (see PR #1524 review) — mining a
|
|
539
|
+
// mid-reply snapshot would export partial assistant parts. Mines
|
|
540
|
+
// run on idle/exit/startup, when turns are complete; the export
|
|
541
|
+
// additionally skips unfinished replies (finish tracking).
|
|
451
542
|
const counters = loadCounters();
|
|
452
543
|
const c = counters[sessionID] || { humanMsgs: 0, lastCheckpoint: 0 };
|
|
453
544
|
c.humanMsgs += 1;
|
|
@@ -456,8 +547,6 @@ export default (async () => {
|
|
|
456
547
|
c.lastCheckpoint = boundary;
|
|
457
548
|
pendingCheckpoint = { sessionID, count: c.humanMsgs };
|
|
458
549
|
hookLog(`session ${sessionID}: ${c.humanMsgs} human msgs — checkpoint armed`);
|
|
459
|
-
log("threshold crossed - queue sync");
|
|
460
|
-
setTimeout(() => dbSync(), 500);
|
|
461
550
|
}
|
|
462
551
|
counters[sessionID] = c;
|
|
463
552
|
persistCounters(counters);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-mempalace-persistence",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
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",
|