opencode-mempalace-persistence 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -16
- package/dist/index.js +174 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,8 +6,12 @@ An OpenCode plugin that automatically saves every conversation to MemPalace and
|
|
|
6
6
|
|
|
7
7
|
Follows the official MemPalace automation pattern (same as the Claude Code hooks): the plugin decides **when** to save, the model decides **what** to file via the MemPalace MCP tools.
|
|
8
8
|
|
|
9
|
+
[](https://www.npmjs.com/package/opencode-mempalace-persistence)
|
|
10
|
+
[](https://www.npmjs.com/package/opencode-mempalace-persistence)
|
|
9
11
|
[](LICENSE)
|
|
10
12
|
|
|
13
|
+

|
|
14
|
+
|
|
11
15
|
---
|
|
12
16
|
|
|
13
17
|
## How it works in 3 seconds
|
|
@@ -170,8 +174,7 @@ You ask a question
|
|
|
170
174
|
→ Model files topics/decisions/quotes via MCP tools, then answers
|
|
171
175
|
|
|
172
176
|
The model responds
|
|
173
|
-
→
|
|
174
|
-
→ Saves the conversation to MemPalace (flat export, no hardcoded wings)
|
|
177
|
+
→ Once the turn completes, the next idle/exit/startup mines it to MemPalace (flat export, no hardcoded wings)
|
|
175
178
|
→ Model records new KG facts via MCP tools (only when something new emerged)
|
|
176
179
|
|
|
177
180
|
Session goes idle / process exits
|
|
@@ -190,7 +193,7 @@ Next time you ask
|
|
|
190
193
|
|
|
191
194
|
## What gets saved
|
|
192
195
|
|
|
193
|
-
Every turn (question + answer) is saved as a drawer in MemPalace.
|
|
196
|
+
Every turn (question + answer) is saved as a drawer in MemPalace. Mining runs with `--mode convos` (default `exchange` extraction: one drawer per exchange pair, verbatim, no paraphrasing). Exports are grouped one wing per project (official multi-project pattern: `bot-oc` sessions land in wing `bot-oc`, never leaking across projects). Only completed turns are exported (in-flight replies are revisited by the next sync). The model additionally records KG facts (decisions, milestones, preferences) during conversation and at each checkpoint via MCP tools.
|
|
194
197
|
|
|
195
198
|
### Backfill existing sessions
|
|
196
199
|
|
|
@@ -218,18 +221,16 @@ The plugin exports everything in the opencode database on the next sync, then re
|
|
|
218
221
|
│ ↓ │
|
|
219
222
|
│ Model sees context → answers │
|
|
220
223
|
│ ↓ │
|
|
221
|
-
Answer done ──►│ chat.message (count) + session.idle
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
│ single serialized call │
|
|
232
|
-
└──────────────────────────────┘
|
|
224
|
+
Answer done ──►│ chat.message (count) + session.idle │
|
|
225
|
+
│ mine on idle / exit / startup │
|
|
226
|
+
│ ↓ │
|
|
227
|
+
│ Query OpenCode DB (completed turns) │
|
|
228
|
+
│ ↓ │
|
|
229
|
+
│ Export → flat text files (0700) │
|
|
230
|
+
│ ↓ │
|
|
231
|
+
│ mempalace mine --mode convos │
|
|
232
|
+
│ single serialized call │
|
|
233
|
+
└──────────────────────────────────────────┘
|
|
233
234
|
│
|
|
234
235
|
▼
|
|
235
236
|
┌──────────────────────────┐
|
|
@@ -258,7 +259,8 @@ The plugin exports everything in the opencode database on the next sync, then re
|
|
|
258
259
|
| `~/.config/opencode/skills/mempalace-recall/SKILL.md` | Bundled recall skill (copy from `skills/` in this repo) |
|
|
259
260
|
| `~/.mempalace/identity.txt` | Your identity (injected by plugin) |
|
|
260
261
|
| `~/.mempalace/hook_state/opencode_counters.json` | Per-session message counters (checkpoint cadence) |
|
|
261
|
-
| `~/.mempalace/hook_state/hook.log` | Checkpoint / pre-compact event log |
|
|
262
|
+
| `~/.mempalace/hook_state/hook.log` | Checkpoint / pre-compact event log (errors always land here) |
|
|
263
|
+
| `~/.mempalace/oc-sessions/` | Private (0700) export workspace for pending transcripts |
|
|
262
264
|
| `~/.mempalace/config.json` | MemPalace config (palace path) |
|
|
263
265
|
| `~/.mempalace/knowledge_graph.sqlite3` | Knowledge Graph (structured facts) |
|
|
264
266
|
| `~/opencode-memory/` | MemPalace vector DB (all drawers) |
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { execSync,
|
|
1
|
+
import { execSync, execFileSync, execFile, 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";
|
|
5
5
|
import { createHash } from "crypto";
|
|
6
6
|
const HOME = homedir();
|
|
7
|
-
const VENV_PYTHON = join(HOME, ".local/share/pipx/venvs/mempalace/bin/python3");
|
|
8
7
|
const MEMPALACE_BIN = join(HOME, ".local/bin/mempalace");
|
|
9
8
|
const OPENCODE_DB = join(HOME, ".local/share/opencode/opencode.db");
|
|
10
9
|
const STATE_FILE = join(HOME, ".mempalace/sync_state.json");
|
|
@@ -13,8 +12,11 @@ const IDENTITY_FILE = join(HOME, ".mempalace/identity.txt");
|
|
|
13
12
|
const HOOK_STATE_DIR = join(HOME, ".mempalace/hook_state");
|
|
14
13
|
const COUNTERS_FILE = join(HOOK_STATE_DIR, "opencode_counters.json");
|
|
15
14
|
const HOOK_LOG = join(HOOK_STATE_DIR, "hook.log");
|
|
16
|
-
|
|
17
|
-
|
|
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");
|
|
18
20
|
const DEBUG = !!process.env.OPENCODE_MEMPALACE_DEBUG;
|
|
19
21
|
const LOG_FILE = "/tmp/opencode-mempalace.log";
|
|
20
22
|
const MAX_INJECT_CHARS = 900;
|
|
@@ -38,6 +40,45 @@ function hookLog(msg) {
|
|
|
38
40
|
}
|
|
39
41
|
catch { }
|
|
40
42
|
}
|
|
43
|
+
// Errors are never silent: hook.log is always written (unlike the
|
|
44
|
+
// DEBUG-gated log), so a broken pipeline is visible by default.
|
|
45
|
+
function errLog(msg) {
|
|
46
|
+
log("ERROR: " + msg);
|
|
47
|
+
hookLog("ERROR: " + msg);
|
|
48
|
+
}
|
|
49
|
+
// Probe for a working Python interpreter at startup instead of hardcoding
|
|
50
|
+
// one installer layout (pipx vs uv tool vs system). runPython only needs
|
|
51
|
+
// stdlib (sqlite3/json), so any python3 works. Priority: explicit env
|
|
52
|
+
// override, legacy pipx venv, uv tool venv, PATH fallback.
|
|
53
|
+
let resolvedPython = undefined;
|
|
54
|
+
function resolvePython() {
|
|
55
|
+
if (resolvedPython !== undefined)
|
|
56
|
+
return resolvedPython;
|
|
57
|
+
const candidates = [
|
|
58
|
+
process.env.MEMPALACE_PYTHON,
|
|
59
|
+
join(HOME, ".local/share/pipx/venvs/mempalace/bin/python3"),
|
|
60
|
+
join(HOME, ".local/share/uv/tools/mempalace/bin/python3"),
|
|
61
|
+
].filter((p) => !!p && existsSync(p));
|
|
62
|
+
if (candidates.length > 0) {
|
|
63
|
+
resolvedPython = candidates[0];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
try {
|
|
67
|
+
execSync("python3 --version", { encoding: "utf-8", timeout: 10000 });
|
|
68
|
+
resolvedPython = "python3";
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
resolvedPython = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (resolvedPython) {
|
|
75
|
+
log("using python: " + resolvedPython);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
errLog("no working Python interpreter found (tried MEMPALACE_PYTHON, pipx venv, uv tool venv, PATH python3) — DB export disabled");
|
|
79
|
+
}
|
|
80
|
+
return resolvedPython;
|
|
81
|
+
}
|
|
41
82
|
let miningLock = false;
|
|
42
83
|
let lastSyncTs = 0;
|
|
43
84
|
let wakeupDone = false;
|
|
@@ -46,14 +87,55 @@ let wakeupDone = false;
|
|
|
46
87
|
// official Stop hook: the hook decides WHEN, the model decides WHAT).
|
|
47
88
|
let pendingCheckpoint = null;
|
|
48
89
|
function runPython(code) {
|
|
49
|
-
|
|
90
|
+
const python = resolvePython();
|
|
91
|
+
if (!python)
|
|
92
|
+
throw new Error("no working Python interpreter (see hook.log)");
|
|
93
|
+
mkdirSync(SYNC_DIR, { recursive: true, mode: 0o700 });
|
|
94
|
+
writeFileSync(TMP_SCRIPT, code, { mode: 0o600 });
|
|
50
95
|
try {
|
|
51
|
-
|
|
96
|
+
// argv array, no shell (see PR #2): paths here are fixed, never user input.
|
|
97
|
+
return execFileSync(python, [TMP_SCRIPT], { encoding: "utf-8", timeout: 30000 }).trim();
|
|
52
98
|
}
|
|
53
99
|
finally {
|
|
54
|
-
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(TMP_SCRIPT);
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
55
104
|
}
|
|
56
105
|
}
|
|
106
|
+
// Resolve the mempalace CLI without hardcoding one installer layout:
|
|
107
|
+
// explicit env override, PATH lookup (cross-platform, incl. Windows),
|
|
108
|
+
// legacy ~/.local/bin fallback.
|
|
109
|
+
let resolvedBin = undefined;
|
|
110
|
+
function resolveBin() {
|
|
111
|
+
if (resolvedBin !== undefined)
|
|
112
|
+
return resolvedBin;
|
|
113
|
+
const envBin = process.env.MEMPALACE_BIN;
|
|
114
|
+
if (envBin && existsSync(envBin)) {
|
|
115
|
+
resolvedBin = envBin;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
try {
|
|
119
|
+
const found = execSync(process.platform === "win32" ? "where mempalace" : "command -v mempalace", {
|
|
120
|
+
encoding: "utf-8", timeout: 10000,
|
|
121
|
+
}).trim().split(/\r?\n/)[0]?.trim();
|
|
122
|
+
if (found)
|
|
123
|
+
resolvedBin = found;
|
|
124
|
+
}
|
|
125
|
+
catch { }
|
|
126
|
+
if (!resolvedBin && existsSync(MEMPALACE_BIN))
|
|
127
|
+
resolvedBin = MEMPALACE_BIN;
|
|
128
|
+
if (!resolvedBin)
|
|
129
|
+
resolvedBin = null;
|
|
130
|
+
}
|
|
131
|
+
if (resolvedBin) {
|
|
132
|
+
log("using mempalace: " + resolvedBin);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
errLog("mempalace CLI not found (tried MEMPALACE_BIN env, PATH, ~/.local/bin) — search/wake-up/mine disabled");
|
|
136
|
+
}
|
|
137
|
+
return resolvedBin;
|
|
138
|
+
}
|
|
57
139
|
function hasText(parts) {
|
|
58
140
|
return parts
|
|
59
141
|
.filter((p) => p?.type === "text" && p?.text?.trim())
|
|
@@ -98,8 +180,12 @@ function persistCounters(counters) {
|
|
|
98
180
|
}
|
|
99
181
|
}
|
|
100
182
|
function mempalaceWakeup() {
|
|
183
|
+
const bin = resolveBin();
|
|
184
|
+
if (!bin)
|
|
185
|
+
return "";
|
|
101
186
|
try {
|
|
102
|
-
|
|
187
|
+
// argv array, no shell.
|
|
188
|
+
const out = execFileSync(bin, ["wake-up"], { encoding: "utf-8", timeout: 15000 }).trim();
|
|
103
189
|
if (!out)
|
|
104
190
|
return "";
|
|
105
191
|
return out.slice(0, MAX_WAKEUP_CHARS);
|
|
@@ -132,8 +218,13 @@ function readIdentity() {
|
|
|
132
218
|
}
|
|
133
219
|
}
|
|
134
220
|
function mempalaceSearch(query) {
|
|
221
|
+
const bin = resolveBin();
|
|
222
|
+
if (!bin)
|
|
223
|
+
return "";
|
|
135
224
|
try {
|
|
136
|
-
|
|
225
|
+
// argv array, no shell (see PR #2): the query is raw user message
|
|
226
|
+
// text, so it must never pass through /bin/sh. No manual escaping needed.
|
|
227
|
+
const out = execFileSync(bin, ["search", query, "--results", String(MAX_SEARCH_RESULTS)], {
|
|
137
228
|
encoding: "utf-8",
|
|
138
229
|
timeout: 15000,
|
|
139
230
|
}).trim();
|
|
@@ -162,7 +253,7 @@ function dbSync() {
|
|
|
162
253
|
doDbSync();
|
|
163
254
|
}
|
|
164
255
|
catch (e) {
|
|
165
|
-
|
|
256
|
+
errLog("sync err: " + String(e));
|
|
166
257
|
}
|
|
167
258
|
}
|
|
168
259
|
function backfillRequested() {
|
|
@@ -197,8 +288,11 @@ print(json.dumps(rows))
|
|
|
197
288
|
if (!sessionsArr || sessionsArr.length === 0)
|
|
198
289
|
return { wings: new Map(), now: Date.now() };
|
|
199
290
|
const now = Date.now();
|
|
291
|
+
// Never advance the cursor past an in-flight reply: anything skipped
|
|
292
|
+
// as incomplete is revisited by the next sync (idle/exit/startup).
|
|
293
|
+
let cursor = now;
|
|
200
294
|
const wings = new Map();
|
|
201
|
-
mkdirSync(OUT_DIR, { recursive: true });
|
|
295
|
+
mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
|
|
202
296
|
for (const sess of sessionsArr) {
|
|
203
297
|
const [sessId, title, , directory] = sess;
|
|
204
298
|
const wing = ((directory || "").split("/").filter(Boolean).pop() || "global")
|
|
@@ -214,10 +308,20 @@ rows = db.execute("""
|
|
|
214
308
|
ORDER BY m.time_created
|
|
215
309
|
""").fetchall()
|
|
216
310
|
texts = []
|
|
311
|
+
incomplete = []
|
|
217
312
|
for (mid, mts, mdata_raw) in rows:
|
|
218
313
|
try: mdata = json.loads(mdata_raw)
|
|
219
314
|
except: mdata = {}
|
|
220
315
|
role = mdata.get("role", "unknown")
|
|
316
|
+
# Completion tracking (see PR #1524 review): the assistant message row
|
|
317
|
+
# is created when a reply STARTS, parts stream in afterwards, and
|
|
318
|
+
# finish is set only on completion. Exporting mid-reply would
|
|
319
|
+
# snapshot partial parts while the cursor advances past the message
|
|
320
|
+
# timestamp — losing the rest of the reply forever. So assistant
|
|
321
|
+
# messages without finish are skipped and revisited next sync.
|
|
322
|
+
if role == "assistant" and not mdata.get("finish"):
|
|
323
|
+
incomplete.append(mts)
|
|
324
|
+
continue
|
|
221
325
|
for (pdata_raw,) in db.execute("SELECT data FROM part WHERE message_id = ? ORDER BY time_created", (mid,)).fetchall():
|
|
222
326
|
try:
|
|
223
327
|
pdata = json.loads(pdata_raw)
|
|
@@ -225,16 +329,22 @@ for (mid, mts, mdata_raw) in rows:
|
|
|
225
329
|
texts.append({"role": role, "text": pdata.get("text").strip(), "ts": mts})
|
|
226
330
|
except: pass
|
|
227
331
|
db.close()
|
|
228
|
-
print(json.dumps(texts))
|
|
332
|
+
print(json.dumps({"texts": texts, "incomplete": incomplete}))
|
|
229
333
|
`);
|
|
230
334
|
let msgList;
|
|
335
|
+
let incompleteTs = [];
|
|
231
336
|
try {
|
|
232
|
-
|
|
337
|
+
const parsed = JSON.parse(msgs);
|
|
338
|
+
msgList = parsed.texts;
|
|
339
|
+
incompleteTs = parsed.incomplete || [];
|
|
233
340
|
}
|
|
234
341
|
catch {
|
|
235
342
|
continue;
|
|
236
343
|
}
|
|
237
|
-
if (
|
|
344
|
+
if (incompleteTs.length > 0) {
|
|
345
|
+
cursor = Math.min(cursor, Math.min(...incompleteTs) - 1);
|
|
346
|
+
}
|
|
347
|
+
if (msgList.length < 2 && incompleteTs.length === 0)
|
|
238
348
|
continue;
|
|
239
349
|
const lines = [
|
|
240
350
|
`# ${title || label}`,
|
|
@@ -254,24 +364,28 @@ print(json.dumps(texts))
|
|
|
254
364
|
continue;
|
|
255
365
|
const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 12);
|
|
256
366
|
const wingDir = join(OUT_DIR, wing);
|
|
257
|
-
mkdirSync(wingDir, { recursive: true });
|
|
367
|
+
mkdirSync(wingDir, { recursive: true, mode: 0o700 });
|
|
258
368
|
const fname = `sync_${prefix}_${contentHash}.txt`;
|
|
259
|
-
writeFileSync(join(wingDir, fname), content + "\n");
|
|
369
|
+
writeFileSync(join(wingDir, fname), content + "\n", { mode: 0o600 });
|
|
260
370
|
if (!wings.has(wing))
|
|
261
371
|
wings.set(wing, []);
|
|
262
372
|
wings.get(wing).push(join(wingDir, fname));
|
|
263
373
|
}
|
|
264
|
-
return { wings, now };
|
|
374
|
+
return { wings, now: cursor };
|
|
265
375
|
}
|
|
266
376
|
function markSynced(now) {
|
|
267
377
|
writeFileSync(STATE_FILE, JSON.stringify({ last_sync_ms: now }));
|
|
268
378
|
lastSyncTs = Date.now();
|
|
269
379
|
}
|
|
270
|
-
//
|
|
271
|
-
//
|
|
380
|
+
// Default `exchange` extraction: one drawer per exchange pair, verbatim,
|
|
381
|
+
// no paraphrasing (see PR #1524 review). Intelligent filing (decisions,
|
|
382
|
+
// KG facts, diary) happens through AI checkpoints, not the miner.
|
|
383
|
+
// Agent tag keeps opencode-mined drawers attributable.
|
|
272
384
|
// One wing per project (official multi-project pattern).
|
|
273
|
-
|
|
274
|
-
|
|
385
|
+
// Argv array, no shell (see PR #2): wing names are sanitized, but the
|
|
386
|
+
// spawn path stays shell-free regardless.
|
|
387
|
+
function mineArgs(wingDir, wing) {
|
|
388
|
+
return ["mine", wingDir, "--mode", "convos", "--agent", "opencode", "--wing", wing];
|
|
275
389
|
}
|
|
276
390
|
function cleanupExport(wings) {
|
|
277
391
|
for (const files of wings.values()) {
|
|
@@ -323,13 +437,22 @@ function doDbSync() {
|
|
|
323
437
|
return;
|
|
324
438
|
}
|
|
325
439
|
const [wing, files] = entries[i];
|
|
326
|
-
|
|
440
|
+
// No timeout here by design (see PR #4): Node would kill only the
|
|
441
|
+
// wrapper shell and orphan the python mine process, which keeps
|
|
442
|
+
// holding the palace lock while the next mine piles up. miningLock
|
|
443
|
+
// already serializes concurrent mines; long mines run to completion.
|
|
444
|
+
const bin = resolveBin();
|
|
445
|
+
if (!bin) {
|
|
446
|
+
miningLock = false;
|
|
447
|
+
errLog("mine skipped: mempalace CLI not found");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
execFile(bin, mineArgs(join(OUT_DIR, wing), wing), {
|
|
327
451
|
encoding: "utf-8",
|
|
328
|
-
timeout: 300000,
|
|
329
452
|
}, (err) => {
|
|
330
453
|
if (err) {
|
|
331
454
|
miningLock = false;
|
|
332
|
-
|
|
455
|
+
errLog(`mine err (${wing}): ${err.message}`);
|
|
333
456
|
return;
|
|
334
457
|
}
|
|
335
458
|
log(`mined wing ${wing} (${files.length} sessions)`);
|
|
@@ -339,20 +462,34 @@ function doDbSync() {
|
|
|
339
462
|
mineNext(0);
|
|
340
463
|
}
|
|
341
464
|
// Best-effort synchronous save for process exit (SIGINT/SIGTERM/exit):
|
|
342
|
-
// only synchronous calls are allowed here.
|
|
465
|
+
// only synchronous calls are allowed here. Bounded by EXIT_BUDGET_MS so
|
|
466
|
+
// shutdown stays fast; miningLock is deliberately ignored here because
|
|
467
|
+
// any in-flight async mine dies with the process — at exit this sync
|
|
468
|
+
// mine takes ownership (see PR #1524 review).
|
|
469
|
+
const EXIT_BUDGET_MS = 45000;
|
|
470
|
+
const EXIT_WING_TIMEOUT_MS = 30000;
|
|
343
471
|
function exitSync() {
|
|
344
472
|
try {
|
|
473
|
+
const bin = resolveBin();
|
|
474
|
+
if (!bin)
|
|
475
|
+
return;
|
|
345
476
|
const { wings, now } = exportNewSessions(getLastSync());
|
|
346
477
|
if (wings.size === 0)
|
|
347
478
|
return;
|
|
479
|
+
const deadline = Date.now() + EXIT_BUDGET_MS;
|
|
348
480
|
for (const [wing] of wings) {
|
|
481
|
+
const remaining = deadline - Date.now();
|
|
482
|
+
if (remaining <= 0) {
|
|
483
|
+
log("exit save: budget exhausted, rest covered next startup");
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
349
486
|
log(`exit save: mining wing ${wing}`);
|
|
350
|
-
const res = spawnSync(
|
|
487
|
+
const res = spawnSync(bin, mineArgs(join(OUT_DIR, wing), wing), {
|
|
351
488
|
encoding: "utf-8",
|
|
352
|
-
timeout:
|
|
489
|
+
timeout: Math.min(EXIT_WING_TIMEOUT_MS, remaining),
|
|
353
490
|
});
|
|
354
491
|
if (res.error || res.status !== 0) {
|
|
355
|
-
|
|
492
|
+
errLog(`exit mine err (${wing}): ${String(res.error || res.status)}`);
|
|
356
493
|
return;
|
|
357
494
|
}
|
|
358
495
|
}
|
|
@@ -361,16 +498,19 @@ function exitSync() {
|
|
|
361
498
|
log("exit save done");
|
|
362
499
|
}
|
|
363
500
|
catch (e) {
|
|
364
|
-
|
|
501
|
+
errLog("exit save err: " + String(e));
|
|
365
502
|
}
|
|
366
503
|
}
|
|
367
504
|
export default (async () => {
|
|
368
|
-
mkdirSync(OUT_DIR, { recursive: true });
|
|
505
|
+
mkdirSync(OUT_DIR, { recursive: true, mode: 0o700 });
|
|
369
506
|
mkdirSync(HOOK_STATE_DIR, { recursive: true });
|
|
370
507
|
const autoInject = isAutoInjectEnabled();
|
|
371
508
|
const identity = readIdentity();
|
|
372
509
|
const interval = saveInterval();
|
|
373
510
|
log(`loaded (autoInjectContext: ${autoInject}, saveInterval: ${interval})`);
|
|
511
|
+
// Catch anything missed by a previous run (e.g. content skipped when
|
|
512
|
+
// the exit budget ran out). Fires once per server lifetime.
|
|
513
|
+
setTimeout(() => dbSync(), 10000);
|
|
374
514
|
// Crash safety: best-effort synchronous save on hard exit.
|
|
375
515
|
// Mirrors the official emergency-save intent (nothing async allowed here).
|
|
376
516
|
let exitHandled = false;
|
|
@@ -395,6 +535,10 @@ export default (async () => {
|
|
|
395
535
|
// Official Save-hook cadence: count human messages per session,
|
|
396
536
|
// persist like ~/.mempalace/hook_state/, arm ONE AI checkpoint
|
|
397
537
|
// per boundary. The model decides WHAT to file.
|
|
538
|
+
// NOTE: no mine here by design (see PR #1524 review) — mining a
|
|
539
|
+
// mid-reply snapshot would export partial assistant parts. Mines
|
|
540
|
+
// run on idle/exit/startup, when turns are complete; the export
|
|
541
|
+
// additionally skips unfinished replies (finish tracking).
|
|
398
542
|
const counters = loadCounters();
|
|
399
543
|
const c = counters[sessionID] || { humanMsgs: 0, lastCheckpoint: 0 };
|
|
400
544
|
c.humanMsgs += 1;
|
|
@@ -403,8 +547,6 @@ export default (async () => {
|
|
|
403
547
|
c.lastCheckpoint = boundary;
|
|
404
548
|
pendingCheckpoint = { sessionID, count: c.humanMsgs };
|
|
405
549
|
hookLog(`session ${sessionID}: ${c.humanMsgs} human msgs — checkpoint armed`);
|
|
406
|
-
log("threshold crossed - queue sync");
|
|
407
|
-
setTimeout(() => dbSync(), 500);
|
|
408
550
|
}
|
|
409
551
|
counters[sessionID] = c;
|
|
410
552
|
persistCounters(counters);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-mempalace-persistence",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "OpenCode plugin — auto-sync conversations to MemPalace memory in real-time. No forced wings, KG extraction via MCP tools.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|