opencode-mempalace-persistence 1.3.2 → 2.0.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.
package/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # opencode-mempalace-persistence
2
2
 
3
- > **Community plugin** — not officially maintained by the MemPalace team. Fully open source, ~200 lines of TypeScript.
3
+ > **Community plugin** — not officially maintained by the MemPalace team. Fully open source, ~450 lines of TypeScript.
4
4
 
5
5
  An OpenCode plugin that automatically saves every conversation to MemPalace and uses stored memory to provide better, context-aware responses. Real-time, zero cron, zero external scripts.
6
6
 
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
+
7
9
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
10
 
9
11
  ---
@@ -59,7 +61,7 @@ mempalace mcp
59
61
 
60
62
  The `mempalace mcp` command gives you the exact MCP setup string for your configuration.
61
63
 
62
- ### 4. Memory injection (recommended)
64
+ ### 4. Memory injection
63
65
 
64
66
  The plugin automatically injects your identity + relevant memories from MemPalace into every prompt. No model discipline required.
65
67
 
@@ -71,36 +73,54 @@ Create `~/.mempalace/plugin-config.json`:
71
73
  }
72
74
  ```
73
75
 
76
+ Optional tuning:
77
+
78
+ ```json
79
+ {
80
+ "autoInjectContext": true,
81
+ "saveInterval": 15
82
+ }
83
+ ```
84
+
85
+ - `saveInterval` (default `15`, min `5`): human messages between AI checkpoints — same cadence as the official MemPalace save hook.
86
+
74
87
  **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.
75
88
 
76
89
  When enabled:
77
90
  - **First message**: Injects your identity from `~/.mempalace/identity.txt`
78
91
  - **Every message**: Runs `mempalace search` and injects relevant results
79
92
 
80
- #### AGENTS.md for this mode
93
+ #### AGENTS.md (minimal recall lives in the skill)
81
94
 
82
95
  Create `~/.config/opencode/AGENTS.md`:
83
96
 
84
97
  ```markdown
85
98
  # Memory & Knowledge instructions
86
99
 
87
- ## CRITICAL: You MUST follow these steps BEFORE every response.
100
+ ## Recall (usually already covered)
88
101
 
89
- ### Step 1 Query Knowledge Graph
90
- Call `mempalace_mempalace_kg_query` for entity "user". Then filter the returned facts — keep only those whose text contains keywords from the user's question, so irrelevant facts are excluded.
102
+ The plugin auto-injects identity + relevant memories into every prompt.
103
+ Only search MemPalace yourself (`mempalace_mempalace_search`) when the
104
+ question is about past work, decisions, people, or projects AND the
105
+ injected context has nothing — quote results verbatim, never paraphrase.
91
106
 
92
- ### Step 2 Record Knowledge Graph facts
107
+ ## Record facts (after responding, only when something new emerged)
93
108
 
94
- After responding, if you discovered any new facts during the conversation (decisions made, milestones reached, problems encountered, preferences expressed, emotional states), call `mempalace_mempalace_kg_add` to record them. Object must be 128 characters or fewer.
109
+ - Durable outcomes (decisions, conclusions, learned facts):
110
+ `mempalace_mempalace_add_drawer`.
111
+ - New KG facts: `mempalace_mempalace_kg_add` (128 chars or fewer).
112
+ - Changed single-valued fact: `mempalace_mempalace_kg_supersede`.
113
+ - Ended fact: `mempalace_mempalace_kg_invalidate`.
95
114
 
96
- **This is mandatory** — record facts you are confident about. Prefer quality over quantity; noisy KG entries degrade retrieval over time.
115
+ Record facts you are confident about. Prefer quality over quantity;
116
+ noisy entries degrade retrieval over time. Don't file secrets or tokens.
97
117
 
98
118
  ### Naming reminder
99
119
  All MemPalace tools use the prefix `mempalace_mempalace_*` (not `mempalace_*`). Examples:
100
- - `mempalace_mempalace_kg_query` (NOT `mempalace_kg_query`)
120
+ - `mempalace_mempalace_search` (NOT `mempalace_search`)
121
+ - `mempalace_mempalace_kg_query`
101
122
  - `mempalace_mempalace_kg_add`
