opencode-mempalace-persistence 2.0.1 → 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.
Files changed (3) hide show
  1. package/README.md +18 -16
  2. package/dist/index.js +174 -32
  3. 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
+ [![npm version](https://img.shields.io/npm/v/opencode-mempalace-persistence.svg)](https://www.npmjs.com/package/opencode-mempalace-persistence)
10
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-mempalace-persistence.svg)](https://www.npmjs.com/package/opencode-mempalace-persistence)
9
11
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
12
 
13
+ ![Demo: a decision filed on Monday is recalled verbatim by a different session on Thursday — memory outlives sessions, not just compaction](demo.gif)
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
- Plugin detects the response is complete
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. No forced categorization — mining runs with `--mode convos --extract general`, so MemPalace itself classifies content into decisions, preferences, milestones, problems, and emotional context. Exports are grouped one wing per project (official multi-project pattern: `bot-oc` sessions land in wing `bot-oc`, never leaking across projects). The model additionally records KG facts (decisions, milestones, preferences) during conversation and at each checkpoint via MCP tools.
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
- Every N msgs / idle / exit:
223
- │ ↓
224
- │ Query OpenCode DB
225
- since last sync
226
-
227
- Export → flat text files
228
-
229
- mempalace mine --mode convos
230
- │ --extract general (async) │
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
@@ -1,10 +1,9 @@
1
- import { execSync, exec, spawnSync } from "child_process";
1
+ import { execSync, execFileSync, execFile, spawnSync } from "child_process";
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync, rmdirSync, unlinkSync, appendFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { join } from "path";
5
5
  import { createHash } from "crypto";
6
6
  const HOME = homedir();
7
- const VENV_PYTHON = join(HOME, ".local/share/pipx/venvs/mempalace/bin/python3");
8
7
  const MEMPALACE_BIN = join(HOME, ".local/bin/mempalace");
9
8
  const OPENCODE_DB = join(HOME, ".local/share/opencode/opencode.db");
10
9
  const STATE_FILE = join(HOME, ".mempalace/sync_state.json");
@@ -13,8 +12,11 @@ const IDENTITY_FILE = join(HOME, ".mempalace/identity.txt");
13
12
  const HOOK_STATE_DIR = join(HOME, ".mempalace/hook_state");
14
13
  const COUNTERS_FILE = join(HOOK_STATE_DIR, "opencode_counters.json");
15
14
  const HOOK_LOG = join(HOOK_STATE_DIR, "hook.log");
16
- const OUT_DIR = "/tmp/oc-sessions";
17
- const TMP_SCRIPT = "/tmp/oc-plugin-query.py";
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");
18
20
  const DEBUG = !!process.env.OPENCODE_MEMPALACE_DEBUG;
19
21
  const LOG_FILE = "/tmp/opencode-mempalace.log";
20
22
  const MAX_INJECT_CHARS = 900;
@@ -38,6 +40,45 @@ function hookLog(msg) {
38
40
  }
39
41
  catch { }
40
42
  }
43
+ // Errors are never silent: hook.log is always written (unlike the
44
+ // DEBUG-gated log), so a broken pipeline is visible by default.
45
+ function errLog(msg) {
46
+ log("ERROR: " + msg);
47
+ hookLog("ERROR: " + msg);
48
+ }
49
+ // Probe for a working Python interpreter at startup instead of hardcoding
50
+ // one installer layout (pipx vs uv tool vs system). runPython only needs
51
+ // stdlib (sqlite3/json), so any python3 works. Priority: explicit env
52
+ // override, legacy pipx venv, uv tool venv, PATH fallback.
53
+ let resolvedPython = undefined;
54
+ function resolvePython() {
55
+ if (resolvedPython !== undefined)
56
+ return resolvedPython;
57
+ const candidates = [
58
+ process.env.MEMPALACE_PYTHON,
59
+ join(HOME, ".local/share/pipx/venvs/mempalace/bin/python3"),
60
+ join(HOME, ".local/share/uv/tools/mempalace/bin/python3"),
61
+ ].filter((p) => !!p && existsSync(p));
62
+ if (candidates.length > 0) {
63
+ resolvedPython = candidates[0];
64
+ }
65
+ else {
66
+ try {
67
+ execSync("python3 --version", { encoding: "utf-8", timeout: 10000 });
68
+ resolvedPython = "python3";
69
+ }
70
+ catch {
71
+ resolvedPython = null;
72
+ }
73
+ }
74
+ if (resolvedPython) {
75
+ log("using python: " + resolvedPython);
76
+ }
77
+ else {
78
+ errLog("no working Python interpreter found (tried MEMPALACE_PYTHON, pipx venv, uv tool venv, PATH python3) — DB export disabled");
79
+ }
80
+ return resolvedPython;
81
+ }
41
82
  let miningLock = false;
