opencode-mempalace-persistence 2.1.0 → 2.3.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 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,12 +174,12 @@ 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
178
181
  → Background mine of everything new since last sync
182
+ → TUI toast confirms what was mined (disable with `"toasts": false`)
179
183
 
180
184
  Compaction starts
181
185
  → [MemPalace Pre-Compact Emergency Save]: model files everything first
@@ -190,7 +194,7 @@ Next time you ask
190
194
 
191
195
  ## What gets saved
192
196
 
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.
197
+ 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
198
 
195
199
  ### Backfill existing sessions
196
200
 
@@ -218,18 +222,16 @@ The plugin exports everything in the opencode database on the next sync, then re
218
222
  │ ↓ │
219
223
  │ Model sees context → answers │
220
224
  │ ↓ │
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
- └──────────────────────────────┘
225
+ Answer done ──►│ chat.message (count) + session.idle
226
+ mine on idle / exit / startup
227
+ │ ↓
228
+ │ Query OpenCode DB (completed turns)
229
+
230
+ Export → flat text files (0700)
231
+
232
+ mempalace mine --mode convos
233
+ single serialized call
234
+ └──────────────────────────────────────────┘
233
235
 
234
236
 
235
237
  ┌──────────────────────────┐
@@ -258,7 +260,8 @@ The plugin exports everything in the opencode database on the next sync, then re
258
260
  | `~/.config/opencode/skills/mempalace-recall/SKILL.md` | Bundled recall skill (copy from `skills/` in this repo) |
259
261
  | `~/.mempalace/identity.txt` | Your identity (injected by plugin) |
260
262
  | `~/.mempalace/hook_state/opencode_counters.json` | Per-session message counters (checkpoint cadence) |
261
- | `~/.mempalace/hook_state/hook.log` | Checkpoint / pre-compact event log |
263
+ | `~/.mempalace/hook_state/hook.log` | Checkpoint / pre-compact event log (errors always land here) |
264
+ | `~/.mempalace/oc-sessions/` | Private (0700) export workspace for pending transcripts |
262
265
  | `~/.mempalace/config.json` | MemPalace config (palace path) |
263
266
  | `~/.mempalace/knowledge_graph.sqlite3` | Knowledge Graph (structured facts) |