102
- - `mempalace_mempalace_kg_invalidate`
103
- If you ever catch yourself typing `mempalace_kg_query`, STOP — the correct prefix is `mempalace_mempalace_`.
123
+ If you ever catch yourself typing `mempalace_search`, STOP — the correct prefix is `mempalace_mempalace_`.
104
124
  ```
105
125
 
106
126
  #### Complete `~/.config/opencode/opencode.json`
@@ -122,79 +142,44 @@ If you ever catch yourself typing `mempalace_kg_query`, STOP — the correct pre
122
142
 
123
143
  > Note: `identity.txt` is NOT listed in `instructions` — the plugin injects it automatically. It is also NOT in the `provider` block or `permission` block — those are optional and depend on your model setup.
124
144
 
125
- ### 5. Alternative: Model-driven memory (without autoInjectContext)
126
-
127
- If you prefer the model to search MemPalace on its own via AGENTS.md (requires good model tool-use discipline), set `autoInjectContext` to `false` or omit the file:
128
-
129
- ```json
130
- {
131
- "autoInjectContext": false
132
- }
133
- ```
134
-
135
- #### AGENTS.md for this mode
136
-
137
- Create `~/.config/opencode/AGENTS.md`:
138
-
139
- ```markdown
140
- # Memory & Knowledge instructions
145
+ #### Recall skill (bundled, Claude-style)
141
146
 
142
- ## CRITICAL: You MUST follow these steps BEFORE every response.
143
-
144
- ### Step 1 Search MemPalace
145
- Call `mempalace_mempalace_search` with the user's question or key topics as query. Get the top 5-10 most relevant memory drawers.
146
- **This is mandatory. Never skip this step. No exceptions.**
147
-
148
- ### Step 2 — Query Knowledge Graph
149
- Call `mempalace_mempalace_kg_query` for entity "user". Then filter the returned facts.
150
-
151
- ### Step 3 — Record Knowledge Graph facts
152
- After responding, call `mempalace_mempalace_kg_add` for any new facts.
153
-
154
- ### Naming reminder
155
- All MemPalace tools use the prefix `mempalace_mempalace_*` (not `mempalace_*`).
156
- ```
157
-
158
- And keep `"~/.mempalace/identity.txt"` in `instructions` in opencode.json since the plugin won't inject it.
159
-
160
- #### Comparison
161
-
162
- | Feature | **Auto-inject (Recommended)** | Model-driven (alternative) |
163
- |---------|:-:|:-:|
164
- | Memory search | Plugin injects automatically | Model calls `mempalace_search` |
165
- | Identity | Plugin injects automatically | Via `instructions: ["identity.txt"]` |
166
- | AGENTS.md | Minimal (KG only) | Full (search + KG) |
167
- | Depends on model discipline | No | Yes |
147
+ The repo ships `skills/mempalace-recall/SKILL.md` the question-driven
148
+ search-before-answer protocol, adapted from the official MemPalace skill
149
+ for OpenCode (including the `mempalace_mempalace_*` tool-prefix note).
150
+ Install it where OpenCode loads skills from:
168
151
 
169
152
  ```bash
170
- # Install (requires mempalace>=3.3.5 for HNSW corruption fix)
171
- uv tool install "mempalace>=3.3.5"
172
- # or
173
- pipx install "mempalace>=3.3.5"
174
-
175
- # Create palace
176
- mempalace init ~/opencode-memory
177
-
178
- # Configure MCP
179
- mempalace mcp
153
+ mkdir -p ~/.config/opencode/skills/mempalace-recall
154
+ cp skills/mempalace-recall/SKILL.md ~/.config/opencode/skills/mempalace-recall/
180
155
  ```
181
156
 
182
- The `mempalace mcp` command gives you the exact MCP setup string for your configuration.
157
+ The model then loads it on demand whenever a question touches past work,
158
+ decisions, people, or projects — same mechanism as the Claude skill.
159
+ No AGENTS.md changes needed beyond the minimal block above.
183
160
 
184
161
  ---
185
162
 
186
- ## What happens after installation (auto-inject mode)
163
+ ## What happens after installation
187
164
 
188
165
  ```
189
166
  You ask a question
190
167
  → Plugin hooks into `experimental.chat.messages.transform`
191
168
  → Injects your identity + relevant memories from MemPalace
192
- Model sees context without having to search
169
+ Every ~15 messages: injects a [MemPalace Checkpoint] block
170
+ → Model files topics/decisions/quotes via MCP tools, then answers
193
171
 
194
172
  The model responds
195
173
  → Plugin detects the response is complete
196
174
  → Saves the conversation to MemPalace (flat export, no hardcoded wings)