42
83
  let lastSyncTs = 0;
43
84
  let wakeupDone = false;
@@ -46,14 +87,55 @@ let wakeupDone = false;
46
87
  // official Stop hook: the hook decides WHEN, the model decides WHAT).
47
88
  let pendingCheckpoint = null;
48
89
  function runPython(code) {
49
- writeFileSync(TMP_SCRIPT, code);
90
+ const python = resolvePython();
91
+ if (!python)
92
+ throw new Error("no working Python interpreter (see hook.log)");
93
+ mkdirSync(SYNC_DIR, { recursive: true, mode: 0o700 });
94
+ writeFileSync(TMP_SCRIPT, code, { mode: 0o600 });
50
95
  try {
51
- return execSync(`${VENV_PYTHON} ${TMP_SCRIPT}`, { encoding: "utf-8", timeout: 30000 }).trim();
96
+ // argv array, no shell (see PR #2): paths here are fixed, never user input.
97
+ return execFileSync(python, [TMP_SCRIPT], { encoding: "utf-8", timeout: 30000 }).trim();
52
98
  }
53
99
  finally {
54
- unlinkSync(TMP_SCRIPT);
100
+ try {
101
+ unlinkSync(TMP_SCRIPT);
102
+ }
103
+ catch { }
55
104
  }
56
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
+ }
57
139
  function hasText(parts) {
58
140
  return parts
59
141
  .filter((p) => p?.type === "text" && p?.text?.trim())
@@ -98,8 +180,12 @@ function persistCounters(counters) {
98
180
  }
99
181
  }
