opencode-mempalace-persistence 2.0.1 → 2.1.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 (2) hide show
  1. package/dist/index.js +68 -15
  2. package/package.json +1 -1
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");
@@ -38,6 +37,45 @@ function hookLog(msg) {
38
37
  }
39
38
  catch { }
40
39
  }
40
+ // Errors are never silent: hook.log is always written (unlike the
41
+ // DEBUG-gated log), so a broken pipeline is visible by default.
42
+ function errLog(msg) {
43
+ log("ERROR: " + msg);
44
+ hookLog("ERROR: " + msg);
45
+ }
46
+ // Probe for a working Python interpreter at startup instead of hardcoding
47
+ // one installer layout (pipx vs uv tool vs system). runPython only needs
48
+ // stdlib (sqlite3/json), so any python3 works. Priority: explicit env
49
+ // override, legacy pipx venv, uv tool venv, PATH fallback.
50
+ let resolvedPython = undefined;
51
+ function resolvePython() {
52
+ if (resolvedPython !== undefined)
53
+ return resolvedPython;
54
+ const candidates = [
55
+ process.env.MEMPALACE_PYTHON,
56
+ join(HOME, ".local/share/pipx/venvs/mempalace/bin/python3"),
57
+ join(HOME, ".local/share/uv/tools/mempalace/bin/python3"),
58
+ ].filter((p) => !!p && existsSync(p));
59
+ if (candidates.length > 0) {
60
+ resolvedPython = candidates[0];
61
+ }
62
+ else {
63
+ try {
64
+ execSync("python3 --version", { encoding: "utf-8", timeout: 10000 });
65
+ resolvedPython = "python3";
66
+ }
67
+ catch {
68
+ resolvedPython = null;
69
+ }
70
+ }
71
+ if (resolvedPython) {
72
+ log("using python: " + resolvedPython);
73
+ }
74
+ else {
75
+ errLog("no working Python interpreter found (tried MEMPALACE_PYTHON, pipx venv, uv tool venv, PATH python3) — DB export disabled");
76
+ }
77
+ return resolvedPython;
78
+ }
41
79
  let miningLock = false;
42
80
  let lastSyncTs = 0;
43
81
  let wakeupDone = false;
@@ -46,12 +84,19 @@ let wakeupDone = false;
46
84
  // official Stop hook: the hook decides WHEN, the model decides WHAT).
47
85
  let pendingCheckpoint = null;
48
86
  function runPython(code) {
87
+ const python = resolvePython();
88
+ if (!python)
89
+ throw new Error("no working Python interpreter (see hook.log)");
49
90
  writeFileSync(TMP_SCRIPT, code);
50
91
  try {
51
- return execSync(`${VENV_PYTHON} ${TMP_SCRIPT}`, { encoding: "utf-8", timeout: 30000 }).trim();
92
+ // argv array, no shell (see PR #2): paths here are fixed, never user input.
93
+ return execFileSync(python, [TMP_SCRIPT], { encoding: "utf-8", timeout: 30000 }).trim();
52
94
  }
53
95
  finally {
54
- unlinkSync(TMP_SCRIPT);
96
+ try {
97
+ unlinkSync(TMP_SCRIPT);
98
+ }
99
+ catch { }
55
100
  }
56
101
  }
57
102
  function hasText(parts) {
@@ -99,7 +144,8 @@ function persistCounters(counters) {
99
144
  }
100
145
  function mempalaceWakeup() {
101
146
  try {
102
- const out = execSync(`${MEMPALACE_BIN} wake-up`, { encoding: "utf-8", timeout: 15000 }).trim();
147
+ // argv array, no shell.
148
+ const out = execFileSync(MEMPALACE_BIN, ["wake-up"], { encoding: "utf-8", timeout: 15000 }).trim();
103
149
  if (!out)
104
150
  return "";
105
151
  return out.slice(0, MAX_WAKEUP_CHARS);
@@ -133,7 +179,9 @@ function readIdentity() {
133
179
  }
134
180
  function mempalaceSearch(query) {
135
181
  try {
136
- const out = execSync(`${MEMPALACE_BIN} search "${query.replace(/"/g, '\\"')}" --results ${MAX_SEARCH_RESULTS}`, {
182
+ // argv array, no shell (see PR #2): the query is raw user message
183
+ // text, so it must never pass through /bin/sh. No manual escaping needed.
184
+ const out = execFileSync(MEMPALACE_BIN, ["search", query, "--results", String(MAX_SEARCH_RESULTS)], {
137
185
  encoding: "utf-8",
138
186
  timeout: 15000,
139
187
  }).trim();
@@ -162,7 +210,7 @@ function dbSync() {
162
210
  doDbSync();
163
211
  }
164
212
  catch (e) {
165
- log("sync err: " + String(e));
213
+ errLog("sync err: " + String(e));
166
214
  }
167
215
  }
168
216
  function backfillRequested() {
@@ -270,8 +318,10 @@ function markSynced(now) {
270
318
  // Official classification: decisions, preferences, milestones, problems,
271
319
  // emotional context. Agent tag keeps opencode-mined drawers attributable.
272
320
  // 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}`;
321
+ // Argv array, no shell (see PR #2): wing names are sanitized, but the
322
+ // spawn path stays shell-free regardless.
323
+ function mineArgs(wingDir, wing) {
324
+ return ["mine", wingDir, "--mode", "convos", "--extract", "general", "--agent", "opencode", "--wing", wing];
275
325
  }
276
326
  function cleanupExport(wings) {
277
327
  for (const files of wings.values()) {
@@ -323,13 +373,16 @@ function doDbSync() {
323
373
  return;
324
374
  }
325
375
  const [wing, files] = entries[i];
326
- exec(mineCommand(join(OUT_DIR, wing), wing), {
376
+ // No timeout here by design (see PR #4): Node would kill only the
377
+ // wrapper shell and orphan the python mine process, which keeps
378
+ // holding the palace lock while the next mine piles up. miningLock
379
+ // already serializes concurrent mines; long mines run to completion.
380
+ execFile(MEMPALACE_BIN, mineArgs(join(OUT_DIR, wing), wing), {
327
381
  encoding: "utf-8",
328
- timeout: 300000,
329
382
  }, (err) => {
330
383
  if (err) {
331
384
  miningLock = false;
332
- log(`mine err (${wing}): ${err.message}`);
385
+ errLog(`mine err (${wing}): ${err.message}`);
333
386
  return;
334
387
  }
335
388
  log(`mined wing ${wing} (${files.length} sessions)`);
@@ -347,12 +400,12 @@ function exitSync() {
347
400
  return;
348
401
  for (const [wing] of wings) {
349
402
  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], {
403
+ const res = spawnSync(MEMPALACE_BIN, mineArgs(join(OUT_DIR, wing), wing), {
351
404
  encoding: "utf-8",
352
405
  timeout: 60000,
353
406
  });
354
407
  if (res.error || res.status !== 0) {
355
- log(`exit mine err (${wing}): ${String(res.error || res.status)}`);
408
+ errLog(`exit mine err (${wing}): ${String(res.error || res.status)}`);
356
409
  return;
357
410
  }
358
411
  }
@@ -361,7 +414,7 @@ function exitSync() {
361
414
  log("exit save done");
362
415
  }
363
416
  catch (e) {
364
- log("exit save err: " + String(e));
417
+ errLog("exit save err: " + String(e));
365
418
  }
366
419
  }
367
420
  export default (async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-mempalace-persistence",
3
- "version": "2.0.1",
3
+ "version": "2.1.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",