197
- → Model records KG facts via MCP tools (mandatory per AGENTS.md)
175
+ → Model records new KG facts via MCP tools (only when something new emerged)
176
+
177
+ Session goes idle / process exits
178
+ → Background mine of everything new since last sync
179
+
180
+ Compaction starts
181
+ → [MemPalace Pre-Compact Emergency Save]: model files everything first
182
+ → Identity + wake-up context re-attached so the summary cannot lose them
198
183
 
199
184
  Next time you ask
200
185
  → Plugin finds the previous memory → injects it automatically
@@ -205,7 +190,17 @@ Next time you ask
205
190
 
206
191
  ## What gets saved
207
192
 
208
- Every turn (question + answer) is saved as a drawer in MemPalace. No forced categorization — MemPalace's own mining handles organization. The model can optionally record KG facts (decisions, milestones, preferences) during conversation via MCP tools.
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.
194
+
195
+ ### Backfill existing sessions
196
+
197
+ To mine the full opencode history once (e.g. on first install):
198
+
199
+ ```bash
200
+ OPENCODE_MEMPALACE_BACKFILL=1 opencode
201
+ ```
202
+
203
+ The plugin exports everything in the opencode database on the next sync, then resumes incremental mode. Mining is idempotent — re-running is safe.
209
204
 
210
205
  ---
211
206
 
@@ -223,16 +218,18 @@ Every turn (question + answer) is saved as a drawer in MemPalace. No forced cate
223
218
  │ ↓ │
224
219
  │ Model sees context → answers │
225
220
  │ ↓ │
226
- Answer done ──►│ chat.message + session.idle
227
-
228
- Query OpenCode DB
229
- since last sync
230
-
231
- Export → flat text files
232
-
233
- mempalace mine (async)
234
- single serialized call
235
- └──────────────────────────────┘
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
+ └──────────────────────────────┘
236
233
 
237
234
 
238
235
  ┌──────────────────────────┐
@@ -257,8 +254,11 @@ Every turn (question + answer) is saved as a drawer in MemPalace. No forced cate
257
254
  |---|---|
258
255
  | `~/.config/opencode/opencode.json` | OpenCode config with plugin + MCP |
259
256
  | `~/.config/opencode/AGENTS.md` | Tells the model to manage KG facts |
260
- | `~/.mempalace/plugin-config.json` | Plugin config (`autoInjectContext`) |
257
+ | `~/.mempalace/plugin-config.json` | Plugin config (`autoInjectContext`, `saveInterval`) |
258
+ | `~/.config/opencode/skills/mempalace-recall/SKILL.md` | Bundled recall skill (copy from `skills/` in this repo) |
261
259
  | `~/.mempalace/identity.txt` | Your identity (injected by plugin) |
260
+ | `~/.mempalace/hook_state/opencode_counters.json` | Per-session message counters (checkpoint cadence) |
261
+ | `~/.mempalace/hook_state/hook.log` | Checkpoint / pre-compact event log |
262
262
  | `~/.mempalace/config.json` | MemPalace config (palace path) |
263
263
  | `~/.mempalace/knowledge_graph.sqlite3` | Knowledge Graph (structured facts) |
