opencode-mempalace-persistence 2.4.0 → 2.5.1

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 +53 -23
  2. package/dist/index.js +172 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -65,32 +65,29 @@ mempalace mcp
65
65
 
66
66
  The `mempalace mcp` command gives you the exact MCP setup string for your configuration.
67
67
 
68
- ### 4. Memory injection
68
+ ### 4. Plugin config (all optional)
69
69
 
70
- The plugin automatically injects your identity + relevant memories from MemPalace into every prompt. No model discipline required.
71
-
72
- Create `~/.mempalace/plugin-config.json`:
70
+ No config file is needed to start: every setting has a default, and **not
71
+ creating anything means all defaults**. When you want to change one,
72
+ create `~/.mempalace/plugin-config.json`:
73
73
 
74
74
  ```json
75
75
  {
76
- "autoInjectContext": true
76
+ "autoInjectContext": false,
77
+ "saveInterval": 15,
78
+ "toasts": true
77
79
  }
78
80
  ```
79
81
 
80
- Optional tuning:
81
-
82
- ```json
83
- {
84
- "autoInjectContext": true,
85
- "saveInterval": 15
86
- }
87
- ```
88
-
89
- - `saveInterval` (default `15`, min `5`): human messages between AI checkpoints — same cadence as the official MemPalace save hook.
82
+ | Key | Default | What it does |
83
+ |---|---|---|
84
+ | `autoInjectContext` | `false` | Inject identity + `mempalace search` results into every prompt. Needs no model discipline, but adds context (and noise) to every turn. Off by default: recall happens via the skill instead |
85
+ | `saveInterval` | `15` (min `5`) | Human messages between AI checkpoints — same cadence as the official MemPalace save hook |
86
+ | `toasts` | `true` | TUI toasts for mines, checkpoints and MemPalace calls. Set `false` to silence them |
90
87
 
91
88
  **Do NOT put this in `opencode.json`** — OpenCode's schema validation rejects unknown keys. The plugin reads its config from `~/.mempalace/plugin-config.json` instead.
92
89
 
93
- When enabled:
90
+ When `autoInjectContext` is enabled:
94
91
  - **First message**: Injects your identity from `~/.mempalace/identity.txt`
95
92
  - **Every message**: Runs `mempalace search` and injects relevant results
96
93
 
