opencode-mempalace-persistence 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 geco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,214 @@
1
+ # opencode-mempalace-persistence
2
+
3
+ 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.
4
+
5
+ > **It just works** — install, use OpenCode, the plugin handles the rest.
6
+
7
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+
9
+ ---
10
+
11
+ ## How it works in 3 seconds
12
+
13
+ | Without plugin | With plugin |
14
+ |---|---|
15
+ | Every session starts from scratch | The model knows who you are and what you've done |
16
+ | You repeat context each time | Memory is automatic |
17
+ | No Knowledge Graph | Decisions, milestones, problems tracked |
18
+
19
+ The model searches MemPalace on every question (via AGENTS.md + MCP), and the plugin saves every response. A perfect feedback loop.
20
+
21
+ ---
22
+
23
+ ## Installation
24
+
25
+ ### 1. Plugin (saves conversations)
26
+
27
+ ```json
28
+ {
29
+ "plugin": ["opencode-mempalace-persistence"]
30
+ }
31
+ ```
32
+
33
+ Add this line to your `~/.config/opencode/opencode.json` and restart OpenCode.
34
+
35
+ ### 2. Instructions for the model (uses memory)
36
+
37
+ Create `~/.config/opencode/AGENTS.md`:
38
+
39
+ ```markdown
40
+ # Memory & Knowledge instructions
41
+
42
+ Before answering the user, always search your MemPalace memory for relevant context using the MCP tools.
43
+
44
+ 1. **Search MemPalace**: Call `mempalace_search` with the user's question or key topics as query. Get the top 5-10 most relevant memory drawers.
45
+ 2. **Query Knowledge Graph**: Call `mempalace_kg_query` for entity "user". Then filter the returned facts — keep only those whose text contains keywords from the user's question.
46
+ 3. **Use the context**: Incorporate relevant memory and facts into your response, referencing the source naturally when useful.
47
+ ```
48
+
49
+ ### 3. Identity (who you are)
50
+
51
+ Create `~/.mempalace/identity.txt`:
52
+
53
+ ```
54
+ I am [name], a [role]. I work with [technologies]. My main projects are [projects].
55
+ ```
56
+
57
+ This is loaded automatically at session start via `instructions` in opencode.json.
58
+
59
+ ### 4. Complete configuration
60
+
61
+ `~/.config/opencode/opencode.json`:
62
+
63
+ ```json
64
+ {
65
+ "model": "opencode/deepseek-v4-flash-free",
66
+ "instructions": ["AGENTS.md", "~/.mempalace/identity.txt"],
67
+ "plugin": ["opencode-mempalace-persistence"],
68
+ "mcp": {
69
+ "mempalace": {
70
+ "type": "local",
71
+ "command": ["mempalace-mcp"],
72
+ "enabled": true
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ ### 5. MemPalace (if not already installed)
79
+
80
+ ```bash
81
+ # Install
82
+ pipx install mempalace
83
+
84
+ # Create palace
85
+ mempalace init ~/opencode-memory
86
+
87
+ # Configure MCP
88
+ mempalace mcp
89
+ ```
90
+
91
+ The `mempalace mcp` command gives you the exact MCP setup string for your configuration.
92
+
93
+ ---
94
+
95
+ ## What happens after installation
96
+
97
+ ```
98
+ You ask a question
99
+ → AGENTS.md tells the model: "search MemPalace first"
100
+ → The model calls mempalace_search("question") via MCP
101
+ → Finds relevant memories → gives a better answer
102
+
103
+ The model responds
104
+ → The opencode-mempalace-persistence plugin detects the response is complete
105
+ → Saves the conversation to MemPalace
106
+ → Extracts Knowledge Graph facts
107
+
108
+ Next time you ask
109
+ → The model finds the previous memory → coherent responses
110
+ → The cycle continues, memory grows
111
+ ```
112
+
113
+ ---
114
+
115
+ ## What gets saved
116
+
117
+ Every turn (question + answer):
118
+
119
+ - **Text** categorized by wing (developer, creative, emotions, family, consciousness)
120
+ - **Knowledge Graph**: automatically extracted facts
121
+ - `decision` → "decided to use TypeScript"
122
+ - `milestone` → "backend deploy completed"
123
+ - `problem` → "chromadb ModuleNotFoundError"
124
+ - `preference` → "prefer Svelte over React"
125
+ - `emotional` → "frustrated with Docker compose"
126
+
127
+ ---
128
+
129
+ ## Architecture
130
+
131
+ ```
132
+ ┌──────────────────────────┐
133
+ │ OpenCode │
134
+ │ │
135
+ User msg ─────►│ chat.message hook │
136
+ │ ↓ │
137
+ │ Query OpenCode DB │
138
+ │ (messages since lastSync)│
139
+ │ ↓ │
140
+ │ Categorize by wing │
141
+ │ ↓ │
142
+ │ Export delta → tmp │
143
+ │ ↓ │
144
+ │ Save state immediately │
145
+ │ ↓ │
146
+ │ mempalace mine (async) │ ← non-blocking
147
+ │ ↓ │
148
+ │ Extract KG facts │
149
+ │ ↓ │
150
+ Session idle ─►│ session.idle hook │
151
+ │ (saves last turn) │
152
+ └──────────────────────────┘
153
+
154
+
155
+ ┌──────────────────────────┐
156
+ │ MemPalace │
157
+ │ ~/opencode-memory/ │
158
+ │ Vector DB + KG SQLite │
159
+ └──────────────────────────┘
160
+
161
+
162
+ ┌──────────────────────────┐
163
+ │ AGENTS.md + MCP │
164
+ │ The model searches │
165
+ │ MemPalace on every │
166
+ │ question │
167
+ └──────────────────────────┘
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Relevant files
173
+
174
+ | File | Purpose |
175
+ |---|---|
176
+ | `~/.config/opencode/opencode.json` | OpenCode config with plugin + MCP + instructions |
177
+ | `~/.config/opencode/AGENTS.md` | Tells the model to search MemPalace |
178
+ | `~/.mempalace/identity.txt` | Your identity (loaded every session) |
179
+ | `~/.mempalace/config.json` | MemPalace config (palace path, wings, keywords) |
180
+ | `~/.mempalace/knowledge_graph.sqlite3` | Knowledge Graph (structured facts) |
181
+ | `~/opencode-memory/` | MemPalace vector DB (all drawers) |
182
+ | `~/.mempalace/sync_state.json` | Last sync state (plugin + Python script) |
183
+
184
+ ---
185
+
186
+ ## Install from npm
187
+
188
+ ```json
189
+ {
190
+ "plugin": ["opencode-mempalace-persistence"]
191
+ }
192
+ ```
193
+
194
+ ## Local development
195
+
196
+ ```json
197
+ {
198
+ "plugin": ["/path/to/opencode-mempalace-persistence/dist/index.js"]
199
+ }
200
+ ```
201
+
202
+ ## Debug logging
203
+
204
+ ```bash
205
+ export OPENCODE_MEMPALACE_DEBUG=1
206
+ ```
207
+
208
+ When set, the plugin writes a debug log to `/tmp/opencode-mempalace.log`.
209
+
210
+ ---
211
+
212
+ ## License
213
+
214
+ MIT
@@ -0,0 +1,17 @@
1
+ declare const _default: () => Promise<{
2
+ "chat.message": (_input: {
3
+ sessionID: string;
4
+ agent?: string;
5
+ model?: {
6
+ providerID: string;
7
+ modelID: string;
8
+ };
9
+ messageID?: string;
10
+ variant?: string;
11
+ }, output: {
12
+ message: import("@opencode-ai/sdk").UserMessage;
13
+ parts: import("@opencode-ai/sdk").Part[];
14
+ }) => Promise<void>;
15
+ event: ({ event }: any) => Promise<void>;
16
+ }>;
17
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,273 @@
1
+ import { execSync, exec } from "child_process";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, rmdirSync, unlinkSync, appendFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { createHash } from "crypto";
6
+ const HOME = homedir();
7
+ const VENV_PYTHON = join(HOME, ".local/share/pipx/venvs/mempalace/bin/python3");
8
+ const MEMPALACE_BIN = join(HOME, ".local/bin/mempalace");
9
+ const OPENCODE_DB = join(HOME, ".local/share/opencode/opencode.db");
10
+ const STATE_FILE = join(HOME, ".mempalace/sync_state.json");
11
+ const KG_DB = join(HOME, ".mempalace/knowledge_graph.sqlite3");
12
+ const OUT_DIR = "/tmp/oc-sessions";
13
+ const TMP_SCRIPT = "/tmp/oc-plugin-query.py";
14
+ const DEBUG = !!process.env.OPENCODE_MEMPALACE_DEBUG;
15
+ const LOG_FILE = "/tmp/opencode-mempalace.log";
16
+ function log(msg) {
17
+ if (!DEBUG)
18
+ return;
19
+ const ts = new Date().toISOString();
20
+ try {
21
+ appendFileSync(LOG_FILE, `[${ts}] ${msg}\n`);
22
+ }
23
+ catch { }
24
+ }
25
+ let pendingSync = false;
26
+ let lastSyncTs = 0;
27
+ function runPython(code) {
28
+ writeFileSync(TMP_SCRIPT, code);
29
+ try {
30
+ return execSync(`${VENV_PYTHON} ${TMP_SCRIPT}`, { encoding: "utf-8", timeout: 30000 }).trim();
31
+ }
32
+ finally {
33
+ unlinkSync(TMP_SCRIPT);
34
+ }
35
+ }
36
+ const WING_KEYWORDS = {
37
+ creative: ["eurovision", "music", "art", "film", "game", "festival", "song", "concert"],
38
+ family: ["family", "kid", "son", "daughter", "wife", "husband", "parent", "mother", "father"],
39
+ emotions: ["feel", "happy", "sad", "love", "hate", "angry", "fear", "anxiety", "grateful"],
40
+ consciousness: ["think", "mind", "conscious", "self", "identity", "exist", "meaning"],
41
+ };
42
+ const KG_PATTERNS = {
43
+ decision: ["ho deciso", "ho scelto", "implementato", "added ", "ho aggiunto", "decided", "chosen", "created", "creato", "modified"],
44
+ milestone: ["completato", "finito", "rilasciato", "deployato", "ho completato", "successo", "funziona", "completed", "done", "deployed"],
45
+ problem: ["problema", "bug", "errore", "issue", "fix", "fixato", "risolto", "solved", "fixed", "error"],
46
+ preference: ["preferisco", "mi piace", "non mi piace", "prefer", "like to", "better"],
47
+ emotional: ["sentimento", "emozione", "frustrato", "contento", "happy", "sad", "feel", "emotion"],
48
+ };
49
+ function categorize(text) {
50
+ if (!text)
51
+ return "developer";
52
+ const lower = text.toLowerCase();
53
+ for (const [wing, keywords] of Object.entries(WING_KEYWORDS)) {
54
+ if (keywords.some((kw) => lower.includes(kw)))
55
+ return wing;
56
+ }
57
+ return "developer";
58
+ }
59
+ function hasText(parts) {
60
+ return parts
61
+ .filter((p) => p?.type === "text" && p?.text?.trim())
62
+ .map((p) => p.text.trim())
63
+ .join("\n");
64
+ }
65
+ function getLastSync() {
66
+ if (!existsSync(STATE_FILE))
67
+ return 0;
68
+ try {
69
+ return JSON.parse(readFileSync(STATE_FILE, "utf-8")).last_sync_ms || 0;
70
+ }
71
+ catch {
72
+ return 0;
73
+ }
74
+ }
75
+ function dbSync() {
76
+ if (pendingSync)
77
+ return;
78
+ pendingSync = true;
79
+ try {
80
+ doDbSync();
81
+ }
82
+ catch (e) {
83
+ log("sync err: " + String(e));
84
+ }
85
+ }
86
+ function doDbSync() {
87
+ if (lastSyncTs && Date.now() - lastSyncTs < 5000)
88
+ return;
89
+ const lastSync = getLastSync();
90
+ const sessions = runPython(`
91
+ import sqlite3, json
92
+ db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
93
+ rows = db.execute("""
94
+ SELECT DISTINCT s.id, s.title, p.worktree, s.directory, s.slug, s.time_created
95
+ FROM session s
96
+ LEFT JOIN project p ON s.project_id = p.id
97
+ INNER JOIN message m ON m.session_id = s.id
98
+ WHERE m.time_created > ${lastSync}
99
+ ORDER BY s.time_created
100
+ """).fetchall()
101
+ db.close()
102
+ print(json.dumps(rows))
103
+ `);
104
+ let sessionsArr;
105
+ try {
106
+ sessionsArr = JSON.parse(sessions);
107
+ }
108
+ catch {
109
+ pendingSync = false;
110
+ return;
111
+ }
112
+ if (!sessionsArr || sessionsArr.length === 0) {
113
+ pendingSync = false;
114
+ return;
115
+ }
116
+ const now = Date.now();
117
+ const exported = [];
118
+ for (const sess of sessionsArr) {
119
+ const [sessId, title, worktree, directory] = sess;
120
+ const label = (title || "").replace(/[^a-zA-Z0-9 _-]/g, "_") || (sessId || "").slice(0, 12);
121
+ const prefix = `${new Date().toISOString().slice(0, 10)}_${label.slice(0, 30)}_${(sessId || "").slice(0, 8)}`;
122
+ const msgs = runPython(`
123
+ import sqlite3, json
124
+ db = sqlite3.connect(${JSON.stringify(OPENCODE_DB)})
125
+ rows = db.execute("""
126
+ SELECT m.id, m.time_created, m.data FROM message m
127
+ WHERE m.session_id = ${JSON.stringify(sessId)} AND m.time_created > ${lastSync}
128
+ ORDER BY m.time_created
129
+ """).fetchall()
130
+ texts = []
131
+ for (mid, mts, mdata_raw) in rows:
132
+ try: mdata = json.loads(mdata_raw)
133
+ except: mdata = {}
134
+ role = mdata.get("role", "unknown")
135
+ for (pdata_raw,) in db.execute("SELECT data FROM part WHERE message_id = ? ORDER BY time_created", (mid,)).fetchall():
136
+ try:
137
+ pdata = json.loads(pdata_raw)
138
+ if pdata.get("type") == "text" and pdata.get("text","").strip():
139
+ texts.append({"role": role, "text": pdata.get("text").strip(), "ts": mts})
140
+ except: pass
141
+ db.close()
142
+ print(json.dumps(texts))
143
+ `);
144
+ let msgList;
145
+ try {
146
+ msgList = JSON.parse(msgs);
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ if (msgList.length < 2)
152
+ continue;
153
+ const lines = [
154
+ `# ${title || label}`,
155
+ `Date: ${new Date().toISOString().slice(0, 10)}`,
156
+ `Session: ${sessId}`,
157
+ "",
158
+ ];
159
+ for (const m of msgList) {
160
+ const ts = m.ts ? new Date(m.ts).toISOString().slice(11, 19) : "";
161
+ lines.push(`## ${m.role.toUpperCase()} — ${ts}`);
162
+ lines.push("");
163
+ lines.push(m.text);
164
+ lines.push("");
165
+ }
166
+ const content = lines.join("\n").trim();
167
+ if (!content)
168
+ continue;
169
+ const wing = categorize(content);
170
+ const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 12);
171
+ const fname = `sync_${prefix}_${contentHash}.txt`;
172
+ const wingDir = join(OUT_DIR, wing);
173
+ mkdirSync(wingDir, { recursive: true });
174
+ writeFileSync(join(wingDir, fname), content + "\n");
175
+ exported.push({ wing, fname, dir: wingDir });
176
+ }
177
+ if (exported.length === 0) {
178
+ pendingSync = false;
179
+ return;
180
+ }
181
+ // Save state immediately so next sync doesn't re-process
182
+ writeFileSync(STATE_FILE, JSON.stringify({ last_sync_ms: now }));
183
+ lastSyncTs = Date.now();
184
+ pendingSync = false;
185
+ log(`queued ${exported.length} files for mining`);
186
+ // Mining async (fire-and-forget, non-blocking)
187
+ for (const { wing, fname, dir: wingDir } of exported) {
188
+ const filePath = join(wingDir, fname);
189
+ exec(`${MEMPALACE_BIN} mine ${wingDir} --mode convos --extract general --wing ${wing}`, {
190
+ encoding: "utf-8",
191
+ timeout: 120000,
192
+ }, (err) => {
193
+ if (err) {
194
+ log(`mine err ${wing}: ${err.message}`);
195
+ return;
196
+ }
197
+ rmSync(filePath);
198
+ try {
199
+ rmdirSync(wingDir);
200
+ }
201
+ catch { }
202
+ log(`mined ${wing}`);
203
+ // KG extraction after mining
204
+ try {
205
+ const raw = runPython(`
206
+ from mempalace.config import MempalaceConfig
207
+ import chromadb, json, re
208
+ c = MempalaceConfig()
209
+ col = chromadb.PersistentClient(path=c.palace_path).get_collection(c.collection_name)
210
+ d = col.get(limit=5000, include=["metadatas","documents"])
211
+ p = ${JSON.stringify(KG_PATTERNS)}
212
+ r = []
213
+ for i,m,doc in zip(d.get("ids",[]), d.get("metadatas",[]), d.get("documents",[])):
214
+ if not m or not doc: continue
215
+ rm = m.get("room","")
216
+ if rm not in ("decision","milestone","problem","preference","emotional"): continue
217
+ da = doc.split("Date: ")[1][:10] if "Date: " in doc else ""
218
+ if not da: continue
219
+ if not any(pat in doc.lower() for pat in p.get(rm,[])): continue
220
+ for ln in doc.strip().split("\\n")[:5]:
221
+ l = ln.strip()
222
+ if l and not l.startswith("#") and len(l) > 20 and len(re.findall(r"[^a-zA-Z0-9\\s]", l))/len(l) <= 0.3:
223
+ r.append([da, rm, l[:120]]); break
224
+ print(json.dumps(r))
225
+ `);
226
+ const nf = JSON.parse(raw) || [];
227
+ if (nf.length > 0) {
228
+ const ex = runPython(`import sqlite3,json;d=sqlite3.connect(${JSON.stringify(KG_DB)});r=d.execute("SELECT predicate,object FROM triples WHERE subject='user'").fetchall();d.close();print(json.dumps(r))`);
229
+ const seen = new Set();
230
+ try {
231
+ for (const [p, o] of JSON.parse(ex))
232
+ seen.add(p + "::" + o);
233
+ }
234
+ catch { }
235
+ const ins = [];
236
+ for (const [d, t, x] of nf) {
237
+ const k = t + "::" + x;
238
+ if (seen.has(k))
239
+ continue;
240
+ seen.add(k);
241
+ ins.push(`INSERT OR IGNORE INTO triples(id,subject,predicate,object,valid_from,confidence,extracted_at) VALUES('t_user_${t}_${createHash("sha256").update(x).digest("hex").slice(0, 12)}','user','${t}','${x.replace(/'/g, "''")}','${d}',1.0,datetime('now'))`);
242
+ }
243
+ if (ins.length > 0) {
244
+ runPython(`import sqlite3;db=sqlite3.connect(${JSON.stringify(KG_DB)});[db.execute(s) for s in ${JSON.stringify(ins)}];db.commit();db.close()`);
245
+ }
246
+ }
247
+ }
248
+ catch { }
249
+ });
250
+ }
251
+ }
252
+ export default (async () => {
253
+ mkdirSync(OUT_DIR, { recursive: true });
254
+ log("loaded");
255
+ return {
256
+ "chat.message": async (_input, output) => {
257
+ const role = output.message.role;
258
+ if (role !== "user")
259
+ return;
260
+ const text = hasText(output.parts || []);
261
+ if (!text)
262
+ return;
263
+ log("user msg - queue sync");
264
+ setTimeout(() => dbSync(), 500);
265
+ },
266
+ event: async ({ event }) => {
267
+ if (event?.type !== "session.idle")
268
+ return;
269
+ log("idle - queue sync");
270
+ setTimeout(() => dbSync(), 3000);
271
+ },
272
+ };
273
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "opencode-mempalace-persistence",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugin — auto-sync conversations to MemPalace memory in real-time with auto-categorization and Knowledge Graph",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": ["dist"],
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "prepublishOnly": "npm run build"
11
+ },
12
+ "keywords": ["opencode", "mempalace", "memory", "opencode-plugin", "typescript"],
13
+ "author": "geco",
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/geco/opencode-mempalace.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/geco/opencode-mempalace/issues"
22
+ },
23
+ "homepage": "https://github.com/geco/opencode-mempalace#readme",
24
+ "devDependencies": {
25
+ "@opencode-ai/plugin": "^1.15.0",
26
+ "@types/node": "^25.8.0",
27
+ "typescript": "^6.0.3"
28
+ }
29
+ }