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 +80 -80
- package/dist/index.d.ts +7 -1
- package/dist/index.js +269 -50
- package/package.json +3 -2
- package/skills/mempalace-recall/SKILL.md +68 -0
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
|
---
|
|
@@ -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
|
|
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
|
|
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
|
-
##
|
|
100
|
+
## Recall (usually already covered)
|
|
88
101
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
107
|
+
## Record facts (after responding, only when something new emerged)
|
|
93
108
|
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
- `
|
|
120
|
+
- `mempalace_mempalace_search` (NOT `mempalace_search`)
|
|
121
|
+
- `mempalace_mempalace_kg_query`
|
|
101
122
|
- `mempalace_mempalace_kg_add`
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
-
→
|
|
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 (
|
|
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
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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": (
|
|
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,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-mempalace-persistence",
|
|
3
|
-
"version": "
|
|
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.
|