@@ -101,12 +98,14 @@ Create `~/.config/opencode/AGENTS.md`:
101
98
  ```markdown
102
99
  # Memory & Knowledge instructions
103
100
 
104
- ## Recall (usually already covered)
101
+ ## Recall (via skill, unless auto-inject is on)
105
102
 
106
- The plugin auto-injects identity + relevant memories into every prompt.
107
- Only search MemPalace yourself (`mempalace_mempalace_search`) when the
108
- question is about past work, decisions, people, or projects AND the
109
- injected context has nothing quote results verbatim, never paraphrase.
103
+ Recall follows the bundled `mempalace-recall` skill (question-driven
104
+ search). If you enabled `autoInjectContext`, identity + relevant memories
105
+ are additionally injected into every prompt then only search MemPalace
106
+ yourself when the question is about past work, decisions, people, or
107
+ projects AND the injected context has nothing. Either way, quote results
108
+ verbatim, never paraphrase.
110
109
 
111
110
  ## Record facts (after responding, only when something new emerged)
112
111
 
@@ -183,7 +182,16 @@ Session goes idle / process exits
183
182
 
184
183
  Every MemPalace call — plugin searches, model MCP calls (search, diary,
185
184
  KG) — also raises a short TUI toast with what was asked and a result
186
- preview, so background memory activity is always visible.
185
+ preview, so background memory activity is always visible. A startup toast
186
+ shows the loaded plugin version (`plugin v2.x loaded`), so you always know
187
+ whether you're running the npm release or a local build.
188
+
189
+ Multiple opencode instances are supported: mines coordinate through the
190
+ palace lock with backoff-and-retry (up to ~10min per wing), so concurrent
191
+ instances interleave wing by wing instead of starving each other — backfills
192
+ complete even with two sessions open. Routine contention shows one info
193
+ toast every 5 minutes max. To reduce contention during huge backfills, a
194
+ single instance is still fastest.
187
195
 
188
196
  Compaction starts
189
197
  → [MemPalace Pre-Compact Emergency Save]: model files everything first
@@ -260,7 +268,7 @@ The plugin exports everything in the opencode database on the next sync, then re
260
268
  |---|---|
261
269
  | `~/.config/opencode/opencode.json` | OpenCode config with plugin + MCP |
262
270
  | `~/.config/opencode/AGENTS.md` | Tells the model to manage KG facts |
263
- | `~/.mempalace/plugin-config.json` | Plugin config (`autoInjectContext`, `saveInterval`) |
271
+ | `~/.mempalace/plugin-config.json` | Plugin config (`autoInjectContext`, `saveInterval`, `toasts` — all optional, see §4) |
264
272
  | `~/.config/opencode/skills/mempalace-recall/SKILL.md` | Bundled recall skill (copy from `skills/` in this repo) |
265
273
  | `~/.mempalace/identity.txt` | Your identity (injected by plugin) |
266
274
  | `~/.mempalace/hook_state/opencode_counters.json` | Per-session message counters (checkpoint cadence) |
@@ -299,6 +307,28 @@ When set, the plugin writes a debug log to `/tmp/opencode-mempalace.log`.
299
307
 
300
308
  ---
301
309
 
310
+ ## Observability: toasts and commands
311
+
312
+ Background memory activity is visible three ways — ephemeral first,
313
+ history on demand, never polluting session context:
314
+
315
+ - **TUI toasts** (on by default, `"toasts": false` to disable): mine
316
+ results and errors, armed checkpoints, and every MemPalace call
317
+ (plugin searches and model MCP calls) with what was asked plus a
318
+ short answer preview. A startup toast shows the loaded build
319
+ (`opencode-mempalace-persistence v2.x loaded`), so npm-cache vs
320
+ local build is never a mystery.
321
+ - **`/memory-status`** — palace health in the transcript: drawers,
322
+ KG stats, last sync, pending backlog, recent activity, errors with
323
+ explanations, active config. Read-only.
324
+ - **`/memory-log [N] [filter]`** — the interaction history: every
325
+ search (query → result count), tool call (asked → answered preview),
326
+ mine (outcome per wing) and checkpoint, newest last. Backed by
327
+ `~/.mempalace/hook_state/interactions.log` (JSON lines, auto-rotated).
328
+ Read-only.
329
+
330
+ ---
331
+
302
332
  ## License
303
333
 
304
334
  MIT
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { execSync, execFileSync, execFile, spawnSync } from "child_process";
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, rmdirSync, unlinkSync, appendFileSync } from "fs";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmdirSync, unlinkSync, appendFileSync, statSync, readdirSync } from "fs";
3
3
  import { homedir } from "os";
4
- import { join } from "path";
4
+ import { join, dirname } from "path";
5
5
  import { createHash } from "crypto";
6
+ import { fileURLToPath } from "url";
6
7
  const HOME = homedir();
7
8
  const MEMPALACE_BIN = join(HOME, ".local/bin/mempalace");
8
9
  const OPENCODE_DB = join(HOME, ".local/share/opencode/opencode.db");
@@ -12,6 +13,9 @@ const IDENTITY_FILE = join(HOME, ".mempalace/identity.txt");
12
13
  const HOOK_STATE_DIR = join(HOME, ".mempalace/hook_state");
13
14
  const COUNTERS_FILE = join(HOOK_STATE_DIR, "opencode_counters.json");
14
15
  const HOOK_LOG = join(HOOK_STATE_DIR, "hook.log");
16
+ const INTERACTIONS_LOG = join(HOOK_STATE_DIR, "interactions.log");
17
+ // Cap the interactions log so it never grows unbounded (approx lines).
18
+ const INTERACTIONS_MAX_LINES = 2000;
15
19
  // Private sync workspace (0700): transcripts contain conversation text,
16
20
  // so they must never sit world-readable in /tmp (see PR #1524 review).
17
21
  const SYNC_DIR = join(HOME, ".mempalace/oc-sessions");
@@ -46,6 +50,28 @@ function errLog(msg) {
46
50
  log("ERROR: " + msg);
47
51
  hookLog("ERROR: " + msg);
48
52
  }
53
+ // Structured interaction log (JSON lines): every MemPalace question and
54
+ // answer, readable by /memory-log. Ephemeral TUI toasts show the moment;
55
+ // this file keeps the history — without polluting session context.
56
+ function ilog(kind, data) {
57
+ try {
58
+ mkdirSync(HOOK_STATE_DIR, { recursive: true });
59
+ appendFileSync(INTERACTIONS_LOG, JSON.stringify({ ts: new Date().toISOString(), kind, ...data }) + "\n");
60
+ // Cheap rotation: count lines only when the file looks big.
61
+ let size = 0;
62
+ try {
63
+ size = statSync(INTERACTIONS_LOG).size;
64
+ }
65
+ catch { }
66
+ if (size > 600 * 1024) {
67
+ const lines = readFileSync(INTERACTIONS_LOG, "utf-8").split("\n");
68
+ if (lines.length > INTERACTIONS_MAX_LINES) {
69
+ writeFileSync(INTERACTIONS_LOG, lines.slice(-INTERACTIONS_MAX_LINES).join("\n"));
70
+ }
71
+ }
72
+ }
73
+ catch { }
74
+ }
49
75
  function toastsEnabled() {
50
76
  try {
51
77
  const raw = readFileSync(PLUGIN_CONFIG, "utf-8");
@@ -56,9 +82,78 @@ function toastsEnabled() {
56
82
  catch { }
57
83
  return true;
58
84
  }
85
+ // Messages arrived after the sync cursor: still waiting for the next run.
86
+ function countPendingMessages(sinceMs) {
87
+ try {
88
+ const out = runPython(`
89
+ import sqlite3
90
+ db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
91
+ n = db.execute("SELECT COUNT(*) FROM message WHERE time_created > ${sinceMs}").fetchone()[0]
92
+ db.close()
93
+ print(n)
94
+ `);
95
+ const n = parseInt(out.trim(), 10);
96
+ return isNaN(n) ? 0 : n;
97
+ }
98
+ catch {
99
+ return 0;
100
+ }
101
+ }
102
+ let cachedName = undefined;
103
+ let cachedVersion = undefined;
104
+ function pluginName() {
105
+ if (cachedName !== undefined)
106
+ return cachedName;
107
+ try {
108
+ const here = dirname(fileURLToPath(import.meta.url));
109
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
110
+ cachedName = typeof pkg?.name === "string" ? pkg.name : "opencode-mempalace-persistence";
111
+ }
112
+ catch {
113
+ cachedName = "opencode-mempalace-persistence";
114
+ }
115
+ return cachedName ?? "opencode-mempalace-persistence";
116
+ }
117
+ function pluginVersion() {
118
+ if (cachedVersion !== undefined)
119
+ return cachedVersion;
120
+ try {
121
+ const here = dirname(fileURLToPath(import.meta.url));
122
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
123
+ cachedVersion = typeof pkg?.version === "string" ? pkg.version : "unknown";
124
+ }
125
+ catch {
126
+ cachedVersion = "unknown";
127
+ }
128
+ return cachedVersion ?? "unknown";
129
+ }
130
+ // Pending export files (backlog). Pure fs walk, no shell.
131
+ function countPendingFiles() {
132
+ let n = 0;
133
+ try {
134
+ for (const w of readdirSync(SYNC_DIR, { withFileTypes: true })) {
135
+ if (!w.isDirectory())
136
+ continue;
137
+ try {
138
+ n += readdirSync(join(SYNC_DIR, w.name)).length;
139
+ }
140
+ catch { }
141
+ }
142
+ }
143
+ catch { }
144
+ return n;
145
+ }
146
+ function countPendingSuffix() {
147
+ const pending = countPendingFiles();
148
+ return pending > 0 ? `, ${pending} file(s) waiting to mine` : ", queue empty";
149
+ }
59
150
  // TUI toast client (set by the factory). Fire-and-forget: headless runs
60
151
  // (`opencode run`, no TUI attached) must never break on this.
61
152
  let tuiClient = null;
153
+ // Throttle for routine skip notices (busy palace): at most one toast
154
+ // per window, otherwise active sessions get spammed every turn.
155
+ let lastBusyToastTs = 0;
156
+ const BUSY_TOAST_WINDOW_MS = 5 * 60 * 1000;
62
157
  function toast(variant, title, message) {
63
158
  if (!toastsEnabled() || !tuiClient?.tui?.showToast)
64
159
  return;
@@ -244,6 +339,7 @@ function mempalaceSearch(query) {
244
339
  const bin = resolveBin();
245
340
  if (!bin)
246
341
  return "";
342
+ const started = Date.now();
247
343
  try {
248
344
  // argv array, no shell (see PR #2): the query is raw user message
249
345
  // text, so it must never pass through /bin/sh. No manual escaping needed.
@@ -253,10 +349,12 @@ function mempalaceSearch(query) {
253
349
  }).trim();
254
350
  if (!out || out.includes("No results")) {
255
351
  toast("info", "MemPalace", `search "${query.slice(0, 50)}" → no results`);
352
+ ilog("search", { via: "cli", query: query.slice(0, 200), results: 0, ms: Date.now() - started });
256
353
  return "";
257
354
  }
258
355
  const n = (out.match(/\n\s*\[\d+\]/g) || []).length || 1;
259
356
  toast("info", "MemPalace", `search "${query.slice(0, 50)}" → ${n} result(s)`);
357
+ ilog("search", { via: "cli", query: query.slice(0, 200), results: n, ms: Date.now() - started });
260
358
  return out.slice(0, MAX_INJECT_CHARS);
261
359
  }
262
360
  catch {
@@ -470,7 +568,19 @@ function doDbSync() {
470
568
  miningLock = true;
471
569
  log(`mining ${wingCount(wings)} sessions across ${wings.size} wings`);
472
570
  const entries = [...wings.entries()];
473
- const mineNext = (i) => {
571
+ // Per-wing drawers tally for the final toast (parsed from mine stdout).
572
+ const wingDrawers = new Map();
573
+ const parseDrawers = (stdout) => {
574
+ const m = String(stdout || "").match(/Drawers filed:\s*(\d+)/i);
575
+ return m ? parseInt(m[1], 10) : 0;
576
+ };
577
+ // Retry schedule for lock contention: two instances (or an MCP write)
578
+ // interleave wing by wing instead of starving each other. Total ~10min
579
+ // of retries per wing, then give up until the next trigger (idle/exit).
580
+ // miningLock stays held during backoff so one process never piles up.
581
+ const RETRY_DELAYS_MS = [15000, 30000, 60000, 120000, 180000, 300000];
582
+ const jitter = (ms) => ms + Math.floor(Math.random() * 10000);
583
+ const mineNext = (i, attempt = 0) => {
474
584
  if (i >= entries.length) {
475
585
  miningLock = false;
476
586
  // Advance state only on full success: on failure the same
@@ -480,7 +590,13 @@ function doDbSync() {
480
590
  cleanupExport(wings);
481
591
  log("mine done");
482
592
  const names = [...wings.keys()].join(", ");
483
- toast("success", "MemPalace", `mined ${wingCount(wings)} session(s) ${names}`);
593
+ const totalDrawers = [...wingDrawers.values()].reduce((a, b) => a + b, 0);
594
+ const detail = totalDrawers > 0 ? ` (${totalDrawers} drawers)` : "";
595
+ // Anything that arrived while this mine was running stays pending.
596
+ const remaining = countPendingMessages(now);
597
+ const tail = remaining > 0 ? `, ${remaining} message(s) still waiting` : ", queue empty";
598
+ toast("success", "MemPalace", `mined ${wingCount(wings)} session(s) → ${names}${detail}${tail}`);
599
+ ilog("mine", { outcome: "ok", sessions: wingCount(wings), wings: [...wings.keys()], drawers: totalDrawers, remaining });
484
600
  return;
485
601
  }
486
602
  const [wing, files] = entries[i];
@@ -496,14 +612,42 @@ function doDbSync() {
496
612
  }
497
613
  execFile(bin, mineArgs(join(OUT_DIR, wing), wing), {
498
614
  encoding: "utf-8",
499
- }, (err) => {
615
+ }, (err, stdout) => {
500
616
  if (err) {
617
+ const msg = err.message || String(err);
618
+ // Lock contention (second opencode instance mining, or an MCP
619
+ // write in flight) is routine, not a failure: back off and retry
620
+ // the same wing — the holder releases between its own wings, so
621
+ // concurrent instances interleave instead of starving. Anything
622
+ // else is a real error.
623
+ if (/is held by/i.test(msg) && attempt < RETRY_DELAYS_MS.length) {
624
+ const wait = jitter(RETRY_DELAYS_MS[attempt]);
625
+ log(`palace busy (${wing}), retry ${attempt + 1}/${RETRY_DELAYS_MS.length} in ${Math.round(wait / 1000)}s`);
626
+ const nowTs = Date.now();
627
+ if (nowTs - lastBusyToastTs > BUSY_TOAST_WINDOW_MS) {
628
+ lastBusyToastTs = nowTs;
629
+ toast("info", "MemPalace", "palace busy (another instance mining?) — backing off, will retry");
630
+ }
631
+ setTimeout(() => mineNext(i, attempt + 1), wait);
632
+ return;
633
+ }
501
634
  miningLock = false;
502
- errLog(`mine err (${wing}): ${err.message}`);
503
- toast("error", "MemPalace", `mine failed (${wing}): ${err.message.slice(0, 120)}`);
635
+ if (/is held by/i.test(msg)) {
636
+ log(`mine skipped, palace busy (${wing}) after ${attempt} retries — next trigger will retry`);
637
+ ilog("mine", { outcome: "busy", wing });
638
+ return;
639
+ }
640
+ errLog(`mine err (${wing}): ${msg}`);
641
+ toast("error", "MemPalace", `mine failed (${wing}): ${msg.slice(0, 120)}`);
642
+ ilog("mine", { outcome: "error", wing, error: msg.slice(0, 200) });
504
643
  return;
505
644
  }
506
645
  log(`mined wing ${wing} (${files.length} sessions)`);
646
+ wingDrawers.set(wing, parseDrawers(stdout));
647
+ // Truthful progress: one toast per completed wing (an exact % is
648
+ // impossible — the mine CLI is a black box with ~4s startup cost
649
+ // per invocation, so per-file mines would only add overhead).
650
+ toast("info", "MemPalace", `wing ${wing} done (${i + 1}/${entries.length})`);
507
651
  mineNext(i + 1);
508
652
  });
509
653
  };
@@ -560,8 +704,17 @@ export default (async ({ client }) => {
560
704
  // Catch anything missed by a previous run (e.g. content skipped when
561
705
  // the exit budget ran out). Fires once per server lifetime.
562
706
  setTimeout(() => dbSync(), 10000);
707
+ // Startup toast (delayed so the TUI is attached): shows exactly which
708
+ // plugin build is loaded — no more guessing npm-cache vs local build —
709
+ // plus pending backlog so a restarted-into-backlog state is visible.
710
+ setTimeout(() => {
711
+ toast("info", "MemPalace", `${pluginName()} v${pluginVersion()} loaded${countPendingSuffix()}`);
712
+ log(`startup toast fired (${pluginName()} v${pluginVersion()})`);
713
+ }, 15000);
563
714
  // Crash safety: best-effort synchronous save on hard exit.
564
715
  // Mirrors the official emergency-save intent (nothing async allowed here).
716
+ // SIGHUP included: closing the terminal / dropping SSH kills the process
717
+ // group, otherwise mines would die mid-run with no save attempt.
565
718
  let exitHandled = false;
566
719
  const onExit = () => {
567
720
  if (exitHandled)
@@ -571,6 +724,7 @@ export default (async ({ client }) => {
571
724
  };
572
725
  process.once("SIGINT", onExit);
573
726
  process.once("SIGTERM", onExit);
727
+ process.once("SIGHUP", onExit);
574
728
  process.once("exit", onExit);
575
729
  return {
576
730
  "chat.message": async (input, output) => {
@@ -596,6 +750,7 @@ export default (async ({ client }) => {
596
750
  c.lastCheckpoint = boundary;
597
751
  pendingCheckpoint = { sessionID, count: c.humanMsgs };
598
752
  hookLog(`session ${sessionID}: ${c.humanMsgs} human msgs — checkpoint armed`);
753
+ ilog("checkpoint", { sessionID, count: c.humanMsgs });
599
754
  toast("info", "MemPalace", `checkpoint armed (~${c.humanMsgs} msgs): the model will file memories now`);
600
755
  }
601
756
  counters[sessionID] = c;
@@ -682,6 +837,16 @@ export default (async ({ client }) => {
682
837
  const summary = summarizeToolCall(name, input?.args, output?.output || "");
683
838
  log(`tool: ${summary}`);
684
839
  toast("info", "MemPalace", summary);
840
+ ilog("tool", {
841
+ tool: String(name).replace(/^mcp_+/, "").replace(/^mempalace_mempalace_/, "").replace(/^mempalace_/, ""),
842
+ asked: (() => { try {
843
+ return JSON.stringify(input?.args || {}).replace(/\s+/g, " ").slice(0, 200);
844
+ }
845
+ catch {
846
+ return "";
847
+ } })(),
848
+ answered: String(output?.output || "").replace(/\s+/g, " ").slice(0, 300),
849
+ });
685
850
  }
686
851
  catch { }
687
852
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-mempalace-persistence",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
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",