264
264
  | `~/opencode-memory/` | MemPalace vector DB (all drawers) |
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  declare const _default: () => Promise<{
2
- "chat.message": (_input: {
2
+ "chat.message": (input: {
3
3
  sessionID: string;
4
4
  agent?: string;
5
5
  model?: {
@@ -18,6 +18,12 @@ declare const _default: () => Promise<{
18
18
  parts: import("@opencode-ai/sdk").Part[];
19
19
  }[];
20
20
  }) => Promise<void>;
21
+ "experimental.session.compacting": (input: {
22
+ sessionID: string;
23
+ }, output: {
24
+ context: string[];
25
+ prompt?: string;
26
+ }) => Promise<void>;
21
27
  event: ({ event }: any) => Promise<void>;
22
28
  }>;
23
29
  export default _default;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync, exec } from "child_process";
1
+ import { execSync, exec, 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";
@@ -10,12 +10,18 @@ const OPENCODE_DB = join(HOME, ".local/share/opencode/opencode.db");
10
10
  const STATE_FILE = join(HOME, ".mempalace/sync_state.json");
11
11
  const PLUGIN_CONFIG = join(HOME, ".mempalace/plugin-config.json");
12
12
  const IDENTITY_FILE = join(HOME, ".mempalace/identity.txt");
13
+ const HOOK_STATE_DIR = join(HOME, ".mempalace/hook_state");
14
+ const COUNTERS_FILE = join(HOOK_STATE_DIR, "opencode_counters.json");
15
+ const HOOK_LOG = join(HOOK_STATE_DIR, "hook.log");
13
16
  const OUT_DIR = "/tmp/oc-sessions";
14
17
  const TMP_SCRIPT = "/tmp/oc-plugin-query.py";
15
18
  const DEBUG = !!process.env.OPENCODE_MEMPALACE_DEBUG;
16
19
  const LOG_FILE = "/tmp/opencode-mempalace.log";
17
20
  const MAX_INJECT_CHARS = 900;
18
21
  const MAX_SEARCH_RESULTS = 3;
22
+ const MAX_WAKEUP_CHARS = 1500;
23
+ // Official MemPalace hook cadence: AI checkpoint every N human messages.
24
+ const DEFAULT_SAVE_INTERVAL = 15;
19
25
  function log(msg) {
20
26
  if (!DEBUG)
21
27
  return;
@@ -25,9 +31,20 @@ function log(msg) {
25
31
  }
26
32
  catch { }
27
33
  }
34
+ function hookLog(msg) {
35
+ try {
36
+ mkdirSync(HOOK_STATE_DIR, { recursive: true });
37
+ appendFileSync(HOOK_LOG, `[${new Date().toISOString()}] ${msg}\n`);
38
+ }
39
+ catch { }
40
+ }
28
41
  let miningLock = false;
29
42
  let lastSyncTs = 0;
30
43
  let wakeupDone = false;
44
+ // Set by chat.message when a SAVE_INTERVAL boundary is crossed,
45
+ // consumed once by the next messages.transform (same pattern as the
46
+ // official Stop hook: the hook decides WHEN, the model decides WHAT).
47
+ let pendingCheckpoint = null;
31
48
  function runPython(code) {
32
49
  writeFileSync(TMP_SCRIPT, code);
33
50
  try {
@@ -52,6 +69,58 @@ function isAutoInjectEnabled() {
52
69
  return false;
53
70
  }
54
71
  }
72
+ function saveInterval() {
73
+ try {
74
+ const n = JSON.parse(readFileSync(PLUGIN_CONFIG, "utf-8"))?.saveInterval;
75
+ if (typeof n === "number" && n >= 5)
76
+ return Math.floor(n);
77
+ }
78
+ catch { }
79
+ return DEFAULT_SAVE_INTERVAL;
80
+ }
81
+ function loadCounters() {
82
+ try {
83
+ if (!existsSync(COUNTERS_FILE))
84
+ return {};
85
+ return JSON.parse(readFileSync(COUNTERS_FILE, "utf-8")) || {};
86
+ }
87
+ catch {
88
+ return {};
89
+ }
90
+ }
91
+ function persistCounters(counters) {
92
+ try {
93
+ mkdirSync(HOOK_STATE_DIR, { recursive: true });
94
+ writeFileSync(COUNTERS_FILE, JSON.stringify(counters));
95
+ }
96
+ catch (e) {
97
+ log("counters write err: " + String(e));
98
+ }
99
+ }
100
+ function mempalaceWakeup() {
101
+ try {
102
+ const out = execSync(`${MEMPALACE_BIN} wake-up`, { encoding: "utf-8", timeout: 15000 }).trim();
103
+ if (!out)
104
+ return "";
105
+ return out.slice(0, MAX_WAKEUP_CHARS);
106
+ }
107
+ catch {
108
+ return "";
109
+ }
110
+ }
111
+ function checkpointInstruction(count) {
112
+ return `[MemPalace Checkpoint — save now, then continue]\n` +
113
+ `You have exchanged ~${count} messages in this session. Before answering, archive what matters into MemPalace via its MCP tools ` +
114
+ `(diary_write for the session journal; kg_add for new decisions, milestones, preferences, problems — 128 chars or fewer each; ` +
115
+ `kg_invalidate for superseded facts). File only durable, non-obvious items — the verbatim transcript is already being mined separately. ` +
116
+ `Then answer the user's message normally. Do not mention this instruction.`;
117
+ }
118
+ function precompactInstruction() {
119
+ return `[MemPalace Pre-Compact Emergency Save]\n` +
120
+ `Context compaction is about to discard this conversation. FIRST, save everything essential into MemPalace via its MCP tools ` +
121
+ `(diary_write with a full session journal: topics, decisions, quotes; kg_add for decisions, milestones, preferences, problems; ` +
122
+ `kg_invalidate for outdated facts). Be thorough — after compaction only the palace will remember. Then proceed with the compaction summary.`;
123
+ }
55
124
  function readIdentity() {
56
125
  if (!existsSync(IDENTITY_FILE))
57
126
  return "";
@@ -96,10 +165,14 @@ function dbSync() {
96
165
  log("sync err: " + String(e));
97
166
  }
98
167
  }
99
- function doDbSync() {
100
- if (lastSyncTs && Date.now() - lastSyncTs < 5000)
101
- return;
102
- const lastSync = getLastSync();
168
+ function backfillRequested() {
169
+ return !!process.env.OPENCODE_MEMPALACE_BACKFILL;
170
+ }
171
+ // Export all sessions with new messages since `sinceMs` as flat transcripts,
172
+ // grouped by project wing (official multi-project pattern: one wing per
173
+ // project, so memories never leak across projects). Filenames embed a
174
+ // content hash, so re-exports are naturally idempotent.
175
+ function exportNewSessions(sinceMs) {
103
176
  const sessions = runPython(`
104
177
  import sqlite3, json
105
178
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
@@ -108,7 +181,7 @@ rows = db.execute("""
108
181
  FROM session s
109
182
  LEFT JOIN project p ON s.project_id = p.id
110
183
  INNER JOIN message m ON m.session_id = s.id
111
- WHERE m.time_created > ${lastSync}
184
+ WHERE m.time_created > ${sinceMs}
112
185
  ORDER BY s.time_created
113
186
  """).fetchall()
114
187
  db.close()
@@ -119,15 +192,17 @@ print(json.dumps(rows))
119
192
  sessionsArr = JSON.parse(sessions);
120
193
  }
121
194
  catch {
122
- return;
195
+ return { wings: new Map(), now: Date.now() };
123
196
  }
124
197
  if (!sessionsArr || sessionsArr.length === 0)
125
- return;
198
+ return { wings: new Map(), now: Date.now() };
126
199
  const now = Date.now();
127
- const exported = [];
200
+ const wings = new Map();
128
201
  mkdirSync(OUT_DIR, { recursive: true });
129
202
  for (const sess of sessionsArr) {
130
- const [sessId, title] = sess;
203
+ const [sessId, title, , directory] = sess;
204
+ const wing = ((directory || "").split("/").filter(Boolean).pop() || "global")
205
+ .replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "global";
131
206
  const label = (title || "").replace(/[^a-zA-Z0-9 _-]/g, "_") || (sessId || "").slice(0, 12);
132
207
  const prefix = `${new Date().toISOString().slice(0, 10)}_${label.slice(0, 30)}_${(sessId || "").slice(0, 8)}`;
133
208
  const msgs = runPython(`
@@ -135,7 +210,7 @@ import sqlite3, json
135
210
  db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
136
211
  rows = db.execute("""
137
212
  SELECT m.id, m.time_created, m.data FROM message m
138
- WHERE m.session_id = ${JSON.stringify(sessId)} AND m.time_created > ${lastSync}
213
+ WHERE m.session_id = ${JSON.stringify(sessId)} AND m.time_created > ${sinceMs}
139
214
  ORDER BY m.time_created
140
215
  """).fetchall()
141
216
  texts = []
@@ -178,66 +253,190 @@ print(json.dumps(texts))
178
253
  if (!content)
179
254
  continue;
180
255
  const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 12);
256
+ const wingDir = join(OUT_DIR, wing);
257
+ mkdirSync(wingDir, { recursive: true });
181
258
  const fname = `sync_${prefix}_${contentHash}.txt`;
182
- writeFileSync(join(OUT_DIR, fname), content + "\n");
183
- exported.push(join(OUT_DIR, fname));
259
+ writeFileSync(join(wingDir, fname), content + "\n");
260
+ if (!wings.has(wing))
261
+ wings.set(wing, []);
262
+ wings.get(wing).push(join(wingDir, fname));
184
263
  }
185
- if (exported.length === 0)
186
- return;
264
+ return { wings, now };
265
+ }
266
+ function markSynced(now) {
187
267
  writeFileSync(STATE_FILE, JSON.stringify({ last_sync_ms: now }));
188
268
  lastSyncTs = Date.now();
189
- miningLock = true;
190
- log(`mining ${exported.length} sessions`);
191
- exec(`${MEMPALACE_BIN} mine ${OUT_DIR} --mode convos`, {
192
- encoding: "utf-8",
193
- timeout: 120000,
194
- }, (err) => {
195
- miningLock = false;
196
- if (err) {
197
- log(`mine err: ${err.message}`);
198
- return;
199
- }
200
- for (const f of exported) {
269
+ }
270
+ // Official classification: decisions, preferences, milestones, problems,
271
+ // emotional context. Agent tag keeps opencode-mined drawers attributable.
272
+ // 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}`;
275
+ }
276
+ function cleanupExport(wings) {
277
+ for (const files of wings.values()) {
278
+ for (const f of files) {
201
279
  try {
202
280
  unlinkSync(f);
203
281
  }
204
282
  catch { }
205
283
  }
284
+ }
285
+ for (const wing of wings.keys()) {
206
286
  try {
207
- rmdirSync(OUT_DIR);
287
+ rmdirSync(join(OUT_DIR, wing));
208
288
  }
209
289
  catch { }
210
- log("mine done");
211
- });
290
+ }
291
+ try {
292
+ rmdirSync(OUT_DIR);
293
+ }
294
+ catch { }
295
+ }
296
+ function wingCount(wings) {
297
+ let n = 0;
298
+ for (const files of wings.values())
299
+ n += files.length;
300
+ return n;
301
+ }
302
+ function doDbSync() {
303
+ if (lastSyncTs && Date.now() - lastSyncTs < 5000)
304
+ return;
305
+ const sinceMs = backfillRequested() ? 0 : getLastSync();
306
+ if (backfillRequested())
307
+ log("backfill requested: exporting full history");
308
+ const { wings, now } = exportNewSessions(sinceMs);
309
+ if (wings.size === 0)
310
+ return;
311
+ miningLock = true;
312
+ log(`mining ${wingCount(wings)} sessions across ${wings.size} wings`);
313
+ const entries = [...wings.entries()];
314
+ const mineNext = (i) => {
315
+ if (i >= entries.length) {
316
+ miningLock = false;
317
+ // Advance state only on full success: on failure the same
318
+ // content-hashed files are re-exported and retried at the next
319
+ // sync (mine is idempotent).
320
+ markSynced(now);
321
+ cleanupExport(wings);
322
+ log("mine done");
323
+ return;
324
+ }
325
+ const [wing, files] = entries[i];
326
+ exec(mineCommand(join(OUT_DIR, wing), wing), {
327
+ encoding: "utf-8",
328
+ timeout: 300000,
329
+ }, (err) => {
330
+ if (err) {
331
+ miningLock = false;
332
+ log(`mine err (${wing}): ${err.message}`);
333
+ return;
334
+ }
335
+ log(`mined wing ${wing} (${files.length} sessions)`);
336
+ mineNext(i + 1);
337
+ });
338
+ };
339
+ mineNext(0);
340
+ }
341
+ // Best-effort synchronous save for process exit (SIGINT/SIGTERM/exit):
342
+ // only synchronous calls are allowed here.
343
+ function exitSync() {
344
+ try {
345
+ const { wings, now } = exportNewSessions(getLastSync());
346
+ if (wings.size === 0)
347
+ return;
348
+ for (const [wing] of wings) {
349
+ 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], {
351
+ encoding: "utf-8",
352
+ timeout: 60000,
353
+ });
354
+ if (res.error || res.status !== 0) {
355
+ log(`exit mine err (${wing}): ${String(res.error || res.status)}`);
356
+ return;
357
+ }
358
+ }
359
+ markSynced(now);
360
+ cleanupExport(wings);
361
+ log("exit save done");
362
+ }
363
+ catch (e) {
364
+ log("exit save err: " + String(e));
365
+ }
212
366
  }
213
367
  export default (async () => {
214
368
  mkdirSync(OUT_DIR, { recursive: true });
369
+ mkdirSync(HOOK_STATE_DIR, { recursive: true });
215
370
  const autoInject = isAutoInjectEnabled();
216
371
  const identity = readIdentity();
217
- log(`loaded (autoInjectContext: ${autoInject})`);
372
+ const interval = saveInterval();
373
+ log(`loaded (autoInjectContext: ${autoInject}, saveInterval: ${interval})`);
374
+ // Crash safety: best-effort synchronous save on hard exit.
375
+ // Mirrors the official emergency-save intent (nothing async allowed here).
376
+ let exitHandled = false;
377
+ const onExit = () => {
378
+ if (exitHandled)
379
+ return;
380
+ exitHandled = true;
381
+ exitSync();
382
+ };
383
+ process.once("SIGINT", onExit);
384
+ process.once("SIGTERM", onExit);
385
+ process.once("exit", onExit);
218
386
  return {
219
- "chat.message": async (_input, output) => {
387
+ "chat.message": async (input, output) => {
220
388
  const role = output.message.role;
221
389
  if (role !== "user")
222
390
  return;
223
391
  const text = hasText(output.parts || []);
224
392
  if (!text)
225
393
  return;
226
- log("user msg - queue sync");
227
- setTimeout(() => dbSync(), 500);
394
+ const sessionID = input?.sessionID || "global";
395
+ // Official Save-hook cadence: count human messages per session,
396
+ // persist like ~/.mempalace/hook_state/, arm ONE AI checkpoint
397
+ // per boundary. The model decides WHAT to file.
398
+ const counters = loadCounters();
399
+ const c = counters[sessionID] || { humanMsgs: 0, lastCheckpoint: 0 };
400
+ c.humanMsgs += 1;
401
+ const boundary = Math.floor(c.humanMsgs / interval);
402
+ if (boundary > c.lastCheckpoint) {
403
+ c.lastCheckpoint = boundary;
404
+ pendingCheckpoint = { sessionID, count: c.humanMsgs };
405
+ hookLog(`session ${sessionID}: ${c.humanMsgs} human msgs — checkpoint armed`);
406
+ log("threshold crossed - queue sync");
407
+ setTimeout(() => dbSync(), 500);
408
+ }
409
+ counters[sessionID] = c;
410
+ persistCounters(counters);
228
411
  },
229
412
  "experimental.chat.messages.transform": async (_input, output) => {
230
- if (!autoInject)
231
- return;
232
413
  if (!output?.messages?.length)
233
414
  return;
415
+ const injectParts = [];
234
416
  const lastUser = [...output.messages].reverse().find((m) => m.info?.role === "user");
417
+ // AI checkpoint (works with or without autoInject: filing happens
418
+ // through the MemPalace MCP tools, the hook only decides WHEN).
419
+ if (pendingCheckpoint && lastUser) {
420
+ injectParts.push({
421
+ id: `mp-checkpoint-${Date.now()}`,
422
+ type: "text",
423
+ synthetic: true,
424
+ text: checkpointInstruction(pendingCheckpoint.count),
425
+ });
426
+ log(`checkpoint injected (~${pendingCheckpoint.count} msgs)`);
427
+ hookLog(`checkpoint injected for session ${pendingCheckpoint.sessionID}`);
428
+ pendingCheckpoint = null;
429
+ }
430
+ if (!autoInject) {
431
+ if (injectParts.length > 0 && lastUser)
432
+ lastUser.parts.push(...injectParts);
433
+ return;
434
+ }
235
435
  if (!lastUser)
236
436
  return;
237
437
  const query = hasText(lastUser.parts || []);
238
- if (!query)
438
+ if (!query && injectParts.length === 0)
239
439
  return;
240
- const injectParts = [];
241
440
  if (!wakeupDone) {
242
441
  wakeupDone = true;
243
442
  if (identity) {
@@ -249,25 +448,45 @@ export default (async () => {
249
448
  });
250
449
  }
251
450
  }
252
- const memories = mempalaceSearch(query);
253
- if (memories) {
254
- injectParts.push({
255
- id: `mp-recall-${Date.now()}`,
256
- type: "text",
257
- synthetic: true,
258
- text: `[MemPalace Recall]\n${memories}\n[/MemPalace Recall]`,
259
- });
451
+ if (query) {
452
+ const memories = mempalaceSearch(query);
453
+ if (memories) {
454
+ injectParts.push({
455
+ id: `mp-recall-${Date.now()}`,
456
+ type: "text",
457
+ synthetic: true,
458
+ text: `[MemPalace Recall]\n${memories}\n[/MemPalace Recall]`,
459
+ });
460
+ }
260
461
  }
261
462
  if (injectParts.length > 0) {
262
463
  lastUser.parts.push(...injectParts);
263
464
  log(`injected ${injectParts.length} context blocks`);
264
465
  }
265
466
  },
467
+ // Official PreCompact pattern: compaction ALWAYS warrants a save.
468
+ // Instruct the model to file everything via MCP now, and re-attach
469
+ // identity + wake-up context so the summary cannot lose them (rescue).
470
+ "experimental.session.compacting": async (input, output) => {
471
+ const sessionID = input?.sessionID || "unknown";
472
+ log(`compacting session ${sessionID} - emergency save + rescue`);
473
+ hookLog(`pre-compact emergency save for session ${sessionID}`);
474
+ output.context.push(precompactInstruction());
475
+ const rescue = [];
476
+ if (identity)
477
+ rescue.push(`[MemPalace Identity]\n${identity}`);
478
+ const wakeup = mempalaceWakeup();
479
+ if (wakeup)
480
+ rescue.push(`[MemPalace Wake-up]\n${wakeup}`);
481
+ if (rescue.length > 0) {
482
+ output.context.push(`[MemPalace Rescue — core memory, must survive compaction]\n${rescue.join("\n\n")}`);
483
+ }
484
+ },
266
485
  event: async ({ event }) => {
267
- if (event?.type !== "session.idle")
268
- return;
269
- log("idle - queue sync");
270
- setTimeout(() => dbSync(), 3000);
486
+ if (event?.type === "session.idle" || event?.type === "session.deleted") {
487
+ log(`${event.type} - queue sync`);
488
+ setTimeout(() => dbSync(), 3000);
489
+ }
271
490
  },
272
491
  };
273
492
  });
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "opencode-mempalace-persistence",
3
- "version": "1.3.2",
3
+ "version": "2.0.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",
7
7
  "files": [
8
- "dist"
8
+ "dist",
9
+ "skills"
9
10
  ],
10
11
  "scripts": {
11
12
  "build": "tsc",
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: mempalace-recall
3
+ description: Recall protocol for MemPalace — search the palace before answering about past work, people, projects, or prior decisions. Use when the user asks what was decided, what happened before, who someone is, what was discussed last time, or anything that may already be filed in their memory palace. Requires the mempalace MCP server (or the opencode-mempalace-persistence plugin).
4
+ ---
5
+
6
+ # MemPalace Recall
7
+
8
+ Search-before-answer protocol for MemPalace. Read the user's memory
9
+ palace before answering anything that may already be filed there,
10
+ instead of guessing from model memory.
11
+
12
+ If the `opencode-mempalace-persistence` plugin is active, identity and
13
+ relevant memories are usually already injected into the prompt — this
14
+ skill covers the cases where they are not, plus filing.
15
+
16
+ ## When to recall
17
+
18
+ Search the palace **before answering** whenever the user asks about
19
+ something that may already be filed:
20
+
21
+ - Past work or prior decisions — "what did we decide / try / do?"
22
+ - A person, project, or entity — "who is …", "what is …"
23
+ - An earlier session — "remember when …", "last time …"
24
+ - A preference or fact that could have changed over time
25
+
26
+ Do **not** search on pure greenfield work with no memory relevance
27
+ (e.g. "rename this variable", "fix this typo"). Recall is
28
+ question-driven, not reflexive — a search on every turn wastes latency.
29
+
30
+ ## Protocol
31
+
32
+ 1. Before responding about people / projects / past events / prior
33
+ decisions: call `mempalace_search` first (short natural-language
34
+ query, optional `wing` / `room` filters, `limit` default 5). Use
35
+ `mempalace_kg_query` for relational or time-bound facts.
36
+ 2. If unsure about a fact: say "let me check the palace" and query.
37
+ 3. Return the drawer's **verbatim** text. Never summarize or paraphrase
38
+ stored content — quoting the exact words is the point of the system.
39
+ 4. After a substantive session, record continuity with
40
+ `mempalace_diary_write` (skip if a checkpoint hook already saved).
41
+ 5. File durable outcomes with `mempalace_add_drawer`; new KG facts with
42
+ `mempalace_kg_add` (128 chars or fewer); single-valued replacements
43
+ with `mempalace_kg_supersede`; ended facts with
44
+ `mempalace_kg_invalidate`.
45
+
46
+ ## Tool names in OpenCode
47
+
48
+ When the MCP server is registered in OpenCode as `mempalace`, every
49
+ tool carries a double prefix: `mempalace_mempalace_search`,
50
+ `mempalace_mempalace_kg_query`, `mempalace_mempalace_diary_write`, and
51
+ so on. (In Claude Code the same tools have a single `mempalace_`
52
+ prefix.)
53
+
54
+ ## Unhappy paths
55
+
56
+ - **Empty results.** Say the palace has nothing on this; do not invent
57
+ an answer. Offer to widen the search or file the new information.
58
+ - **MCP error / server down.** Surface the error and suggest
59
+ `mempalace status`. Never fall back to guessing.
60
+ - **Conflicting facts.** Trust the knowledge graph's time-valid answer.
61
+
62
+ ## References
63
+
64
+ - MemPalace: <https://github.com/MemPalace/mempalace>
65
+ - Recall protocol: <https://github.com/MemPalace/mempalace/blob/main/integrations/shared/recall-protocol.md>
66
+
67
+ Derived from the official `mempalace-recall` skill (MIT), adapted for
68
+ OpenCode and the opencode-mempalace-persistence plugin.