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.
Files changed (3) hide show
  1. package/README.md +18 -16
  2. package/dist/index.js +111 -22
  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
@@ -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
- const OUT_DIR = "/tmp/oc-sessions";
16
- 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");
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
- writeFileSync(TMP_SCRIPT, code);
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(MEMPALACE_BIN, ["wake-up"], { encoding: "utf-8", timeout: 15000 }).trim();
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(MEMPALACE_BIN, ["search", query, "--results", String(MAX_SEARCH_RESULTS)], {
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
- msgList = JSON.parse(msgs);
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 (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)
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
- // Official classification: decisions, preferences, milestones, problems,
319
- // 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.
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", "--extract", "general", "--agent", "opencode", "--wing", wing];
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
- execFile(MEMPALACE_BIN, mineArgs(join(OUT_DIR, wing), wing), {
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(MEMPALACE_BIN, mineArgs(join(OUT_DIR, wing), wing), {
487
+ const res = spawnSync(bin, mineArgs(join(OUT_DIR, wing), wing), {
404
488
  encoding: "utf-8",
405
- timeout: 60000,
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.1.0",
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",