@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 +137 -0
- package/index.ts +327 -0
- package/package.json +37 -0
- package/scripts/capture-daemon.py +307 -0
- package/scripts/derive-collection.sh +50 -0
- package/scripts/parse-transcript.py +193 -0
- package/skills/memory-recall/SKILL.md +41 -0
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."
|