100
182
  function mempalaceWakeup() {
183
+ const bin = resolveBin();
184
+ if (!bin)
185
+ return "";
101
186
  try {
102
- const out = execSync(`${MEMPALACE_BIN} wake-up`, { encoding: "utf-8", timeout: 15000 }).trim();
187
+ // argv array, no shell.
188
+ const out = execFileSync(bin, ["wake-up"], { encoding: "utf-8", timeout: 15000 }).trim();
103
189
  if (!out)
104
190
  return "";
105
191
  return out.slice(0, MAX_WAKEUP_CHARS);
@@ -132,8 +218,13 @@ function readIdentity() {
132
218
  }
133
219
  }
134
220
  function mempalaceSearch(query) {
221
+ const bin = resolveBin();
222
+ if (!bin)
223
+ return "";
135
224
  try {
136
- const out = execSync(`${MEMPALACE_BIN} search "${query.replace(/"/g, '\\"')}" --results ${MAX_SEARCH_RESULTS}`, {
225
+ // argv array, no shell (see PR #2): the query is raw user message
226
+ // text, so it must never pass through /bin/sh. No manual escaping needed.
227
+ const out = execFileSync(bin, ["search", query, "--results", String(MAX_SEARCH_RESULTS)], {
137
228
  encoding: "utf-8",
138
229
  timeout: 15000,
139
230
  }).trim();
@@ -162,7 +253,7 @@ function dbSync() {
162
253
  doDbSync();
163
254
  }
164
255
  catch (e) {
165
- log("sync err: " + String(e));
256
+ errLog("sync err: " + String(e));
166
257
  }
167
258
  }
168
259
  function backfillRequested() {
@@ -197,8 +288,11 @@ print(json.dumps(rows))
197
288
  if (!sessionsArr || sessionsArr.length === 0)
198
289
  return { wings: new Map(), now: Date.now() };
199
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;
200
294
  const wings = new Map();
201
- mkdirSync(OUT_DIR, { recursive: true });
295
+ mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
202
296
  for (const sess of sessionsArr) {
203
297
  const [sessId, title, , directory] = sess;
204
298
  const wing = ((directory || "").split("/").filter(Boolean).pop() || "global")
@@ -214,10 +308,20 @@ rows = db.execute("""
214
308
  ORDER BY m.time_created
215
309
  """).fetchall()
216
310
  texts = []
311
+ incomplete = []
217
312
  for (mid, mts, mdata_raw) in rows:
218
313
  try: mdata = json.loads(mdata_raw)
219
314
  except: mdata = {}
220
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
221
325
  for (pdata_raw,) in db.execute("SELECT data FROM part WHERE message_id = ? ORDER BY time_created", (mid,)).fetchall():
222
326
  try:
223
327
  pdata = json.loads(pdata_raw)
@@ -225,16 +329,22 @@ for (mid, mts, mdata_raw) in rows:
225
329
  texts.append({"role": role, "text": pdata.get("text").strip(), "ts": mts})
226
330
  except: pass
227
331
  db.close()
228
- print(json.dumps(texts))
332
+ print(json.dumps({"texts": texts, "incomplete": incomplete}))
229
333
  `);
230
334
  let msgList;
335
+ let incompleteTs = [];
231
336
  try {
232
- msgList = JSON.parse(msgs);
337
+ const parsed = JSON.parse(msgs);
338
+ msgList = parsed.texts;
339
+ incompleteTs = parsed.incomplete || [];
233
340
  }
234
341
  catch {
235
342
  continue;
236
343
  }
237
- if (msgList.length < 2)
344
+ if (incompleteTs.length > 0) {
345
+ cursor = Math.min(cursor, Math.min(...incompleteTs) - 1);
346
+ }
347
+ if (msgList.length < 2 && incompleteTs.length === 0)
238
348
  continue;
239
349
  const lines = [
240
350
  `# ${title || label}`,
@@ -254,24 +364,28 @@ print(json.dumps(texts))
254
364
  continue;
255
365
  const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 12);
256
366
  const wingDir = join(OUT_DIR, wing);
257
- mkdirSync(wingDir, { recursive: true });
367
+ mkdirSync(wingDir, { recursive: true, mode: 0o700 });
258
368
  const fname = `sync_${prefix}_${contentHash}.txt`;
259
- writeFileSync(join(wingDir, fname), content + "\n");
369
+ writeFileSync(join(wingDir, fname), content + "\n", { mode: 0o600 });
260
370
  if (!wings.has(wing))
261
371
  wings.set(wing, []);
262
372
  wings.get(wing).push(join(wingDir, fname));
263
373
  }
264
- return { wings, now };
374
+ return { wings, now: cursor };
265
375
  }
266
376
  function markSynced(now) {
267
377
  writeFileSync(STATE_FILE, JSON.stringify({ last_sync_ms: now }));
268
378
  lastSyncTs = Date.now();
269
379
  }
270
- // Official classification: decisions, preferences, milestones, problems,
271
- // emotional context. Agent tag keeps opencode-mined drawers attributable.
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.
272
384
  // One wing per project (official multi-project pattern).
273
- function mineCommand(wingDir, wing) {
274
- return `${MEMPALACE_BIN} mine ${wingDir} --mode convos --extract general --agent opencode --wing ${wing}`;
385
+ // Argv array, no shell (see PR #2): wing names are sanitized, but the
386
+ // spawn path stays shell-free regardless.
387
+ function mineArgs(wingDir, wing) {
388
+ return ["mine", wingDir, "--mode", "convos", "--agent", "opencode", "--wing", wing];
275
389
  }
276
390
  function cleanupExport(wings) {
277
391
  for (const files of wings.values()) {
@@ -323,13 +437,22 @@ function doDbSync() {
323
437
  return;
324
438
  }
325
439
  const [wing, files] = entries[i];
326
- exec(mineCommand(join(OUT_DIR, wing), wing), {
440
+ // No timeout here by design (see PR #4): Node would kill only the
441
+ // wrapper shell and orphan the python mine process, which keeps
442
+ // holding the palace lock while the next mine piles up. miningLock
443
+ // already serializes concurrent mines; long mines run to completion.
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), {
327
451
  encoding: "utf-8",
328
- timeout: 300000,
329
452
  }, (err) => {
330
453
  if (err) {
331
454
  miningLock = false;
332
- log(`mine err (${wing}): ${err.message}`);
455
+ errLog(`mine err (${wing}): ${err.message}`);
333
456
  return;
334
457
  }
335
458
  log(`mined wing ${wing} (${files.length} sessions)`);
@@ -339,20 +462,34 @@ function doDbSync() {
339
462
  mineNext(0);
340
463
  }
341
464
  // Best-effort synchronous save for process exit (SIGINT/SIGTERM/exit):
342
- // 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;
343
471
  function exitSync() {
344
472
  try {
473
+ const bin = resolveBin();
474
+ if (!bin)
475
+ return;
345
476
  const { wings, now } = exportNewSessions(getLastSync());
346
477
  if (wings.size === 0)
347
478
  return;
479
+ const deadline = Date.now() + EXIT_BUDGET_MS;
348
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
+ }
349
486
  log(`exit save: mining wing ${wing}`);
350
- const res = spawnSync(MEMPALACE_BIN, ["mine", join(OUT_DIR, wing), "--mode", "convos", "--extract", "general", "--agent", "opencode", "--wing", wing], {
487
+ const res = spawnSync(bin, mineArgs(join(OUT_DIR, wing), wing), {
351
488
  encoding: "utf-8",
352
- timeout: 60000,
489
+ timeout: Math.min(EXIT_WING_TIMEOUT_MS, remaining),
353
490
  });
354
491
  if (res.error || res.status !== 0) {
355
- log(`exit mine err (${wing}): ${String(res.error || res.status)}`);
492
+ errLog(`exit mine err (${wing}): ${String(res.error || res.status)}`);
356
493
  return;
357
494
  }
358
495
  }
@@ -361,16 +498,19 @@ function exitSync() {
361
498
  log("exit save done");
362
499
  }
363
500
  catch (e) {
364
- log("exit save err: " + String(e));
501
+ errLog("exit save err: " + String(e));
365
502
  }
366
503
  }
367
504
  export default (async () => {
368
- mkdirSync(OUT_DIR, { recursive: true });
505
+ mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
369
506
  mkdirSync(HOOK_STATE_DIR, { recursive: true });
370
507
  const autoInject = isAutoInjectEnabled();
371
508
  const identity = readIdentity();
372
509
  const interval = saveInterval();
373
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);
374
514
  // Crash safety: best-effort synchronous save on hard exit.
375
515
  // Mirrors the official emergency-save intent (nothing async allowed here).
376
516
  let exitHandled = false;
@@ -395,6 +535,10 @@ export default (async () => {
395
535
  // Official Save-hook cadence: count human messages per session,
396
536
  // persist like ~/.mempalace/hook_state/, arm ONE AI checkpoint
397
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).
398
542
  const counters = loadCounters();
399
543
  const c = counters[sessionID] || { humanMsgs: 0, lastCheckpoint: 0 };
400
544
  c.humanMsgs += 1;
@@ -403,8 +547,6 @@ export default (async () => {
403
547
  c.lastCheckpoint = boundary;
404
548
  pendingCheckpoint = { sessionID, count: c.humanMsgs };
405
549
  hookLog(`session ${sessionID}: ${c.humanMsgs} human msgs — checkpoint armed`);
406
- log("threshold crossed - queue sync");
407
- setTimeout(() => dbSync(), 500);
408
550
  }
409
551
  counters[sessionID] = c;
410
552
  persistCounters(counters);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-mempalace-persistence",
3
- "version": "2.0.1",
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",