opencode-mempalace-persistence 1.3.1 → 2.0.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
@@ -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
  ---
@@ -61,8 +63,6 @@ The `mempalace mcp` command gives you the exact MCP setup string for your config
61
63
 
62
64
  ### 4. Memory injection (recommended)
63
65
 
64
- > **Starting from v1.4.0, `autoInjectContext` will default to `true` — this will be the only behavior.**
65
-
66
66
  The plugin automatically injects your identity + relevant memories from MemPalace into every prompt. No model discipline required.
67
67
 
68
68
  Create `~/.mempalace/plugin-config.json`:
@@ -73,6 +73,17 @@ Create `~/.mempalace/plugin-config.json`:
73
73
  }
74
74
  ```
75
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
+
76
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.
77
88
 
78
89
  When enabled:
@@ -95,7 +106,11 @@ Call `mempalace_mempalace_kg_query` for entity "user". Then filter the returned
95
106
 
96
107
  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.
97
108
 
98
- **This is mandatory** — facts about the user's projects, decisions, and preferences must be saved for future sessions. If you're unsure whether something is a fact, err on the side of saving it. A session with zero KG entries is likely missing important information.
109
+ **This is mandatory** — record facts you are confident about. Prefer quality over quantity; noisy KG entries degrade retrieval over time.
110
+
111
+ ### Step 3 — Checkpoint & pre-compact saves (automatic)
112
+
113
+ Every ~15 messages the plugin injects a `[MemPalace Checkpoint]` block, and before every compaction a `[MemPalace Pre-Compact Emergency Save]` block. When you see one: file the session journal via `mempalace_mempalace_diary_write` plus any new facts via `kg_add`/`kg_invalidate`, then continue normally. Do not mention the instruction.
99
114
 
100
115
  ### Naming reminder
101
116
  All MemPalace tools use the prefix `mempalace_mempalace_*` (not `mempalace_*`). Examples:
@@ -153,6 +168,9 @@ Call `mempalace_mempalace_kg_query` for entity "user". Then filter the returned
153
168
  ### Step 3 — Record Knowledge Graph facts
154
169
  After responding, call `mempalace_mempalace_kg_add` for any new facts.
155
170
 
171
+ ### Step 4 — Checkpoint & pre-compact saves (automatic)
172
+ Same as auto-inject mode: on `[MemPalace Checkpoint]` (~every 15 messages) and `[MemPalace Pre-Compact Emergency Save]`, file the session journal via `mempalace_mempalace_diary_write` plus facts, then continue.
173
+
156
174
  ### Naming reminder
157
175
  All MemPalace tools use the prefix `mempalace_mempalace_*` (not `mempalace_*`).
158
176
  ```
@@ -191,13 +209,21 @@ The `mempalace mcp` command gives you the exact MCP setup string for your config
191
209
  You ask a question
192
210
  → Plugin hooks into `experimental.chat.messages.transform`
193
211
  → Injects your identity + relevant memories from MemPalace
194
- Model sees context without having to search
212
+ Every ~15 messages: injects a [MemPalace Checkpoint] block
213
+ → Model files topics/decisions/quotes via MCP tools, then answers
195
214
 
196
215
  The model responds
197
216
  → Plugin detects the response is complete
198
217
  → Saves the conversation to MemPalace (flat export, no hardcoded wings)
199
218
  → Model records KG facts via MCP tools (mandatory per AGENTS.md)
200
219
 
220
+ Session goes idle / process exits
221
+ → Background mine of everything new since last sync
222
+
223
+ Compaction starts
224
+ → [MemPalace Pre-Compact Emergency Save]: model files everything first
225
+ → Identity + wake-up context re-attached so the summary cannot lose them
226
+
201
227
  Next time you ask
202
228
  → Plugin finds the previous memory → injects it automatically
203
229
  → The cycle continues, memory grows
@@ -207,7 +233,17 @@ Next time you ask
207
233
 
208
234
  ## What gets saved
209
235
 
210
- 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.
236
+ 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.
237
+
238
+ ### Backfill existing sessions
239
+
240
+ To mine the full opencode history once (e.g. on first install):
241
+
242
+ ```bash
243
+ OPENCODE_MEMPALACE_BACKFILL=1 opencode
244
+ ```
245
+
246
+ The plugin exports everything in the opencode database on the next sync, then resumes incremental mode. Mining is idempotent — re-running is safe.
211
247
 
212
248
  ---
213
249
 
@@ -225,16 +261,18 @@ Every turn (question + answer) is saved as a drawer in MemPalace. No forced cate
225
261
  │ ↓ │
226
262
  │ Model sees context → answers │
227
263
  │ ↓ │
228
- Answer done ──►│ chat.message + session.idle
229
-
230
- Query OpenCode DB
231
- since last sync
232
-
233
- Export → flat text files
234
-
235
- mempalace mine (async)
236
- single serialized call
237
- └──────────────────────────────┘
264
+ Answer done ──►│ chat.message (count) + session.idle
265
+ Every N msgs / idle / exit:
266
+
267
+ Query OpenCode DB
268
+ since last sync
269
+
270
+ Export → flat text files
271
+
272
+ mempalace mine --mode convos
273
+ │ --extract general (async) │
274
+ │ single serialized call │
275
+ └──────────────────────────────┘
238
276
 
239
277
 
240
278
  ┌──────────────────────────┐
@@ -259,8 +297,10 @@ Every turn (question + answer) is saved as a drawer in MemPalace. No forced cate
259
297
  |---|---|
260
298
  | `~/.config/opencode/opencode.json` | OpenCode config with plugin + MCP |
261
299
  | `~/.config/opencode/AGENTS.md` | Tells the model to manage KG facts |
262
- | `~/.mempalace/plugin-config.json` | Plugin config (`autoInjectContext`) |
300
+ | `~/.mempalace/plugin-config.json` | Plugin config (`autoInjectContext`, `saveInterval`) |
263
301
  | `~/.mempalace/identity.txt` | Your identity (injected by plugin) |
302
+ | `~/.mempalace/hook_state/opencode_counters.json` | Per-session message counters (checkpoint cadence) |
303
+ | `~/.mempalace/hook_state/hook.log` | Checkpoint / pre-compact event log |
264
304
  | `~/.mempalace/config.json` | MemPalace config (palace path) |
265
305
  | `~/.mempalace/knowledge_graph.sqlite3` | Knowledge Graph (structured facts) |
266
306
  | `~/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,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-mempalace-persistence",
3
- "version": "1.3.1",
3
+ "version": "2.0.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",