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 +57 -17
- package/dist/index.d.ts +7 -1
- package/dist/index.js +269 -50
- package/package.json +1 -1
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, ~
|
|
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
|
[](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
|
|
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
|
-
→
|
|
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
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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": (
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 > ${
|
|
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
|
|
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 > ${
|
|
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(
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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": "
|
|
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",
|