@zilliz/memsearch-opencode 0.1.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 ADDED
@@ -0,0 +1,137 @@
1
+ # memsearch OpenCode Plugin
2
+
3
+ Semantic memory search for [OpenCode](https://github.com/anomalyco/opencode) — gives your AI assistant persistent memory across sessions with zero user intervention.
4
+
5
+ ## Features
6
+
7
+ - **Auto-capture**: Summarizes each conversation turn and saves to daily `.md` files
8
+ - **Semantic search**: Hybrid search (BM25 + dense vectors + RRF) via Milvus
9
+ - **Three-layer recall**: Search → Expand → Transcript (progressive detail)
10
+ - **Cold-start context**: Injects recent memories into new sessions automatically
11
+ - **Per-project isolation**: Each project gets its own Milvus collection
12
+ - **ONNX embeddings**: CPU-only bge-m3 model, no API key required
13
+
14
+ ## Quick Start
15
+
16
+ ### Prerequisites
17
+
18
+ ```bash
19
+ # Install memsearch with ONNX embeddings
20
+ uv tool install 'memsearch[onnx]'
21
+ # or: pip install 'memsearch[onnx]'
22
+ ```
23
+
24
+ ### Install
25
+
26
+ ```bash
27
+ # Clone the repo (if you haven't already)
28
+ git clone https://github.com/zc277584121/memsearch.git
29
+ cd memsearch
30
+
31
+ # Run the installer
32
+ bash plugins/opencode/install.sh
33
+ ```
34
+
35
+ ### Manual Install
36
+
37
+ ```bash
38
+ # 1. Symlink the plugin
39
+ mkdir -p ~/.config/opencode/plugins
40
+ ln -sf /path/to/memsearch/plugins/opencode/index.ts ~/.config/opencode/plugins/memsearch.ts
41
+
42
+ # 2. Symlink the skill (optional, for !memory-recall)
43
+ mkdir -p ~/.agents/skills
44
+ ln -sf /path/to/memsearch/plugins/opencode/skills/memory-recall ~/.agents/skills/memory-recall
45
+ ```
46
+
47
+ ### npm Install (coming soon)
48
+
49
+ ```json
50
+ // In ~/.config/opencode/opencode.json
51
+ {
52
+ "plugin": ["memsearch-opencode"]
53
+ }
54
+ ```
55
+
56
+ ## Architecture
57
+
58
+ ```
59
+ OpenCode Session
60
+ ├── chat.message hook ──→ Detect turn completion
61
+ │ │
62
+ │ ├── Extract last turn from SQLite
63
+ │ ├── Summarize via LLM (third-person notes)
64
+ │ └── Append to .memsearch/memory/YYYY-MM-DD.md
65
+ │ │
66
+ │ └── memsearch index (background)
67
+
68
+ ├── system.transform hook ──→ Inject recent memories
69
+
70
+ └── Tools
71
+ ├── memory_search ──→ memsearch search (hybrid BM25+dense)
72
+ ├── memory_get ──→ memsearch expand (full context)
73
+ └── memory_transcript ──→ parse-transcript.py (SQLite reader)
74
+ ```
75
+
76
+ ## Tools
77
+
78
+ | Tool | Description |
79
+ |------|-------------|
80
+ | `memory_search` | Semantic search over past memories. Returns ranked chunks. |
81
+ | `memory_get` | Expand a chunk hash to see the full markdown section. |
82
+ | `memory_transcript` | Read original conversation from OpenCode SQLite DB. |
83
+
84
+ ## Memory Files
85
+
86
+ Memory is stored as markdown in `<project>/.memsearch/memory/`:
87
+
88
+ ```
89
+ .memsearch/
90
+ └── memory/
91
+ ├── 2026-03-25.md
92
+ └── 2026-03-26.md
93
+ ```
94
+
95
+ Each file contains timestamped entries with bullet-point summaries:
96
+
97
+ ```markdown
98
+ # 2026-03-26
99
+
100
+ ## Session 14:30
101
+
102
+ ### 14:30
103
+ <!-- session:ses_abc123 source:opencode-sqlite -->
104
+ - User asked about the authentication flow.
105
+ - Assistant explained the OAuth2 implementation in auth.ts.
106
+ - Assistant modified the token refresh logic in refresh.ts.
107
+ ```
108
+
109
+ ## Configuration
110
+
111
+ The plugin uses ONNX embeddings by default (no API key needed). To use a different provider:
112
+
113
+ ```bash
114
+ memsearch config set embedding.provider openai
115
+ # Set the API key in your environment
116
+ export OPENAI_API_KEY=sk-...
117
+ ```
118
+
119
+ ## How It Works
120
+
121
+ 1. **Capture**: After each conversation turn, the plugin extracts the user+assistant exchange, summarizes it via LLM, and appends to a daily markdown file.
122
+
123
+ 2. **Index**: The markdown files are indexed by memsearch into a Milvus collection (Milvus Lite by default, runs in-process).
124
+
125
+ 3. **Recall**: When the assistant needs historical context, it calls `memory_search` to find relevant chunks. Results can be expanded with `memory_get` or drilled into with `memory_transcript`.
126
+
127
+ 4. **Cold-start**: At session start, recent memory bullets are injected into the system prompt so the assistant has immediate context.
128
+
129
+ ## Differences from Other Plugins
130
+
131
+ | Feature | Claude Code | OpenCode | OpenClaw |
132
+ |---------|-------------|----------|----------|
133
+ | Session storage | JSONL | SQLite | JSONL |
134
+ | Hook system | Shell scripts | TypeScript hooks | JS API |
135
+ | Summarizer | claude -p --model haiku | opencode prompt | openclaw agent |
136
+ | Context injection | SessionStart hook | system.transform | before_agent_start |
137
+ | Skill context | context: fork | N/A (no fork) | N/A |
package/index.ts ADDED
@@ -0,0 +1,327 @@
1
+ /**
2
+ * memsearch OpenCode plugin — semantic memory search across sessions.
3
+ *
4
+ * Registers:
5
+ * - memory_search tool: semantic search over past memories
6
+ * - memory_get tool: expand a chunk to full context
7
+ * - memory_transcript tool: parse original conversation from OpenCode SQLite
8
+ * - experimental.chat.system.transform hook: inject recent memories as context
9
+ *
10
+ * Auto-capture is handled by a background Python daemon (capture-daemon.py)
11
+ * that polls the OpenCode SQLite database for completed turns.
12
+ */
13
+
14
+ import type { Plugin } from "@opencode-ai/plugin";
15
+ import { tool } from "@opencode-ai/plugin";
16
+ import { execSync, exec, spawnSync } from "node:child_process";
17
+ import {
18
+ readFileSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ } from "node:fs";
23
+ import { join, dirname } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Detect the memsearch CLI command.
34
+ * Checks: PATH -> ~/.local/bin/uvx -> uvx in PATH.
35
+ */
36
+ function detectMemsearchCmd(): string {
37
+ const home = process.env.HOME || "";
38
+
39
+ try {
40
+ const r = spawnSync("which", ["memsearch"], { stdio: "pipe" });
41
+ if (r.status === 0) return "memsearch";
42
+ } catch { /* ignore */ }
43
+
44
+ const uvxPath = join(home, ".local", "bin", "uvx");
45
+ if (existsSync(uvxPath)) {
46
+ return `${uvxPath} --from 'memsearch[onnx]' memsearch`;
47
+ }
48
+
49
+ try {
50
+ const r = spawnSync("which", ["uvx"], { stdio: "pipe" });
51
+ if (r.status === 0) return "uvx --from 'memsearch[onnx]' memsearch";
52
+ } catch { /* ignore */ }
53
+
54
+ return "memsearch";
55
+ }
56
+
57
+ /** Derive a per-project Milvus collection name via the shared script. */
58
+ function deriveCollectionName(projectDir: string): string {
59
+ const script = join(PLUGIN_DIR, "scripts", "derive-collection.sh");
60
+ try {
61
+ return execSync(`bash "${script}" "${projectDir}"`, {
62
+ encoding: "utf-8",
63
+ timeout: 5000,
64
+ }).trim();
65
+ } catch {
66
+ return "ms_opencode_default";
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Read the tail of the N most recent daily .md files for cold-start context.
72
+ */
73
+ function getRecentMemories(
74
+ memDir: string,
75
+ count = 2,
76
+ tailLines = 15
77
+ ): string {
78
+ if (!existsSync(memDir)) return "";
79
+
80
+ const files = readdirSync(memDir)
81
+ .filter((f) => f.endsWith(".md"))
82
+ .sort()
83
+ .slice(-count);
84
+
85
+ if (files.length === 0) return "";
86
+
87
+ const bullets: string[] = [];
88
+ for (const file of files) {
89
+ try {
90
+ const content = readFileSync(join(memDir, file), "utf-8");
91
+ const lines = content.split("\n").slice(-tailLines);
92
+ const fileBullets = lines.filter((l) => l.startsWith("- ") || l.startsWith("[Human]") || l.startsWith("[Assistant]"));
93
+ if (fileBullets.length > 0) {
94
+ bullets.push(`[${file}]`, ...fileBullets);
95
+ }
96
+ } catch { /* skip */ }
97
+ }
98
+
99
+ if (bullets.length === 0) {
100
+ return `You have ${files.length} past memory file(s). Use the memory_search tool when the user's question could benefit from historical context.`;
101
+ }
102
+
103
+ return `Recent memories (use memory_search for full search):\n${bullets.join("\n")}`;
104
+ }
105
+
106
+ /** Shell-escape a string for safe use inside single quotes. */
107
+ function shellEscape(s: string): string {
108
+ return s.replace(/'/g, "'\\''");
109
+ }
110
+
111
+ /**
112
+ * Start the capture daemon as a background process.
113
+ * The daemon polls OpenCode's SQLite for completed turns and writes to daily .md files.
114
+ */
115
+ function startCaptureDaemon(
116
+ projectDir: string,
117
+ collectionName: string,
118
+ memsearchCmd: string
119
+ ): void {
120
+ const pidFile = join(projectDir, ".memsearch", ".capture.pid");
121
+ const daemonScript = join(PLUGIN_DIR, "scripts", "capture-daemon.py");
122
+
123
+ // Check if daemon is already running
124
+ if (existsSync(pidFile)) {
125
+ try {
126
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
127
+ if (pid > 0) {
128
+ // Check if process is still alive
129
+ try {
130
+ process.kill(pid, 0);
131
+ return; // Already running
132
+ } catch {
133
+ // Process is dead, clean up stale PID file
134
+ }
135
+ }
136
+ } catch { /* ignore */ }
137
+ }
138
+
139
+ // Start daemon in background
140
+ exec(
141
+ `python3 "${daemonScript}" "${projectDir}" "${collectionName}" ` +
142
+ `--memsearch-cmd "${shellEscape(memsearchCmd)}" --poll-interval 10 &`,
143
+ {
144
+ timeout: 5000,
145
+ env: { ...process.env, MEMSEARCH_NO_WATCH: "1" },
146
+ },
147
+ () => { /* ignore */ }
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Stop the capture daemon.
153
+ */
154
+ function stopCaptureDaemon(projectDir: string): void {
155
+ const pidFile = join(projectDir, ".memsearch", ".capture.pid");
156
+ if (existsSync(pidFile)) {
157
+ try {
158
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
159
+ if (pid > 0) {
160
+ try { process.kill(pid, "SIGTERM"); } catch { /* ignore */ }
161
+ }
162
+ } catch { /* ignore */ }
163
+ }
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Plugin entry
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const MemsearchPlugin: Plugin = async ({ project, directory, worktree }) => {
171
+ // worktree can be "/" for global projects — use directory instead
172
+ const projectDir = (worktree && worktree !== "/") ? worktree : (directory || process.cwd());
173
+ const memsearchCmd = detectMemsearchCmd();
174
+ const collectionName = deriveCollectionName(projectDir);
175
+ const memsearchDir = join(projectDir, ".memsearch");
176
+ const memoryDir = join(memsearchDir, "memory");
177
+ const home = process.env.HOME || "~";
178
+
179
+ // Skip capture/recall in child processes to prevent recursion
180
+ const isChildProcess = !!process.env.MEMSEARCH_NO_WATCH;
181
+ const autoCapture = !isChildProcess;
182
+ const autoRecall = !isChildProcess;
183
+
184
+ // Ensure default config (onnx provider) at startup
185
+ try {
186
+ const configFile = join(home, ".memsearch", "config.toml");
187
+ const localConfig = join(projectDir, ".memsearch.toml");
188
+ if (!existsSync(configFile) && !existsSync(localConfig)) {
189
+ try {
190
+ execSync(`${memsearchCmd} config set embedding.provider onnx`, {
191
+ timeout: 5000,
192
+ stdio: "ignore",
193
+ });
194
+ } catch { /* ignore */ }
195
+ }
196
+ } catch { /* ignore */ }
197
+
198
+ // Run initial index in background
199
+ if (existsSync(memoryDir)) {
200
+ exec(
201
+ `${memsearchCmd} index '${shellEscape(memoryDir)}' ` +
202
+ `--collection ${collectionName}`,
203
+ { timeout: 120000 },
204
+ () => { /* ignore */ }
205
+ );
206
+ }
207
+
208
+ // Start capture daemon for auto-capture
209
+ if (autoCapture) {
210
+ startCaptureDaemon(projectDir, collectionName, memsearchCmd);
211
+ }
212
+
213
+ return {
214
+ // ----- Tools -----
215
+ tool: {
216
+ memory_search: tool({
217
+ description:
218
+ "Search past conversation memories using memsearch semantic search. " +
219
+ "Returns relevant chunks from past sessions, including dates, " +
220
+ "topics discussed, and code referenced. Powered by Milvus hybrid " +
221
+ "search (BM25 + dense vectors + RRF reranking).",
222
+ args: {
223
+ query: tool.schema.string().describe("Search query — describe what you want to find"),
224
+ top_k: tool.schema.number().optional().describe("Number of results to return (default: 5)"),
225
+ },
226
+ async execute(args, context) {
227
+ // Use context.directory for the actual session directory (may differ from init)
228
+ const dir = context?.directory || projectDir;
229
+ const col = dir !== projectDir ? deriveCollectionName(dir) : collectionName;
230
+ const memDir = join(dir, ".memsearch", "memory");
231
+ // Ensure daemon is running for current directory
232
+ if (autoCapture) startCaptureDaemon(dir, col, memsearchCmd);
233
+ const topK = args.top_k || 5;
234
+ try {
235
+ const result = spawnSync(
236
+ "bash",
237
+ [
238
+ "-c",
239
+ `${memsearchCmd} search '${shellEscape(args.query)}' ` +
240
+ `--top-k ${topK} --json-output --collection ${col}`,
241
+ ],
242
+ { encoding: "utf-8", timeout: 30000 }
243
+ );
244
+ return result.stdout || result.stderr || "No results found.";
245
+ } catch (e: any) {
246
+ return `Search failed: ${e.message}`;
247
+ }
248
+ },
249
+ }),
250
+
251
+ memory_get: tool({
252
+ description:
253
+ "Expand a memory chunk to see the full markdown section with " +
254
+ "surrounding context. Use after memory_search to get details " +
255
+ "about a specific result.",
256
+ args: {
257
+ chunk_hash: tool.schema.string().describe("The chunk_hash from a search result to expand"),
258
+ },
259
+ async execute(args, context) {
260
+ const dir = context?.directory || projectDir;
261
+ const col = dir !== projectDir ? deriveCollectionName(dir) : collectionName;
262
+ if (autoCapture) startCaptureDaemon(dir, col, memsearchCmd);
263
+ try {
264
+ const result = spawnSync(
265
+ "bash",
266
+ [
267
+ "-c",
268
+ `${memsearchCmd} expand '${shellEscape(args.chunk_hash)}' ` +
269
+ `--collection ${col}`,
270
+ ],
271
+ { encoding: "utf-8", timeout: 15000 }
272
+ );
273
+ return result.stdout || result.stderr || "No content found.";
274
+ } catch (e: any) {
275
+ return `Expand failed: ${e.message}`;
276
+ }
277
+ },
278
+ }),
279
+
280
+ memory_transcript: tool({
281
+ description:
282
+ "Retrieve the original conversation from a past OpenCode session. " +
283
+ "Use after memory_get when the expanded result contains a session anchor " +
284
+ "(<!-- session:ID source:opencode-sqlite -->). Returns the formatted " +
285
+ "dialogue with [Human] and [Assistant] labels.",
286
+ args: {
287
+ session_id: tool.schema.string().describe("The session ID from the anchor comment"),
288
+ limit: tool.schema.number().optional().describe("Max number of messages to return (default: 20)"),
289
+ },
290
+ async execute(args, context) {
291
+ const dir = context?.directory || projectDir;
292
+ const col = dir !== projectDir ? deriveCollectionName(dir) : collectionName;
293
+ if (autoCapture) startCaptureDaemon(dir, col, memsearchCmd);
294
+ try {
295
+ const scriptPath = join(PLUGIN_DIR, "scripts", "parse-transcript.py");
296
+ const result = spawnSync(
297
+ "python3",
298
+ [scriptPath, args.session_id, ...(args.limit ? ["--limit", String(args.limit)] : [])],
299
+ { encoding: "utf-8", timeout: 15000 }
300
+ );
301
+ return result.stdout?.trim() || result.stderr || "No transcript content found.";
302
+ } catch (e: any) {
303
+ return `Transcript parse failed: ${e.message}`;
304
+ }
305
+ },
306
+ }),
307
+ },
308
+
309
+ // ----- Hook: system prompt transform — inject recent memories -----
310
+ ...(autoRecall
311
+ ? {
312
+ "experimental.chat.system.transform": async (_input: any, output: any) => {
313
+ try {
314
+ const context = getRecentMemories(memoryDir);
315
+ if (context) {
316
+ output.system.push(
317
+ `[memsearch] Memory available. You have access to memory_search, memory_get, and memory_transcript tools for recalling past sessions.\n\n${context}`
318
+ );
319
+ }
320
+ } catch { /* ignore */ }
321
+ },
322
+ }
323
+ : {}),
324
+ };
325
+ };
326
+
327
+ export default MemsearchPlugin;
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@zilliz/memsearch-opencode",
3
+ "version": "0.1.0",
4
+ "description": "memsearch plugin for OpenCode — semantic memory search across sessions",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "scripts/",
13
+ "skills/",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "memsearch",
20
+ "memory",
21
+ "semantic-search",
22
+ "milvus"
23
+ ],
24
+ "author": "memsearch contributors",
25
+ "license": "MIT",
26
+ "peerDependencies": {
27
+ "@opencode-ai/plugin": ">=1.0.0"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/zilliztech/memsearch",
32
+ "directory": "plugins/opencode"
33
+ },
34
+ "devDependencies": {
35
+ "@opencode-ai/plugin": "^1.3.2"
36
+ }
37
+ }
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env python3
2
+ """Background daemon that watches OpenCode SQLite for completed turns and captures them.
3
+
4
+ Usage:
5
+ capture-daemon.py <project_dir> <collection_name> [--memsearch-cmd CMD]
6
+
7
+ The daemon polls the OpenCode SQLite database for new completed messages,
8
+ extracts the last turn, summarizes it as bullet points, and writes to
9
+ <project_dir>/.memsearch/memory/YYYY-MM-DD.md.
10
+
11
+ It also triggers memsearch indexing after writing.
12
+ """
13
+
14
+ import sqlite3
15
+ import json
16
+ import sys
17
+ import os
18
+ import time
19
+ import shutil
20
+ import subprocess
21
+ import argparse
22
+ import signal
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+
27
+ def get_small_model():
28
+ """Read small_model from opencode.json config (fallback to model, then empty)."""
29
+ config_paths = [
30
+ os.path.expanduser("~/.config/opencode/opencode.json"),
31
+ "opencode.json",
32
+ ]
33
+ for p in config_paths:
34
+ if os.path.exists(p):
35
+ try:
36
+ with open(p) as f:
37
+ cfg = json.load(f)
38
+ return cfg.get("small_model", cfg.get("model", ""))
39
+ except Exception:
40
+ pass
41
+ return ""
42
+
43
+
44
+ def ensure_isolated_config():
45
+ """Create isolated config dir without plugins/ to prevent recursion."""
46
+ isolated = "/tmp/opencode-memsearch-summarize/opencode"
47
+ os.makedirs(isolated, exist_ok=True)
48
+ # Copy opencode.json (provider config) but NOT plugins/
49
+ src = os.path.expanduser("~/.config/opencode/opencode.json")
50
+ dst = os.path.join(isolated, "opencode.json")
51
+ if os.path.exists(src) and not os.path.exists(dst):
52
+ shutil.copy2(src, dst)
53
+ return os.path.dirname(isolated) # /tmp/opencode-memsearch-summarize
54
+
55
+
56
+ def summarize_with_llm(turn_text, small_model):
57
+ """Summarize using opencode run in isolated env (no plugins -> no recursion)."""
58
+ # Keep the instruction short and put it AFTER the transcript to avoid
59
+ # the LLM treating the instruction itself as the conversation content.
60
+ # Clear delimiters (===) separate the transcript from the task.
61
+ full_prompt = (
62
+ f"===TRANSCRIPT START===\n"
63
+ f"{turn_text}\n"
64
+ f"===TRANSCRIPT END===\n\n"
65
+ f"Summarize the transcript above as 2-6 third-person bullet points (each starting with '- '). "
66
+ f"Write 'User asked/did...' and 'OpenCode replied/did...'. "
67
+ f"Be specific (file names, tools, outcomes). "
68
+ f"Same language as the human. "
69
+ f"Output ONLY bullet points, nothing else."
70
+ )
71
+ isolated_dir = ensure_isolated_config()
72
+
73
+ cmd = ["opencode", "run"]
74
+ if small_model:
75
+ cmd += ["-m", small_model]
76
+ cmd.append(full_prompt)
77
+
78
+ try:
79
+ result = subprocess.run(
80
+ cmd,
81
+ env={
82
+ **os.environ,
83
+ "XDG_CONFIG_HOME": isolated_dir,
84
+ "XDG_DATA_HOME": os.path.join(isolated_dir, "data"),
85
+ "MEMSEARCH_NO_WATCH": "1",
86
+ },
87
+ capture_output=True, text=True, timeout=30,
88
+ )
89
+ output = result.stdout.strip()
90
+ # Extract bullet points (skip any opencode run header lines)
91
+ lines = output.split("\n")
92
+ bullets = [l for l in lines if l.strip().startswith("- ")]
93
+ if bullets:
94
+ return "\n".join(bullets)
95
+ except Exception:
96
+ pass
97
+
98
+ return None # fallback to raw text
99
+
100
+
101
+ def get_db_path():
102
+ """Find the OpenCode SQLite database."""
103
+ default = os.path.expanduser("~/.local/share/opencode/opencode.db")
104
+ if os.path.exists(default):
105
+ return default
106
+ xdg_data = os.environ.get("XDG_DATA_HOME", "")
107
+ if xdg_data:
108
+ alt = os.path.join(xdg_data, "opencode", "opencode.db")
109
+ if os.path.exists(alt):
110
+ return alt
111
+ return default
112
+
113
+
114
+ def get_new_completed_turns(conn, project_dir, last_msg_time):
115
+ """Get new user+assistant pairs from sessions in the project directory.
116
+
117
+ Returns a list of (session_id, turn_text, max_msg_time) tuples for
118
+ turns whose messages are newer than last_msg_time.
119
+ """
120
+ # Find all sessions for this project directory
121
+ sessions = conn.execute(
122
+ """
123
+ SELECT s.id FROM session s
124
+ WHERE s.directory = ?
125
+ ORDER BY s.time_updated DESC
126
+ LIMIT 5
127
+ """,
128
+ (project_dir,),
129
+ ).fetchall()
130
+
131
+ if not sessions:
132
+ # Fallback: match by basename
133
+ sessions = conn.execute(
134
+ """
135
+ SELECT s.id FROM session s
136
+ WHERE s.directory LIKE ?
137
+ ORDER BY s.time_updated DESC
138
+ LIMIT 5
139
+ """,
140
+ (f"%{os.path.basename(project_dir)}%",),
141
+ ).fetchall()
142
+
143
+ results = []
144
+ for (session_id,) in sessions:
145
+ # Get messages newer than last_msg_time, in pairs (user + assistant)
146
+ messages = conn.execute(
147
+ """
148
+ SELECT m.id, m.data, m.time_created
149
+ FROM message m
150
+ WHERE m.session_id = ? AND m.time_created > ?
151
+ ORDER BY m.time_created ASC
152
+ """,
153
+ (session_id, last_msg_time),
154
+ ).fetchall()
155
+
156
+ # Group into user+assistant pairs
157
+ i = 0
158
+ while i < len(messages):
159
+ msg_data = json.loads(messages[i][1])
160
+ role = msg_data.get("role", "")
161
+ msg_time = messages[i][2]
162
+
163
+ if role == "user":
164
+ user_text = _extract_msg_text(conn, messages[i][0], messages[i][1])
165
+ assistant_text = ""
166
+ # Look for following assistant message
167
+ if i + 1 < len(messages):
168
+ next_data = json.loads(messages[i + 1][1])
169
+ if next_data.get("role") == "assistant":
170
+ assistant_text = _extract_msg_text(conn, messages[i + 1][0], messages[i + 1][1])
171
+ msg_time = messages[i + 1][2]
172
+ i += 1
173
+
174
+ if user_text and len(user_text) > 5:
175
+ turn = f"[Human]: {user_text[:2000]}"
176
+ if assistant_text:
177
+ turn += f"\n\n[Assistant]: {assistant_text[:2000]}"
178
+ results.append((session_id, turn, msg_time))
179
+ i += 1
180
+
181
+ return results
182
+
183
+
184
+ def _extract_msg_text(conn, msg_id, msg_json):
185
+ """Extract readable text from a message's parts."""
186
+ parts = conn.execute(
187
+ "SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC",
188
+ (msg_id,),
189
+ ).fetchall()
190
+
191
+ text_parts = []
192
+ for (part_json,) in parts:
193
+ part_data = json.loads(part_json)
194
+ if part_data.get("type") == "text" and part_data.get("text"):
195
+ if not part_data.get("synthetic"):
196
+ text_parts.append(part_data["text"].strip())
197
+ elif part_data.get("type") == "tool" and part_data.get("state", {}).get("status") == "completed":
198
+ tool_name = part_data.get("tool", "unknown")
199
+ tool_input = part_data.get("state", {}).get("input", {})
200
+ hint = ""
201
+ if isinstance(tool_input, dict):
202
+ if "command" in tool_input:
203
+ hint = f" `{tool_input['command'][:80]}`"
204
+ elif "path" in tool_input:
205
+ hint = f" {tool_input['path']}"
206
+ text_parts.append(f"[Tool: {tool_name}{hint}]")
207
+
208
+ return "\n".join(text_parts)
209
+
210
+
211
+ def write_capture(memory_dir, turn_text, session_id):
212
+ """Write a captured turn to the daily memory file."""
213
+ os.makedirs(memory_dir, exist_ok=True)
214
+
215
+ today = datetime.now().strftime("%Y-%m-%d")
216
+ now = datetime.now().strftime("%H:%M")
217
+ memory_file = os.path.join(memory_dir, f"{today}.md")
218
+
219
+ if not os.path.exists(memory_file):
220
+ with open(memory_file, "w") as f:
221
+ f.write(f"# {today}\n\n## Session {now}\n\n")
222
+
223
+ anchor = f"<!-- session:{session_id} source:opencode-sqlite -->\n" if session_id else ""
224
+ entry = f"### {now}\n{anchor}{turn_text}\n\n"
225
+
226
+ with open(memory_file, "a") as f:
227
+ f.write(entry)
228
+
229
+ return memory_file
230
+
231
+
232
+ def main():
233
+ parser = argparse.ArgumentParser(description="Capture daemon for OpenCode sessions")
234
+ parser.add_argument("project_dir", help="Project directory")
235
+ parser.add_argument("collection_name", help="Milvus collection name")
236
+ parser.add_argument("--memsearch-cmd", default="memsearch", help="memsearch command")
237
+ parser.add_argument("--poll-interval", type=int, default=10, help="Poll interval in seconds")
238
+ args = parser.parse_args()
239
+
240
+ db_path = get_db_path()
241
+ if not os.path.exists(db_path):
242
+ print(f"OpenCode database not found at {db_path}", file=sys.stderr)
243
+ sys.exit(1)
244
+
245
+ memory_dir = os.path.join(args.project_dir, ".memsearch", "memory")
246
+ pid_file = os.path.join(args.project_dir, ".memsearch", ".capture.pid")
247
+
248
+ # Write PID file
249
+ os.makedirs(os.path.dirname(pid_file), exist_ok=True)
250
+ with open(pid_file, "w") as f:
251
+ f.write(str(os.getpid()))
252
+
253
+ # Clean up on exit
254
+ def cleanup(signum=None, frame=None):
255
+ try:
256
+ os.remove(pid_file)
257
+ except OSError:
258
+ pass
259
+ sys.exit(0)
260
+
261
+ signal.signal(signal.SIGTERM, cleanup)
262
+ signal.signal(signal.SIGINT, cleanup)
263
+
264
+ small_model = get_small_model()
265
+ # Track by message time — persisted to disk so daemon restarts don't re-capture
266
+ state_file = os.path.join(args.project_dir, ".memsearch", ".last_msg_time")
267
+ last_msg_time = 0
268
+ if os.path.exists(state_file):
269
+ try:
270
+ last_msg_time = int(open(state_file).read().strip())
271
+ except (ValueError, OSError):
272
+ pass
273
+
274
+ while True:
275
+ try:
276
+ conn = sqlite3.connect(db_path, timeout=5)
277
+
278
+ new_turns = get_new_completed_turns(conn, args.project_dir, last_msg_time)
279
+ for session_id, turn_text, msg_time in new_turns:
280
+ if turn_text and len(turn_text) > 10:
281
+ # Summarize with LLM, fallback to raw text
282
+ summary = summarize_with_llm(turn_text, small_model)
283
+ write_capture(memory_dir, summary if summary else turn_text, session_id)
284
+ if msg_time > last_msg_time:
285
+ last_msg_time = msg_time
286
+ try:
287
+ with open(state_file, "w") as sf:
288
+ sf.write(str(last_msg_time))
289
+ except OSError:
290
+ pass
291
+
292
+ if new_turns:
293
+ # Index in background after batch capture
294
+ os.system(
295
+ f"{args.memsearch_cmd} index '{memory_dir}' "
296
+ f"--collection {args.collection_name} &"
297
+ )
298
+
299
+ conn.close()
300
+ except Exception:
301
+ pass
302
+
303
+ time.sleep(args.poll_interval)
304
+
305
+
306
+ if __name__ == "__main__":
307
+ main()
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # Derive a unique Milvus collection name from a project directory path.
3
+ # Used by hooks (via common.sh) and skill (via SKILL.md ! syntax).
4
+ #
5
+ # Usage: derive-collection.sh [project_dir]
6
+ # If no argument given, uses pwd.
7
+ #
8
+ # Output: ms_<sanitized_basename>_<8char_sha256>
9
+ # e.g. /home/user/my-app → ms_my_app_a1b2c3d4
10
+
11
+ set -euo pipefail
12
+
13
+ PROJECT_DIR="${1:-$(pwd)}"
14
+
15
+ # Resolve to absolute path (realpath preferred, cd fallback, raw last resort)
16
+ if realpath -m "$PROJECT_DIR" &>/dev/null 2>&1; then
17
+ PROJECT_DIR="$(realpath -m "$PROJECT_DIR")"
18
+ elif [ -d "$PROJECT_DIR" ]; then
19
+ PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
20
+ else
21
+ # If directory doesn't exist and no realpath, ensure it starts with /
22
+ case "$PROJECT_DIR" in
23
+ /*) ;; # already absolute
24
+ *) PROJECT_DIR="$(pwd)/$PROJECT_DIR" ;;
25
+ esac
26
+ fi
27
+
28
+ # Extract basename and sanitize:
29
+ # - lowercase
30
+ # - replace non-alphanumeric with underscore
31
+ # - collapse consecutive underscores
32
+ # - trim leading/trailing underscores
33
+ # - truncate to 40 chars
34
+ sanitized=$(basename "$PROJECT_DIR" \
35
+ | tr '[:upper:]' '[:lower:]' \
36
+ | sed 's/[^a-z0-9]/_/g' \
37
+ | sed 's/__*/_/g' \
38
+ | sed 's/^_//;s/_$//' \
39
+ | cut -c1-40)
40
+
41
+ # Compute 8-char SHA-256 hash of the full absolute path
42
+ if command -v sha256sum &>/dev/null; then
43
+ hash=$(printf '%s' "$PROJECT_DIR" | sha256sum | cut -c1-8)
44
+ elif command -v shasum &>/dev/null; then
45
+ hash=$(printf '%s' "$PROJECT_DIR" | shasum -a 256 | cut -c1-8)
46
+ else
47
+ hash=$(python3 -c "import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest()[:8])" "$PROJECT_DIR")
48
+ fi
49
+
50
+ echo "ms_${sanitized}_${hash}"
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+ """Parse OpenCode SQLite transcript for a given session.
3
+
4
+ Usage:
5
+ parse-transcript.py <session_id> [--limit N] [--last-turn]
6
+
7
+ Options:
8
+ --limit N Max number of messages to return (default: 20)
9
+ --last-turn Only return the last user+assistant turn (for capture)
10
+
11
+ The script reads from the OpenCode SQLite database at:
12
+ ~/.local/share/opencode/opencode.db
13
+ """
14
+
15
+ import sqlite3
16
+ import json
17
+ import sys
18
+ import os
19
+ import argparse
20
+
21
+
22
+ def get_db_path():
23
+ """Find the OpenCode SQLite database."""
24
+ # Default path
25
+ default = os.path.expanduser("~/.local/share/opencode/opencode.db")
26
+ if os.path.exists(default):
27
+ return default
28
+
29
+ # Check XDG_DATA_HOME
30
+ xdg_data = os.environ.get("XDG_DATA_HOME", "")
31
+ if xdg_data:
32
+ alt = os.path.join(xdg_data, "opencode", "opencode.db")
33
+ if os.path.exists(alt):
34
+ return alt
35
+
36
+ return default
37
+
38
+
39
+ def parse_session(session_id, limit=20, last_turn=False):
40
+ """Parse messages and parts for a session, formatted as [Human]/[Assistant]."""
41
+ db_path = get_db_path()
42
+ if not os.path.exists(db_path):
43
+ print(f"Error: OpenCode database not found at {db_path}", file=sys.stderr)
44
+ sys.exit(1)
45
+
46
+ conn = sqlite3.connect(db_path, timeout=5)
47
+ conn.row_factory = sqlite3.Row
48
+
49
+ try:
50
+ # Get messages for the session, ordered by creation time
51
+ messages = conn.execute(
52
+ """
53
+ SELECT id, data, time_created
54
+ FROM message
55
+ WHERE session_id = ?
56
+ ORDER BY time_created ASC
57
+ """,
58
+ (session_id,),
59
+ ).fetchall()
60
+
61
+ if not messages:
62
+ print(f"No messages found for session {session_id}")
63
+ return
64
+
65
+ # Build structured turns
66
+ turns = []
67
+ for msg in messages:
68
+ msg_data = json.loads(msg["data"])
69
+ role = msg_data.get("role", "unknown")
70
+ msg_id = msg["id"]
71
+
72
+ # Get parts for this message
73
+ parts = conn.execute(
74
+ """
75
+ SELECT id, data, time_created
76
+ FROM part
77
+ WHERE message_id = ?
78
+ ORDER BY time_created ASC
79
+ """,
80
+ (msg_id,),
81
+ ).fetchall()
82
+
83
+ text_parts = []
84
+ tool_parts = []
85
+
86
+ for part in parts:
87
+ part_data = json.loads(part["data"])
88
+ part_type = part_data.get("type", "")
89
+
90
+ if part_type == "text" and part_data.get("text"):
91
+ text = part_data["text"].strip()
92
+ # Skip synthetic parts (e.g. "The following tool was executed by the user")
93
+ if part_data.get("synthetic"):
94
+ continue
95
+ if text:
96
+ text_parts.append(text)
97
+
98
+ elif part_type == "tool" and part_data.get("state"):
99
+ state = part_data["state"]
100
+ tool_name = part_data.get("tool", "unknown")
101
+ status = state.get("status", "unknown")
102
+
103
+ if status == "completed":
104
+ tool_input = state.get("input", {})
105
+ tool_output = state.get("output", "")
106
+ # Truncate long output
107
+ if isinstance(tool_output, str) and len(tool_output) > 300:
108
+ tool_output = tool_output[:300] + "..."
109
+
110
+ input_summary = ""
111
+ if isinstance(tool_input, dict):
112
+ # Summarize common tool inputs
113
+ if "command" in tool_input:
114
+ input_summary = f" `{tool_input['command']}`"
115
+ elif "path" in tool_input:
116
+ input_summary = f" {tool_input['path']}"
117
+ elif "query" in tool_input:
118
+ input_summary = f" '{tool_input['query']}'"
119
+
120
+ tool_parts.append(
121
+ f"[Tool: {tool_name}{input_summary}] {tool_output}"
122
+ )
123
+ elif status == "error":
124
+ error = state.get("error", "unknown error")
125
+ tool_parts.append(f"[Tool: {tool_name}] Error: {error}")
126
+
127
+ # Combine text and tool parts
128
+ combined = "\n".join(text_parts)
129
+ if tool_parts:
130
+ combined += "\n" + "\n".join(tool_parts)
131
+
132
+ if combined.strip():
133
+ turns.append({"role": role, "text": combined.strip()})
134
+
135
+ if not turns:
136
+ print(f"No meaningful content found for session {session_id}")
137
+ return
138
+
139
+ # If --last-turn, extract only the last user+assistant exchange
140
+ if last_turn:
141
+ turns = extract_last_turn(turns)
142
+
143
+ # Apply limit
144
+ if limit and not last_turn:
145
+ turns = turns[-limit:]
146
+
147
+ # Format output
148
+ for turn in turns:
149
+ role_label = "[Human]" if turn["role"] == "user" else "[Assistant]"
150
+ text = turn["text"]
151
+ # Truncate very long turns
152
+ if len(text) > 3000:
153
+ text = text[:3000] + "\n..."
154
+ print(f"{role_label}: {text}\n")
155
+
156
+ finally:
157
+ conn.close()
158
+
159
+
160
+ def extract_last_turn(turns):
161
+ """Extract the last user+assistant pair from turns."""
162
+ if not turns:
163
+ return []
164
+
165
+ # Find the last user message (threshold 5 chars to support CJK)
166
+ last_user_idx = -1
167
+ for i in range(len(turns) - 1, -1, -1):
168
+ if turns[i]["role"] == "user" and len(turns[i]["text"]) > 5:
169
+ last_user_idx = i
170
+ break
171
+
172
+ if last_user_idx == -1:
173
+ return []
174
+
175
+ return turns[last_user_idx:]
176
+
177
+
178
+ def main():
179
+ parser = argparse.ArgumentParser(description="Parse OpenCode session transcript")
180
+ parser.add_argument("session_id", help="Session ID to parse")
181
+ parser.add_argument("--limit", type=int, default=20, help="Max messages to return")
182
+ parser.add_argument(
183
+ "--last-turn",
184
+ action="store_true",
185
+ help="Only return the last user+assistant turn",
186
+ )
187
+ args = parser.parse_args()
188
+
189
+ parse_session(args.session_id, limit=args.limit, last_turn=args.last_turn)
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: memory-recall
3
+ description: "Search and recall relevant memories from past sessions. Use when the user's question could benefit from historical context, past decisions, debugging notes, previous conversations, or project knowledge."
4
+ allowed-tools: Bash
5
+ ---
6
+
7
+ You are a memory retrieval agent for memsearch. Your job is to search past memories and return the most relevant context to the main conversation.
8
+
9
+ ## Project Collection
10
+
11
+ Collection: !`bash __INSTALL_DIR__/scripts/derive-collection.sh`
12
+
13
+ ## Your Task
14
+
15
+ Search for memories relevant to: $ARGUMENTS
16
+
17
+ ## Steps
18
+
19
+ 1. **Search**: Run `memsearch search "<query>" --top-k 5 --json-output --collection <collection name above>` to find relevant chunks.
20
+ - If `memsearch` is not found, try `uvx memsearch` instead.
21
+ - Choose a search query that captures the core intent of the user's question.
22
+
23
+ 2. **Evaluate**: Look at the search results. Skip chunks that are clearly irrelevant or too generic.
24
+
25
+ 3. **Expand**: For each relevant result, run `memsearch expand <chunk_hash> --collection <collection name above>` to get the full markdown section with surrounding context.
26
+
27
+ 4. **Deep drill (optional)**: If an expanded chunk contains a session anchor (`<!-- session:ID source:opencode-sqlite -->`), and the original conversation seems critical, run:
28
+ ```
29
+ python3 __INSTALL_DIR__/scripts/parse-transcript.py <session_id> --limit 10
30
+ ```
31
+ to retrieve the original conversation turns from the OpenCode SQLite database.
32
+
33
+ 5. **Return results**: Output a curated summary of the most relevant memories. Be concise — only include information that is genuinely useful for the user's current question.
34
+
35
+ ## Output Format
36
+
37
+ Organize by relevance. For each memory include:
38
+ - The key information (decisions, patterns, solutions, context)
39
+ - Source reference (file name, date) for traceability
40
+
41
+ If nothing relevant is found, simply say "No relevant memories found."