264
267
  | `~/opencode-memory/` | MemPalace vector DB (all drawers) |
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- declare const _default: () => Promise<{
1
+ declare const _default: ({ client }: any) => Promise<{
2
2
  "chat.message": (input: {
3
3
  sessionID: string;
4
4
  agent?: string;
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;
@@ -43,6 +46,29 @@ function errLog(msg) {
43
46
  log("ERROR: " + msg);
44
47
  hookLog("ERROR: " + msg);
45
48
  }
49
+ function toastsEnabled() {
50
+ try {
51
+ const raw = readFileSync(PLUGIN_CONFIG, "utf-8");
52
+ const v = JSON.parse(raw)?.toasts;
53
+ if (v === false)
54
+ return false;
55
+ }
56
+ catch { }
57
+ return true;
58
+ }
59
+ // TUI toast client (set by the factory). Fire-and-forget: headless runs
60
+ // (`opencode run`, no TUI attached) must never break on this.
61
+ let tuiClient = null;
62
+ function toast(variant, title, message) {
63
+ if (!toastsEnabled() || !tuiClient?.tui?.showToast)
64
+ return;
65
+ try {
66
+ const p = tuiClient.tui.showToast({ body: { title, message, variant, duration: 5000 } });
67
+ if (p && typeof p.catch === "function")
68
+ p.catch(() => { });
69
+ }
70
+ catch { }
71
+ }
46
72
  // Probe for a working Python interpreter at startup instead of hardcoding
47
73
  // one installer layout (pipx vs uv tool vs system). runPython only needs
48
74
  // stdlib (sqlite3/json), so any python3 works. Priority: explicit env
@@ -87,7 +113,8 @@ function runPython(code) {
87
113
  const python = resolvePython();
88
114
  if (!python)
89
115
  throw new Error("no working Python interpreter (see hook.log)");
90
- writeFileSync(TMP_SCRIPT, code);
116
+ mkdirSync(SYNC_DIR, { recursive: true, mode: 0o700 });
117
+ writeFileSync(TMP_SCRIPT, code, { mode: 0o600 });
91
118
  try {
92
119
  // argv array, no shell (see PR #2): paths here are fixed, never user input.
93
120
  return execFileSync(python, [TMP_SCRIPT], { encoding: "utf-8", timeout: 30000 }).trim();
@@ -99,6 +126,39 @@ function runPython(code) {
99
126
  catch { }
100
127
  }
101
128
  }
129
+ // Resolve the mempalace CLI without hardcoding one installer layout:
130
+ // explicit env override, PATH lookup (cross-platform, incl. Windows),
131
+ // legacy ~/.local/bin fallback.
132
+ let resolvedBin = undefined;
133
+ function resolveBin() {
134
+ if (resolvedBin !== undefined)
135
+ return resolvedBin;
136
+ const envBin = process.env.MEMPALACE_BIN;
137
+ if (envBin && existsSync(envBin)) {
138
+ resolvedBin = envBin;
139
+ }
140
+ else {
141
+ try {
142
+ const found = execSync(process.platform === "win32" ? "where mempalace" : "command -v mempalace", {
143
+ encoding: "utf-8", timeout: 10000,
144
+ }).trim().split(/\r?\n/)[0]?.trim();
145
+ if (found)
146
+ resolvedBin = found;
147
+ }
148
+ catch { }
149
+ if (!resolvedBin && existsSync(MEMPALACE_BIN))
150
+ resolvedBin = MEMPALACE_BIN;
151
+ if (!resolvedBin)
152
+ resolvedBin = null;
153
+ }
154
+ if (resolvedBin) {
155
+ log("using mempalace: " + resolvedBin);
156
+ }
157
+ else {
158
+ errLog("mempalace CLI not found (tried MEMPALACE_BIN env, PATH, ~/.local/bin) — search/wake-up/mine disabled");
159
+ }
160
+ return resolvedBin;
161
+ }
102
162
  function hasText(parts) {
103
163
  return parts
104
164
  .filter((p) => p?.type === "text" && p?.text?.trim())
@@ -143,9 +203,12 @@ function persistCounters(counters) {
143
203
  }
144
204
  }
145
205
  function mempalaceWakeup() {
206
+ const bin = resolveBin();
207
+ if (!bin)
208
+ return "";
146
209
  try {
147
210
  // argv array, no shell.
148
- const out = execFileSync(MEMPALACE_BIN, ["wake-up"], { encoding: "utf-8", timeout: 15000 }).trim();
211
+ const out = execFileSync(bin, ["wake-up"], { encoding: "utf-8", timeout: 15000 }).trim();
149
212
  if (!out)
150
213
  return "";
151
214
  return out.slice(0, MAX_WAKEUP_CHARS);
@@ -178,10 +241,13 @@ function readIdentity() {
178
241
  }
179
242
  }
180
243
  function mempalaceSearch(query) {
244
+ const bin = resolveBin();
245
+ if (!bin)
246
+ return "";
181
247
  try {
182
248
  // argv array, no shell (see PR #2): the query is raw user message
183
249
  // 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)], {
250
+ const out = execFileSync(bin, ["search", query, "--results", String(MAX_SEARCH_RESULTS)], {
185
251
  encoding: "utf-8",
186
252
  timeout: 15000,
187
253
  }).trim();
@@ -245,8 +311,11 @@ print(json.dumps(rows))
245
311
  if (!sessionsArr || sessionsArr.length === 0)
246
312
  return { wings: new Map(), now: Date.now() };
247
313
  const now = Date.now();
314
+ // Never advance the cursor past an in-flight reply: anything skipped
315
+ // as incomplete is revisited by the next sync (idle/exit/startup).
316
+ let cursor = now;
248
317
  const wings = new Map();
249
- mkdirSync(OUT_DIR, { recursive: true });
318
+ mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
250
319
  for (const sess of sessionsArr) {
251
320
  const [sessId, title, , directory] = sess;
252
321
  const wing = ((directory || "").split("/").filter(Boolean).pop() || "global")
@@ -262,10 +331,20 @@ rows = db.execute("""
262
331
  ORDER BY m.time_created
263
332
  """).fetchall()
264
333
  texts = []
334
+ incomplete = []
265
335
  for (mid, mts, mdata_raw) in rows:
266
336
  try: mdata = json.loads(mdata_raw)
267
337
  except: mdata = {}
268
338
  role = mdata.get("role", "unknown")
339
+ # Completion tracking (see PR #1524 review): the assistant message row
340
+ # is created when a reply STARTS, parts stream in afterwards, and
341
+ # finish is set only on completion. Exporting mid-reply would
342
+ # snapshot partial parts while the cursor advances past the message
343
+ # timestamp — losing the rest of the reply forever. So assistant
344
+ # messages without finish are skipped and revisited next sync.
345
+ if role == "assistant" and not mdata.get("finish"):
346
+ incomplete.append(mts)
347
+ continue
269
348
  for (pdata_raw,) in db.execute("SELECT data FROM part WHERE message_id = ? ORDER BY time_created", (mid,)).fetchall():
270
349
  try:
271
350
  pdata = json.loads(pdata_raw)
@@ -273,16 +352,22 @@ for (mid, mts, mdata_raw) in rows:
273
352
  texts.append({"role": role, "text": pdata.get("text").strip(), "ts": mts})
274
353
  except: pass
275
354
  db.close()
276
- print(json.dumps(texts))
355
+ print(json.dumps({"texts": texts, "incomplete": incomplete}))
277
356
  `);
278
357
  let msgList;
358
+ let incompleteTs = [];
279
359
  try {
280
- msgList = JSON.parse(msgs);
360
+ const parsed = JSON.parse(msgs);
361
+ msgList = parsed.texts;
362
+ incompleteTs = parsed.incomplete || [];
281
363
  }
282
364
  catch {
283
365
  continue;
284
366
  }
285
- if (msgList.length < 2)
367
+ if (incompleteTs.length > 0) {
368
+ cursor = Math.min(cursor, Math.min(...incompleteTs) - 1);
369
+ }
370
+ if (msgList.length < 2 && incompleteTs.length === 0)
286
371
  continue;
287
372
  const lines = [
288
373
  `# ${title || label}`,
@@ -302,26 +387,28 @@ print(json.dumps(texts))
302
387
  continue;
303
388
  const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 12);
304
389
  const wingDir = join(OUT_DIR, wing);
305
- mkdirSync(wingDir, { recursive: true });
390
+ mkdirSync(wingDir, { recursive: true, mode: 0o700 });
306
391
  const fname = `sync_${prefix}_${contentHash}.txt`;
307
- writeFileSync(join(wingDir, fname), content + "\n");
392
+ writeFileSync(join(wingDir, fname), content + "\n", { mode: 0o600 });
308
393
  if (!wings.has(wing))
309
394
  wings.set(wing, []);
310
395
  wings.get(wing).push(join(wingDir, fname));
311
396
  }
312
- return { wings, now };
397
+ return { wings, now: cursor };
313
398
  }
314
399
  function markSynced(now) {
315
400
  writeFileSync(STATE_FILE, JSON.stringify({ last_sync_ms: now }));
316
401
  lastSyncTs = Date.now();
317
402
  }
318
- // Official classification: decisions, preferences, milestones, problems,
319
- // emotional context. Agent tag keeps opencode-mined drawers attributable.
403
+ // Default `exchange` extraction: one drawer per exchange pair, verbatim,
404
+ // no paraphrasing (see PR #1524 review). Intelligent filing (decisions,
405
+ // KG facts, diary) happens through AI checkpoints, not the miner.
406
+ // Agent tag keeps opencode-mined drawers attributable.
320
407
  // One wing per project (official multi-project pattern).
321
408
  // Argv array, no shell (see PR #2): wing names are sanitized, but the
322
409
  // spawn path stays shell-free regardless.
323
410
  function mineArgs(wingDir, wing) {
324
- return ["mine", wingDir, "--mode", "convos", "--extract", "general", "--agent", "opencode", "--wing", wing];
411
+ return ["mine", wingDir, "--mode", "convos", "--agent", "opencode", "--wing", wing];
325
412
  }
326
413
  function cleanupExport(wings) {
327
414
  for (const files of wings.values()) {
@@ -370,6 +457,8 @@ function doDbSync() {
370
457
  markSynced(now);
371
458
  cleanupExport(wings);
372
459
  log("mine done");
460
+ const names = [...wings.keys()].join(", ");
461
+ toast("success", "MemPalace", `mined ${wingCount(wings)} session(s) → ${names}`);
373
462
  return;
374
463
  }
375
464
  const [wing, files] = entries[i];
@@ -377,12 +466,19 @@ function doDbSync() {
377
466
  // wrapper shell and orphan the python mine process, which keeps
378
467
  // holding the palace lock while the next mine piles up. miningLock
379
468
  // already serializes concurrent mines; long mines run to completion.
380
- execFile(MEMPALACE_BIN, mineArgs(join(OUT_DIR, wing), wing), {
469
+ const bin = resolveBin();
470
+ if (!bin) {
471
+ miningLock = false;
472
+ errLog("mine skipped: mempalace CLI not found");
473
+ return;
474
+ }
475
+ execFile(bin, mineArgs(join(OUT_DIR, wing), wing), {
381
476
  encoding: "utf-8",
382
477
  }, (err) => {
383
478
  if (err) {
384
479
  miningLock = false;
385
480
  errLog(`mine err (${wing}): ${err.message}`);
481
+ toast("error", "MemPalace", `mine failed (${wing}): ${err.message.slice(0, 120)}`);
386
482
  return;
387
483
  }
388
484
  log(`mined wing ${wing} (${files.length} sessions)`);
@@ -392,17 +488,31 @@ function doDbSync() {
392
488
  mineNext(0);
393
489
  }
394
490
  // Best-effort synchronous save for process exit (SIGINT/SIGTERM/exit):
395
- // only synchronous calls are allowed here.
491
+ // only synchronous calls are allowed here. Bounded by EXIT_BUDGET_MS so
492
+ // shutdown stays fast; miningLock is deliberately ignored here because
493
+ // any in-flight async mine dies with the process — at exit this sync
494
+ // mine takes ownership (see PR #1524 review).
495
+ const EXIT_BUDGET_MS = 45000;
496
+ const EXIT_WING_TIMEOUT_MS = 30000;
396
497
  function exitSync() {
397
498
  try {
499
+ const bin = resolveBin();
500
+ if (!bin)
501
+ return;
398
502
  const { wings, now } = exportNewSessions(getLastSync());
399
503
  if (wings.size === 0)
400
504
  return;
505
+ const deadline = Date.now() + EXIT_BUDGET_MS;
401
506
  for (const [wing] of wings) {
507
+ const remaining = deadline - Date.now();
508
+ if (remaining <= 0) {
509
+ log("exit save: budget exhausted, rest covered next startup");
510
+ break;
511
+ }
402
512
  log(`exit save: mining wing ${wing}`);
403
- const res = spawnSync(MEMPALACE_BIN, mineArgs(join(OUT_DIR, wing), wing), {
513
+ const res = spawnSync(bin, mineArgs(join(OUT_DIR, wing), wing), {
404
514
  encoding: "utf-8",
405
- timeout: 60000,
515
+ timeout: Math.min(EXIT_WING_TIMEOUT_MS, remaining),
406
516
  });
407
517
  if (res.error || res.status !== 0) {
408
518
  errLog(`exit mine err (${wing}): ${String(res.error || res.status)}`);
@@ -417,13 +527,17 @@ function exitSync() {
417
527
  errLog("exit save err: " + String(e));
418
528
  }
419
529
  }
420
- export default (async () => {
421
- mkdirSync(OUT_DIR, { recursive: true });
530
+ export default (async ({ client }) => {
531
+ tuiClient = client || null;
532
+ mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
422
533
  mkdirSync(HOOK_STATE_DIR, { recursive: true });
423
534
  const autoInject = isAutoInjectEnabled();
424
535
  const identity = readIdentity();
425
536
  const interval = saveInterval();
426
537
  log(`loaded (autoInjectContext: ${autoInject}, saveInterval: ${interval})`);
538
+ // Catch anything missed by a previous run (e.g. content skipped when
539
+ // the exit budget ran out). Fires once per server lifetime.
540
+ setTimeout(() => dbSync(), 10000);
427
541
  // Crash safety: best-effort synchronous save on hard exit.
428
542
  // Mirrors the official emergency-save intent (nothing async allowed here).
429
543
  let exitHandled = false;
@@ -448,6 +562,10 @@ export default (async () => {
448
562
  // Official Save-hook cadence: count human messages per session,
449
563
  // persist like ~/.mempalace/hook_state/, arm ONE AI checkpoint
450
564
  // per boundary. The model decides WHAT to file.
565
+ // NOTE: no mine here by design (see PR #1524 review) — mining a
566
+ // mid-reply snapshot would export partial assistant parts. Mines
567
+ // run on idle/exit/startup, when turns are complete; the export
568
+ // additionally skips unfinished replies (finish tracking).
451
569
  const counters = loadCounters();
452
570
  const c = counters[sessionID] || { humanMsgs: 0, lastCheckpoint: 0 };
453
571
  c.humanMsgs += 1;
@@ -456,8 +574,7 @@ export default (async () => {
456
574
  c.lastCheckpoint = boundary;
457
575
  pendingCheckpoint = { sessionID, count: c.humanMsgs };
458
576
  hookLog(`session ${sessionID}: ${c.humanMsgs} human msgs — checkpoint armed`);
459
- log("threshold crossed - queue sync");
460
- setTimeout(() => dbSync(), 500);
577
+ toast("info", "MemPalace", `checkpoint armed (~${c.humanMsgs} msgs): the model will file memories now`);
461
578
  }
462
579
  counters[sessionID] = c;
463
580
  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.3.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",