opencode-memsearch 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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +530 -0
- package/package.json +50 -0
- package/scripts/seed-memories.ts +481 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jeremy Dormitzer
|
|
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,125 @@
|
|
|
1
|
+
# opencode-memsearch
|
|
2
|
+
|
|
3
|
+
Persistent cross-session memory for [OpenCode](https://opencode.ai), powered by [memsearch](https://github.com/nicobako/memsearch).
|
|
4
|
+
|
|
5
|
+
This plugin gives your OpenCode agent long-term memory. It automatically summarizes each conversation turn and stores it in a local vector database. On session start, recent context is injected into the system prompt, and the agent can search all past memories with semantic search.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Automatic memory capture** — each conversation turn is summarized by an LLM and appended to daily memory files in `.memsearch/memory/`
|
|
10
|
+
- **Cold-start context** — the last 30 lines of the 2 most recent memory files are injected into the system prompt when a new session starts
|
|
11
|
+
- **Semantic search** — two custom tools (`memsearch_search` and `memsearch_expand`) let the agent search and drill into past memories
|
|
12
|
+
- **Per-project isolation** — memory collections are scoped by project directory
|
|
13
|
+
- **Local embeddings** — uses memsearch's local embedding provider, so no API calls are needed for vector search
|
|
14
|
+
- **Memory protocol** — a system prompt directive instructs the agent to check memory at session start and whenever it encounters a topic that might have prior context
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
You need the `memsearch` CLI installed. The easiest way is via [uv](https://docs.astral.sh/uv/):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Install uv (if you don't have it)
|
|
22
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
23
|
+
|
|
24
|
+
# Install memsearch with local embeddings
|
|
25
|
+
uv tool install 'memsearch[local]'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or install directly with pip:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install 'memsearch[local]'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If `memsearch` is not installed, the plugin's tools will return a clear error message asking the agent to tell you to install it.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Add `opencode-memsearch` to the plugin list in your OpenCode config:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"$schema": "https://opencode.ai/config.json",
|
|
43
|
+
"plugin": ["opencode-memsearch"]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This can go in either:
|
|
48
|
+
- `opencode.json` in your project root (project-level)
|
|
49
|
+
- `~/.config/opencode/opencode.json` (global)
|
|
50
|
+
|
|
51
|
+
OpenCode will install the npm package automatically on startup.
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
### Memory capture
|
|
56
|
+
|
|
57
|
+
When the agent finishes responding (session goes idle), the plugin:
|
|
58
|
+
|
|
59
|
+
1. Extracts the last conversation turn (user message + agent response)
|
|
60
|
+
2. Summarizes it into 2-6 bullet points using Claude Haiku via `opencode run`
|
|
61
|
+
3. Appends the summary to `.memsearch/memory/YYYY-MM-DD.md`
|
|
62
|
+
4. Re-indexes the memory directory into the vector database
|
|
63
|
+
|
|
64
|
+
Summaries are written in third person (e.g. "User asked about...", "Agent edited file X...") and include specific file names, function names, and outcomes.
|
|
65
|
+
|
|
66
|
+
### Memory recall
|
|
67
|
+
|
|
68
|
+
On session start, the plugin:
|
|
69
|
+
|
|
70
|
+
1. Reads the tail of the 2 most recent memory files and injects them into the system prompt as `<memsearch-context>`
|
|
71
|
+
2. Adds a MEMORY PROTOCOL to the system prompt instructing the agent to use `memsearch_search` at session start and whenever relevant
|
|
72
|
+
|
|
73
|
+
The agent can also search memories on demand:
|
|
74
|
+
|
|
75
|
+
- **`memsearch_search`** — semantic search across all past memory chunks. Returns ranked results with content previews and chunk hashes.
|
|
76
|
+
- **`memsearch_expand`** — expand a specific chunk to see full context, source file location, and session IDs for deeper investigation.
|
|
77
|
+
|
|
78
|
+
### Storage
|
|
79
|
+
|
|
80
|
+
Memory data is stored per-project in `.memsearch/`:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
your-project/
|
|
84
|
+
.memsearch/
|
|
85
|
+
memory/
|
|
86
|
+
2025-03-28.md # Daily memory summaries
|
|
87
|
+
2025-03-27.md
|
|
88
|
+
...
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
You should add `.memsearch/` to your `.gitignore`.
|
|
92
|
+
|
|
93
|
+
## Seed script
|
|
94
|
+
|
|
95
|
+
The repo includes a seed script (`scripts/seed-memories.ts`) that can backfill memory from existing OpenCode sessions. This is useful when first installing the plugin on a project you've already been working on:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Seed from the last 14 days of sessions (default)
|
|
99
|
+
bun run scripts/seed-memories.ts
|
|
100
|
+
|
|
101
|
+
# Seed from the last 30 days
|
|
102
|
+
bun run scripts/seed-memories.ts --days 30
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The script reads directly from the OpenCode SQLite database, summarizes each conversation turn with Claude Haiku, and writes the results to `.memsearch/memory/`.
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
The plugin auto-configures memsearch to use local embeddings. If you want to use a remote Milvus instance instead of the default local database, configure it via the memsearch CLI:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
memsearch config set milvus.uri http://localhost:19530
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
In remote mode, the plugin starts a file watcher process that automatically re-indexes memory files when they change.
|
|
116
|
+
|
|
117
|
+
## Environment variables
|
|
118
|
+
|
|
119
|
+
| Variable | Description |
|
|
120
|
+
|----------|-------------|
|
|
121
|
+
| `MEMSEARCH_DISABLE` | Set to any value to disable the plugin (used internally to prevent recursion during summarization) |
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
import { readdir, readFile, appendFile, mkdir, writeFile, unlink } from "fs/promises";
|
|
5
|
+
import { join, basename, resolve } from "path";
|
|
6
|
+
import { tmpdir } from "os";
|
|
7
|
+
function deriveCollectionName(directory) {
|
|
8
|
+
const abs = resolve(directory);
|
|
9
|
+
const sanitized = basename(abs).toLowerCase().replace(/[^a-z0-9]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 40);
|
|
10
|
+
const hash = createHash("sha256").update(abs).digest("hex").slice(0, 8);
|
|
11
|
+
return `ms_${sanitized}_${hash}`;
|
|
12
|
+
}
|
|
13
|
+
function todayDate() {
|
|
14
|
+
const d = new Date;
|
|
15
|
+
const yyyy = d.getFullYear();
|
|
16
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
17
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
18
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
19
|
+
}
|
|
20
|
+
function nowTime() {
|
|
21
|
+
const d = new Date;
|
|
22
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
23
|
+
}
|
|
24
|
+
var SUMMARIZE_PROMPT = `You are a third-person note-taker. The attached file contains a transcript of ONE conversation turn between a human and an AI coding agent. Tool calls are labeled [Tool Call] and their results [Tool Result] or [Tool Error].
|
|
25
|
+
|
|
26
|
+
Your job is to record what happened as factual third-person notes. You are an EXTERNAL OBSERVER. Do NOT answer the human's question, do NOT give suggestions, do NOT offer help. ONLY record what occurred.
|
|
27
|
+
|
|
28
|
+
Output 2-6 bullet points, each starting with '- '. NOTHING else.
|
|
29
|
+
|
|
30
|
+
Rules:
|
|
31
|
+
- Write in third person: 'User asked...', 'Agent read file X', 'Agent ran command Y'
|
|
32
|
+
- First bullet: what the user asked or wanted (one sentence)
|
|
33
|
+
- Remaining bullets: what the agent did — tools called, files read/edited, commands run, key findings
|
|
34
|
+
- Be specific: mention file names, function names, tool names, and concrete outcomes
|
|
35
|
+
- Do NOT answer the human's question yourself — just note what was discussed
|
|
36
|
+
- Do NOT add any text before or after the bullet points
|
|
37
|
+
- Do NOT continue the conversation after the bullet points
|
|
38
|
+
- Do NOT ask follow-up questions
|
|
39
|
+
- STOP immediately after the last bullet point`;
|
|
40
|
+
var HAIKU_MODEL = "anthropic/claude-haiku-4-5";
|
|
41
|
+
var TEMP_DIR = join(tmpdir(), "memsearch-plugin");
|
|
42
|
+
var memsearchPlugin = async ({ client, $, directory }) => {
|
|
43
|
+
if (process.env.MEMSEARCH_DISABLE) {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
const sessions = new Map;
|
|
47
|
+
let memsearchCmd = null;
|
|
48
|
+
async function detectMemsearch() {
|
|
49
|
+
try {
|
|
50
|
+
await $`which memsearch`.quiet();
|
|
51
|
+
return ["memsearch"];
|
|
52
|
+
} catch {}
|
|
53
|
+
try {
|
|
54
|
+
await $`which uvx`.quiet();
|
|
55
|
+
return ["uvx", "--from", "memsearch[local]", "memsearch"];
|
|
56
|
+
} catch {}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
async function ensureMemsearch() {
|
|
60
|
+
if (memsearchCmd)
|
|
61
|
+
return memsearchCmd;
|
|
62
|
+
memsearchCmd = await detectMemsearch();
|
|
63
|
+
return memsearchCmd;
|
|
64
|
+
}
|
|
65
|
+
const MEMSEARCH_NOT_FOUND_ERROR = "memsearch is not installed. Tell the user to install it by running: pip install 'memsearch[local]' — or, if they have uv: uv tool install 'memsearch[local]'. See https://github.com/jdormit/opencode-memsearch for details.";
|
|
66
|
+
async function runMemsearch(args, collectionName) {
|
|
67
|
+
const cmd = memsearchCmd;
|
|
68
|
+
if (!cmd)
|
|
69
|
+
return "";
|
|
70
|
+
const fullArgs = [...cmd, ...args, "--collection", collectionName];
|
|
71
|
+
try {
|
|
72
|
+
return await $`${fullArgs}`.quiet().text();
|
|
73
|
+
} catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function getMemsearchConfig(key) {
|
|
78
|
+
const cmd = memsearchCmd;
|
|
79
|
+
if (!cmd)
|
|
80
|
+
return "";
|
|
81
|
+
try {
|
|
82
|
+
return (await $`${[...cmd, "config", "get", key]}`.quiet().text()).trim();
|
|
83
|
+
} catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function configureLocalEmbedding() {
|
|
88
|
+
const cmd = memsearchCmd;
|
|
89
|
+
if (!cmd)
|
|
90
|
+
return;
|
|
91
|
+
const provider = await getMemsearchConfig("embedding.provider");
|
|
92
|
+
if (provider !== "local") {
|
|
93
|
+
try {
|
|
94
|
+
await $`${[...cmd, "config", "set", "embedding.provider", "local"]}`.quiet();
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function stopWatch(memsearchDir) {
|
|
99
|
+
const pidFile = join(memsearchDir, ".watch.pid");
|
|
100
|
+
try {
|
|
101
|
+
const pidStr = await readFile(pidFile, "utf-8");
|
|
102
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
103
|
+
if (pid) {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid);
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
await unlink(pidFile);
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
async function startWatch(memoryDir, memsearchDir, collectionName) {
|
|
112
|
+
const cmd = memsearchCmd;
|
|
113
|
+
if (!cmd)
|
|
114
|
+
return;
|
|
115
|
+
const milvusUri = await getMemsearchConfig("milvus.uri");
|
|
116
|
+
if (!milvusUri.startsWith("http") && !milvusUri.startsWith("tcp")) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await stopWatch(memsearchDir);
|
|
120
|
+
const pidFile = join(memsearchDir, ".watch.pid");
|
|
121
|
+
try {
|
|
122
|
+
const watchProc = Bun.spawn([...cmd, "watch", memoryDir, "--collection", collectionName], {
|
|
123
|
+
stdout: "ignore",
|
|
124
|
+
stderr: "ignore",
|
|
125
|
+
stdin: "ignore"
|
|
126
|
+
});
|
|
127
|
+
await writeFile(pidFile, String(watchProc.pid));
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
async function getRecentMemory(memoryDir) {
|
|
131
|
+
try {
|
|
132
|
+
const files = await readdir(memoryDir);
|
|
133
|
+
const mdFiles = files.filter((f) => f.endsWith(".md")).sort().reverse().slice(0, 2);
|
|
134
|
+
if (mdFiles.length === 0)
|
|
135
|
+
return "";
|
|
136
|
+
let context = `# Recent Memory
|
|
137
|
+
|
|
138
|
+
`;
|
|
139
|
+
for (const f of mdFiles) {
|
|
140
|
+
const content = await readFile(join(memoryDir, f), "utf-8");
|
|
141
|
+
const lines = content.split(`
|
|
142
|
+
`);
|
|
143
|
+
const tail = lines.slice(-30).join(`
|
|
144
|
+
`).trim();
|
|
145
|
+
if (tail) {
|
|
146
|
+
context += `## ${f}
|
|
147
|
+
${tail}
|
|
148
|
+
|
|
149
|
+
`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return context;
|
|
153
|
+
} catch {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function formatTurnTranscript(messages) {
|
|
158
|
+
let lastUserIdx = -1;
|
|
159
|
+
for (let i = messages.length - 1;i >= 0; i--) {
|
|
160
|
+
if (messages[i].info.role === "user") {
|
|
161
|
+
lastUserIdx = i;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (lastUserIdx === -1)
|
|
166
|
+
return "";
|
|
167
|
+
const turn = messages.slice(lastUserIdx);
|
|
168
|
+
const lines = [
|
|
169
|
+
"=== Transcript of a conversation turn between a human and an AI coding agent ==="
|
|
170
|
+
];
|
|
171
|
+
for (const msg of turn) {
|
|
172
|
+
const role = msg.info.role;
|
|
173
|
+
for (const part of msg.parts) {
|
|
174
|
+
if (part.type === "text" && part.text?.trim()) {
|
|
175
|
+
if (role === "user") {
|
|
176
|
+
lines.push(`[Human]: ${part.text.trim()}`);
|
|
177
|
+
} else if (role === "assistant") {
|
|
178
|
+
lines.push(`[Assistant]: ${part.text.trim()}`);
|
|
179
|
+
}
|
|
180
|
+
} else if (part.type === "tool-invocation" || part.type === "tool_use") {
|
|
181
|
+
const name = part.toolName || part.name || "unknown";
|
|
182
|
+
const args = part.args || part.input || {};
|
|
183
|
+
const argParts = [];
|
|
184
|
+
for (const [k, v] of Object.entries(args)) {
|
|
185
|
+
let vStr = String(v);
|
|
186
|
+
if (vStr.length > 120)
|
|
187
|
+
vStr = vStr.slice(0, 120) + "...";
|
|
188
|
+
argParts.push(`${k}=${vStr}`);
|
|
189
|
+
}
|
|
190
|
+
let argSummary = argParts.join(", ");
|
|
191
|
+
if (argSummary.length > 400)
|
|
192
|
+
argSummary = argSummary.slice(0, 400) + "...";
|
|
193
|
+
lines.push(`[Tool Call]: ${name}(${argSummary})`);
|
|
194
|
+
} else if (part.type === "tool-result" || part.type === "tool_result") {
|
|
195
|
+
const output = String(part.output || part.content || "").slice(0, 1000);
|
|
196
|
+
const isError = part.isError || part.is_error;
|
|
197
|
+
const label = isError ? "[Tool Error]" : "[Tool Result]";
|
|
198
|
+
lines.push(`${label}: ${output}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return lines.join(`
|
|
203
|
+
`);
|
|
204
|
+
}
|
|
205
|
+
async function summarizeTranscript(transcript, sessionID, turnIdx) {
|
|
206
|
+
const tempFile = join(TEMP_DIR, `turn-${sessionID}-${turnIdx}.txt`);
|
|
207
|
+
await mkdir(TEMP_DIR, { recursive: true });
|
|
208
|
+
await writeFile(tempFile, transcript);
|
|
209
|
+
try {
|
|
210
|
+
const rawOutput = await $`opencode run -f ${tempFile} --model ${HAIKU_MODEL} --format json ${SUMMARIZE_PROMPT}`.env({ ...process.env, MEMSEARCH_DISABLE: "1" }).nothrow().quiet().text();
|
|
211
|
+
let summarizationSessionID;
|
|
212
|
+
const textParts = [];
|
|
213
|
+
for (const line of rawOutput.split(`
|
|
214
|
+
`)) {
|
|
215
|
+
if (!line.trim())
|
|
216
|
+
continue;
|
|
217
|
+
try {
|
|
218
|
+
const event = JSON.parse(line);
|
|
219
|
+
if (!summarizationSessionID && event.sessionID) {
|
|
220
|
+
summarizationSessionID = event.sessionID;
|
|
221
|
+
}
|
|
222
|
+
if (event.type === "text" && event.part?.text) {
|
|
223
|
+
textParts.push(event.part.text);
|
|
224
|
+
}
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
if (summarizationSessionID) {
|
|
228
|
+
try {
|
|
229
|
+
await client.session.delete({ path: { id: summarizationSessionID } });
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
const combined = textParts.join("");
|
|
233
|
+
const bulletLines = combined.split(`
|
|
234
|
+
`).filter((l) => l.trimStart().startsWith("- ")).filter((l) => {
|
|
235
|
+
const content = l.trimStart().slice(2);
|
|
236
|
+
return content.startsWith("User ") || content.startsWith("Agent ");
|
|
237
|
+
});
|
|
238
|
+
return bulletLines.join(`
|
|
239
|
+
`).trim();
|
|
240
|
+
} finally {
|
|
241
|
+
try {
|
|
242
|
+
await unlink(tempFile);
|
|
243
|
+
} catch {}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function formatConciseSummary(messages) {
|
|
247
|
+
let lastUserIdx = -1;
|
|
248
|
+
for (let i = messages.length - 1;i >= 0; i--) {
|
|
249
|
+
if (messages[i].info.role === "user") {
|
|
250
|
+
lastUserIdx = i;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (lastUserIdx === -1)
|
|
255
|
+
return "";
|
|
256
|
+
const turn = messages.slice(lastUserIdx);
|
|
257
|
+
const lines = [];
|
|
258
|
+
for (const msg of turn) {
|
|
259
|
+
const role = msg.info.role;
|
|
260
|
+
for (const part of msg.parts) {
|
|
261
|
+
if (part.type === "text" && part.text?.trim()) {
|
|
262
|
+
if (role === "user") {
|
|
263
|
+
const text = part.text.trim();
|
|
264
|
+
lines.push(`- User asked: ${text.length > 200 ? text.slice(0, 200) + "..." : text}`);
|
|
265
|
+
} else if (role === "assistant") {
|
|
266
|
+
const text = part.text.trim();
|
|
267
|
+
if (text.length > 0) {
|
|
268
|
+
lines.push(`- Agent responded: ${text.length > 200 ? text.slice(0, 200) + "..." : text}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else if (part.type === "tool-invocation" || part.type === "tool_use") {
|
|
272
|
+
const name = part.toolName || part.name || "unknown";
|
|
273
|
+
const args = part.args || part.input || {};
|
|
274
|
+
const keyArgs = [];
|
|
275
|
+
for (const [k, v] of Object.entries(args)) {
|
|
276
|
+
const vStr = String(v);
|
|
277
|
+
if (vStr.length <= 100) {
|
|
278
|
+
keyArgs.push(`${k}=${vStr}`);
|
|
279
|
+
} else {
|
|
280
|
+
keyArgs.push(`${k}=${vStr.slice(0, 80)}...`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
lines.push(`- Tool: ${name}(${keyArgs.join(", ").slice(0, 300)})`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return lines.slice(0, 20).join(`
|
|
288
|
+
`);
|
|
289
|
+
}
|
|
290
|
+
await ensureMemsearch();
|
|
291
|
+
if (memsearchCmd) {
|
|
292
|
+
await configureLocalEmbedding();
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
event: async ({ event }) => {
|
|
296
|
+
if (event.type === "session.created") {
|
|
297
|
+
const sessionInfo = event.properties.info;
|
|
298
|
+
const sessionID = sessionInfo.id;
|
|
299
|
+
if (sessionInfo.parentID)
|
|
300
|
+
return;
|
|
301
|
+
const sessionDir = sessionInfo.directory || directory;
|
|
302
|
+
const memsearchDir = join(sessionDir, ".memsearch");
|
|
303
|
+
const memoryDir = join(memsearchDir, "memory");
|
|
304
|
+
const collectionName = deriveCollectionName(sessionDir);
|
|
305
|
+
if (!memsearchCmd) {
|
|
306
|
+
await ensureMemsearch();
|
|
307
|
+
}
|
|
308
|
+
await mkdir(memoryDir, { recursive: true });
|
|
309
|
+
sessions.set(sessionID, {
|
|
310
|
+
directory: sessionDir,
|
|
311
|
+
memoryDir,
|
|
312
|
+
collectionName,
|
|
313
|
+
isSummarizing: false,
|
|
314
|
+
lastSummarizedMessageCount: 0,
|
|
315
|
+
headingWritten: false
|
|
316
|
+
});
|
|
317
|
+
await startWatch(memoryDir, memsearchDir, collectionName);
|
|
318
|
+
const milvusUri = await getMemsearchConfig("milvus.uri");
|
|
319
|
+
if (!milvusUri.startsWith("http") && !milvusUri.startsWith("tcp")) {
|
|
320
|
+
runMemsearch(["index", memoryDir], collectionName);
|
|
321
|
+
}
|
|
322
|
+
const coldStart = await getRecentMemory(memoryDir);
|
|
323
|
+
if (coldStart) {
|
|
324
|
+
const state = sessions.get(sessionID);
|
|
325
|
+
if (state) {
|
|
326
|
+
state.coldStartContext = `<memsearch-context>
|
|
327
|
+
# Recent Memory
|
|
328
|
+
|
|
329
|
+
${coldStart}</memsearch-context>
|
|
330
|
+
|
|
331
|
+
The above is recent memory context from past sessions. Use the memsearch_search tool to search for more specific memories when needed.`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (event.type === "session.status") {
|
|
336
|
+
const props = event.properties;
|
|
337
|
+
const sessionID = props.sessionID;
|
|
338
|
+
const status = props.status;
|
|
339
|
+
if (status.type !== "idle")
|
|
340
|
+
return;
|
|
341
|
+
let state = sessions.get(sessionID);
|
|
342
|
+
if (!state) {
|
|
343
|
+
try {
|
|
344
|
+
const listResp = await client.session.list();
|
|
345
|
+
const allSessions = listResp.data || listResp || [];
|
|
346
|
+
const sessionInfo = allSessions.find((s) => s.id === sessionID);
|
|
347
|
+
if (!sessionInfo)
|
|
348
|
+
return;
|
|
349
|
+
if (sessionInfo.parentID)
|
|
350
|
+
return;
|
|
351
|
+
const sessionDir = sessionInfo.directory || directory;
|
|
352
|
+
const memsearchDir = join(sessionDir, ".memsearch");
|
|
353
|
+
const memoryDir = join(memsearchDir, "memory");
|
|
354
|
+
const collectionName = deriveCollectionName(sessionDir);
|
|
355
|
+
await mkdir(memoryDir, { recursive: true });
|
|
356
|
+
state = {
|
|
357
|
+
directory: sessionDir,
|
|
358
|
+
memoryDir,
|
|
359
|
+
collectionName,
|
|
360
|
+
isSummarizing: false,
|
|
361
|
+
lastSummarizedMessageCount: 0,
|
|
362
|
+
headingWritten: false
|
|
363
|
+
};
|
|
364
|
+
sessions.set(sessionID, state);
|
|
365
|
+
} catch {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (state.isSummarizing)
|
|
370
|
+
return;
|
|
371
|
+
state.isSummarizing = true;
|
|
372
|
+
try {
|
|
373
|
+
const messagesResp = await client.session.messages({
|
|
374
|
+
path: { id: sessionID }
|
|
375
|
+
});
|
|
376
|
+
const messages = messagesResp.data || messagesResp;
|
|
377
|
+
if (!Array.isArray(messages) || messages.length < 2)
|
|
378
|
+
return;
|
|
379
|
+
if (messages.length <= state.lastSummarizedMessageCount)
|
|
380
|
+
return;
|
|
381
|
+
let lastUserText = "";
|
|
382
|
+
for (let i = messages.length - 1;i >= 0; i--) {
|
|
383
|
+
if (messages[i].info.role === "user") {
|
|
384
|
+
for (const part of messages[i].parts) {
|
|
385
|
+
if (part.type === "text" && part.text) {
|
|
386
|
+
lastUserText = part.text.trim();
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (lastUserText.length < 10)
|
|
394
|
+
return;
|
|
395
|
+
const transcript = formatTurnTranscript(messages);
|
|
396
|
+
if (!transcript || transcript.split(`
|
|
397
|
+
`).length < 3)
|
|
398
|
+
return;
|
|
399
|
+
let summary;
|
|
400
|
+
try {
|
|
401
|
+
summary = await summarizeTranscript(transcript, sessionID, state.lastSummarizedMessageCount);
|
|
402
|
+
} catch {
|
|
403
|
+
summary = "";
|
|
404
|
+
}
|
|
405
|
+
if (!summary) {
|
|
406
|
+
summary = formatConciseSummary(messages);
|
|
407
|
+
}
|
|
408
|
+
if (!summary)
|
|
409
|
+
return;
|
|
410
|
+
const today = todayDate();
|
|
411
|
+
const now = nowTime();
|
|
412
|
+
const memoryFile = join(state.memoryDir, `${today}.md`);
|
|
413
|
+
if (!state.headingWritten) {
|
|
414
|
+
await appendFile(memoryFile, `
|
|
415
|
+
## Session ${now}
|
|
416
|
+
|
|
417
|
+
`);
|
|
418
|
+
state.headingWritten = true;
|
|
419
|
+
}
|
|
420
|
+
const entry = `### ${now}
|
|
421
|
+
<!-- session:${sessionID} -->
|
|
422
|
+
${summary}
|
|
423
|
+
|
|
424
|
+
`;
|
|
425
|
+
await appendFile(memoryFile, entry);
|
|
426
|
+
state.lastSummarizedMessageCount = messages.length;
|
|
427
|
+
runMemsearch(["index", state.memoryDir], state.collectionName);
|
|
428
|
+
} catch {} finally {
|
|
429
|
+
state.isSummarizing = false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
tool: {
|
|
434
|
+
memsearch_search: tool({
|
|
435
|
+
description: "Search past session memories using semantic search. Returns relevant memory chunks from previous conversations, including decisions made, bugs debugged, files edited, and other contextual information. Use this at the start of a session and whenever you encounter a topic that might have prior context.",
|
|
436
|
+
args: {
|
|
437
|
+
query: tool.schema.string().describe("Natural language search query describing what you want to recall from past sessions"),
|
|
438
|
+
top_k: tool.schema.number().optional().default(5).describe("Number of results to return (default: 5)")
|
|
439
|
+
},
|
|
440
|
+
async execute(args, context) {
|
|
441
|
+
await ensureMemsearch();
|
|
442
|
+
if (!memsearchCmd) {
|
|
443
|
+
return MEMSEARCH_NOT_FOUND_ERROR;
|
|
444
|
+
}
|
|
445
|
+
const collectionName = deriveCollectionName(context.directory);
|
|
446
|
+
const raw = await runMemsearch([
|
|
447
|
+
"search",
|
|
448
|
+
args.query,
|
|
449
|
+
"--top-k",
|
|
450
|
+
String(args.top_k ?? 5),
|
|
451
|
+
"--json-output"
|
|
452
|
+
], collectionName);
|
|
453
|
+
if (!raw.trim()) {
|
|
454
|
+
return "No results found.";
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const results = JSON.parse(raw);
|
|
458
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
459
|
+
return "No results found.";
|
|
460
|
+
}
|
|
461
|
+
return JSON.stringify(results, null, 2);
|
|
462
|
+
} catch {
|
|
463
|
+
return raw;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}),
|
|
467
|
+
memsearch_expand: tool({
|
|
468
|
+
description: "Expand a memory search result to show its full context. Takes a chunk_hash from a memsearch_search result and returns the complete markdown section with surrounding content, plus guidance on how to dig deeper into the original session.",
|
|
469
|
+
args: {
|
|
470
|
+
chunk_hash: tool.schema.string().describe("The chunk_hash from a memsearch_search result to expand")
|
|
471
|
+
},
|
|
472
|
+
async execute(args, context) {
|
|
473
|
+
await ensureMemsearch();
|
|
474
|
+
if (!memsearchCmd) {
|
|
475
|
+
return MEMSEARCH_NOT_FOUND_ERROR;
|
|
476
|
+
}
|
|
477
|
+
const collectionName = deriveCollectionName(context.directory);
|
|
478
|
+
const raw = await runMemsearch(["expand", args.chunk_hash, "--json-output"], collectionName);
|
|
479
|
+
if (!raw.trim()) {
|
|
480
|
+
return "Chunk not found.";
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
const result = JSON.parse(raw);
|
|
484
|
+
const lines = [];
|
|
485
|
+
lines.push(result.content || "");
|
|
486
|
+
lines.push("");
|
|
487
|
+
lines.push("--- Deep drill ---");
|
|
488
|
+
if (result.source) {
|
|
489
|
+
lines.push(`Source file: ${result.source} (lines ${result.start_line}-${result.end_line})`);
|
|
490
|
+
}
|
|
491
|
+
const sessionMatches = (result.content || "").matchAll(/<!-- session:(ses_[a-zA-Z0-9]+) -->/g);
|
|
492
|
+
const sessionIDs = [...new Set([...sessionMatches].map((m) => m[1]))];
|
|
493
|
+
if (sessionIDs.length > 0) {
|
|
494
|
+
lines.push(`Session IDs found: ${sessionIDs.join(", ")}`);
|
|
495
|
+
}
|
|
496
|
+
lines.push("To get more context:");
|
|
497
|
+
if (result.source) {
|
|
498
|
+
lines.push(`- Read the source file "${result.source}" with the Read tool around lines ${result.start_line}-${result.end_line} for surrounding entries`);
|
|
499
|
+
const memoryDir = result.source.replace(/\/[^/]+$/, "");
|
|
500
|
+
lines.push(`- Search for a session ID in "${memoryDir}/" to find all entries from the same session`);
|
|
501
|
+
}
|
|
502
|
+
return lines.join(`
|
|
503
|
+
`);
|
|
504
|
+
} catch {
|
|
505
|
+
return raw;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
},
|
|
510
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
511
|
+
const sessionID = input.sessionID;
|
|
512
|
+
if (sessionID) {
|
|
513
|
+
const state = sessions.get(sessionID);
|
|
514
|
+
if (state?.coldStartContext) {
|
|
515
|
+
output.system.push(state.coldStartContext);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
output.system.push(`MEMORY PROTOCOL:
|
|
519
|
+
1. Check the <memsearch-context> in the system prompt for recent memory from past sessions.
|
|
520
|
+
2. ALWAYS use the memsearch_search tool to search for relevant memories before starting work in a session. The injected context only contains the last 30 lines of the 2 most recent memory files — it is not comprehensive. The memsearch_search tool performs semantic search across ALL past memories and is much more thorough. You don't need to recall memories for every conversation turn, but you should check for relevant memories at the start of a session and whenever you encounter a topic that might have prior context — past decisions, debugging sessions, user preferences, or earlier work on the same files or features.
|
|
521
|
+
3. Search memory again whenever you encounter a topic that might have prior context — past decisions, debugging sessions, user preferences, or earlier work on the same files or features.
|
|
522
|
+
4. Use memsearch_expand to get full context for any relevant search results. It will also provide guidance on how to dig deeper into the original session data.
|
|
523
|
+
5. Memories are automatically recorded as you work — you do not need to write them manually.`);
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
};
|
|
527
|
+
var src_default = memsearchPlugin;
|
|
528
|
+
export {
|
|
529
|
+
src_default as default
|
|
530
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-memsearch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Persistent cross-session memory for OpenCode, powered by memsearch",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"scripts"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target node --external @opencode-ai/plugin && tsc --emitDeclarationOnly",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"opencode",
|
|
25
|
+
"memory",
|
|
26
|
+
"memsearch",
|
|
27
|
+
"ai",
|
|
28
|
+
"coding",
|
|
29
|
+
"persistent-memory",
|
|
30
|
+
"semantic-search"
|
|
31
|
+
],
|
|
32
|
+
"author": "Jeremy Dormitzer <jeremy.dormitzer@gmail.com>",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/jdormit/opencode-memsearch.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/jdormit/opencode-memsearch/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/jdormit/opencode-memsearch#readme",
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@opencode-ai/plugin": ">=1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@opencode-ai/plugin": "^1.3.3",
|
|
47
|
+
"@types/bun": "latest",
|
|
48
|
+
"typescript": "^5.8.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* seed-memories.ts — Seed memsearch memory files from recent OpenCode sessions.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun run ~/.config/opencode/scripts/seed-memories.ts [--days 14]
|
|
7
|
+
*
|
|
8
|
+
* This script:
|
|
9
|
+
* 1. Reads session + message data directly from the OpenCode SQLite database
|
|
10
|
+
* 2. For each session, formats each conversation turn as a transcript
|
|
11
|
+
* 3. Summarizes each turn via `opencode run` with claude-haiku (one process per turn)
|
|
12
|
+
* 4. Writes summaries to .memsearch/memory/YYYY-MM-DD.md files per project
|
|
13
|
+
* 5. Indexes all memory files with memsearch
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Database } from "bun:sqlite"
|
|
17
|
+
import { createHash } from "crypto"
|
|
18
|
+
import { appendFile, mkdir, writeFile, unlink } from "fs/promises"
|
|
19
|
+
import { join, basename, resolve } from "path"
|
|
20
|
+
import { homedir, tmpdir } from "os"
|
|
21
|
+
import { $ } from "bun"
|
|
22
|
+
|
|
23
|
+
// --- Config ---
|
|
24
|
+
|
|
25
|
+
const SUMMARIZE_PROMPT = `You are a third-person note-taker. The attached file contains a transcript of ONE conversation turn between a human and an AI coding agent. Tool calls are labeled [Tool Call] and their results [Tool Result] or [Tool Error].
|
|
26
|
+
|
|
27
|
+
Your job is to record what happened as factual third-person notes. You are an EXTERNAL OBSERVER. Do NOT answer the human's question, do NOT give suggestions, do NOT offer help. ONLY record what occurred.
|
|
28
|
+
|
|
29
|
+
Output 2-6 bullet points, each starting with '- '. NOTHING else.
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- Write in third person: 'User asked...', 'Agent read file X', 'Agent ran command Y'
|
|
33
|
+
- First bullet: what the user asked or wanted (one sentence)
|
|
34
|
+
- Remaining bullets: what the agent did — tools called, files read/edited, commands run, key findings
|
|
35
|
+
- Be specific: mention file names, function names, tool names, and concrete outcomes
|
|
36
|
+
- Do NOT answer the human's question yourself — just note what was discussed
|
|
37
|
+
- Do NOT add any text before or after the bullet points
|
|
38
|
+
- Do NOT continue the conversation after the bullet points
|
|
39
|
+
- Do NOT ask follow-up questions
|
|
40
|
+
- STOP immediately after the last bullet point`
|
|
41
|
+
|
|
42
|
+
const HAIKU_MODEL = "anthropic/claude-haiku-4-5"
|
|
43
|
+
|
|
44
|
+
const DB_PATH = join(homedir(), ".local", "share", "opencode", "opencode.db")
|
|
45
|
+
const TEMP_DIR = join(tmpdir(), "memsearch-seed")
|
|
46
|
+
|
|
47
|
+
// --- Helpers ---
|
|
48
|
+
|
|
49
|
+
function deriveCollectionName(directory: string): string {
|
|
50
|
+
const abs = resolve(directory)
|
|
51
|
+
const sanitized = basename(abs)
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/[^a-z0-9]/g, "_")
|
|
54
|
+
.replace(/_+/g, "_")
|
|
55
|
+
.replace(/^_|_$/g, "")
|
|
56
|
+
.slice(0, 40)
|
|
57
|
+
const hash = createHash("sha256").update(abs).digest("hex").slice(0, 8)
|
|
58
|
+
return `ms_${sanitized}_${hash}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatDate(epochMs: number): string {
|
|
62
|
+
const d = new Date(epochMs)
|
|
63
|
+
const yyyy = d.getFullYear()
|
|
64
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0")
|
|
65
|
+
const dd = String(d.getDate()).padStart(2, "0")
|
|
66
|
+
return `${yyyy}-${mm}-${dd}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatTime(epochMs: number): string {
|
|
70
|
+
const d = new Date(epochMs)
|
|
71
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseArgs(): { days: number } {
|
|
75
|
+
const args = process.argv.slice(2)
|
|
76
|
+
let days = 14
|
|
77
|
+
for (let i = 0; i < args.length; i++) {
|
|
78
|
+
if (args[i] === "--days" && args[i + 1]) {
|
|
79
|
+
days = parseInt(args[i + 1], 10)
|
|
80
|
+
if (isNaN(days) || days < 1) {
|
|
81
|
+
console.error("Invalid --days value, using default 14")
|
|
82
|
+
days = 14
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { days }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Database types ---
|
|
90
|
+
|
|
91
|
+
interface DbSession {
|
|
92
|
+
id: string
|
|
93
|
+
directory: string
|
|
94
|
+
title: string
|
|
95
|
+
parent_id: string | null
|
|
96
|
+
time_created: number
|
|
97
|
+
time_updated: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface DbMessage {
|
|
101
|
+
id: string
|
|
102
|
+
session_id: string
|
|
103
|
+
time_created: number
|
|
104
|
+
data: string // JSON
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface DbPart {
|
|
108
|
+
id: string
|
|
109
|
+
message_id: string
|
|
110
|
+
time_created: number
|
|
111
|
+
data: string // JSON
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- Database access ---
|
|
115
|
+
|
|
116
|
+
function listSessionsFromDb(db: Database, cutoffMs: number): DbSession[] {
|
|
117
|
+
return db.query<DbSession, [number]>(`
|
|
118
|
+
SELECT id, directory, title, parent_id, time_created, time_updated
|
|
119
|
+
FROM session
|
|
120
|
+
WHERE time_created >= ?
|
|
121
|
+
AND parent_id IS NULL
|
|
122
|
+
ORDER BY time_created ASC
|
|
123
|
+
`).all(cutoffMs)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getSessionMessages(
|
|
127
|
+
db: Database,
|
|
128
|
+
sessionId: string,
|
|
129
|
+
): { info: any; parts: any[] }[] {
|
|
130
|
+
// Get messages
|
|
131
|
+
const dbMessages = db.query<DbMessage, [string]>(`
|
|
132
|
+
SELECT id, session_id, time_created, data
|
|
133
|
+
FROM message
|
|
134
|
+
WHERE session_id = ?
|
|
135
|
+
ORDER BY time_created ASC
|
|
136
|
+
`).all(sessionId)
|
|
137
|
+
|
|
138
|
+
if (dbMessages.length === 0) return []
|
|
139
|
+
|
|
140
|
+
// Get all parts for this session in one query
|
|
141
|
+
const dbParts = db.query<DbPart, [string]>(`
|
|
142
|
+
SELECT id, message_id, time_created, data
|
|
143
|
+
FROM part
|
|
144
|
+
WHERE session_id = ?
|
|
145
|
+
ORDER BY time_created ASC
|
|
146
|
+
`).all(sessionId)
|
|
147
|
+
|
|
148
|
+
// Group parts by message_id
|
|
149
|
+
const partsByMessage = new Map<string, any[]>()
|
|
150
|
+
for (const p of dbParts) {
|
|
151
|
+
if (!partsByMessage.has(p.message_id)) partsByMessage.set(p.message_id, [])
|
|
152
|
+
partsByMessage.get(p.message_id)!.push(JSON.parse(p.data))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Assemble messages with their parts
|
|
156
|
+
return dbMessages.map((m) => ({
|
|
157
|
+
info: JSON.parse(m.data),
|
|
158
|
+
parts: partsByMessage.get(m.id) || [],
|
|
159
|
+
}))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Message processing ---
|
|
163
|
+
|
|
164
|
+
function splitIntoTurns(
|
|
165
|
+
messages: { info: any; parts: any[] }[],
|
|
166
|
+
): { info: any; parts: any[] }[][] {
|
|
167
|
+
const turns: { info: any; parts: any[] }[][] = []
|
|
168
|
+
let current: { info: any; parts: any[] }[] = []
|
|
169
|
+
|
|
170
|
+
for (const msg of messages) {
|
|
171
|
+
if (msg.info.role === "user" && current.length > 0) {
|
|
172
|
+
turns.push(current)
|
|
173
|
+
current = []
|
|
174
|
+
}
|
|
175
|
+
current.push(msg)
|
|
176
|
+
}
|
|
177
|
+
if (current.length > 0) {
|
|
178
|
+
turns.push(current)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return turns
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatTurnTranscript(
|
|
185
|
+
turn: { info: any; parts: any[] }[],
|
|
186
|
+
): string {
|
|
187
|
+
const lines: string[] = [
|
|
188
|
+
"=== Transcript of a conversation turn between a human and an AI coding agent ===",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
for (const msg of turn) {
|
|
192
|
+
const role = msg.info.role
|
|
193
|
+
for (const part of msg.parts) {
|
|
194
|
+
if (part.type === "text" && part.text?.trim()) {
|
|
195
|
+
if (role === "user") {
|
|
196
|
+
lines.push(`[Human]: ${part.text.trim()}`)
|
|
197
|
+
} else if (role === "assistant") {
|
|
198
|
+
lines.push(`[Assistant]: ${part.text.trim()}`)
|
|
199
|
+
}
|
|
200
|
+
} else if (part.type === "tool-invocation" || part.type === "tool_use") {
|
|
201
|
+
const name = part.toolName || part.name || "unknown"
|
|
202
|
+
const args = part.args || part.input || {}
|
|
203
|
+
const argParts: string[] = []
|
|
204
|
+
for (const [k, v] of Object.entries(args)) {
|
|
205
|
+
let vStr = String(v)
|
|
206
|
+
if (vStr.length > 120) vStr = vStr.slice(0, 120) + "..."
|
|
207
|
+
argParts.push(`${k}=${vStr}`)
|
|
208
|
+
}
|
|
209
|
+
let argSummary = argParts.join(", ")
|
|
210
|
+
if (argSummary.length > 400)
|
|
211
|
+
argSummary = argSummary.slice(0, 400) + "..."
|
|
212
|
+
lines.push(`[Tool Call]: ${name}(${argSummary})`)
|
|
213
|
+
} else if (part.type === "tool-result" || part.type === "tool_result") {
|
|
214
|
+
const output = String(part.output || part.content || "").slice(0, 1000)
|
|
215
|
+
const isError = part.isError || part.is_error
|
|
216
|
+
const label = isError ? "[Tool Error]" : "[Tool Result]"
|
|
217
|
+
lines.push(`${label}: ${output}`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return lines.join("\n")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function formatConciseSummary(
|
|
226
|
+
turn: { info: any; parts: any[] }[],
|
|
227
|
+
): string {
|
|
228
|
+
const lines: string[] = []
|
|
229
|
+
|
|
230
|
+
for (const msg of turn) {
|
|
231
|
+
const role = msg.info.role
|
|
232
|
+
for (const part of msg.parts) {
|
|
233
|
+
if (part.type === "text" && part.text?.trim()) {
|
|
234
|
+
if (role === "user") {
|
|
235
|
+
const text = part.text.trim()
|
|
236
|
+
lines.push(`- User asked: ${text.length > 200 ? text.slice(0, 200) + "..." : text}`)
|
|
237
|
+
} else if (role === "assistant") {
|
|
238
|
+
const text = part.text.trim()
|
|
239
|
+
if (text.length > 0) {
|
|
240
|
+
lines.push(`- Agent responded: ${text.length > 200 ? text.slice(0, 200) + "..." : text}`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} else if (part.type === "tool-invocation" || part.type === "tool_use") {
|
|
244
|
+
const name = part.toolName || part.name || "unknown"
|
|
245
|
+
const args = part.args || part.input || {}
|
|
246
|
+
const keyArgs: string[] = []
|
|
247
|
+
for (const [k, v] of Object.entries(args)) {
|
|
248
|
+
const vStr = String(v)
|
|
249
|
+
if (vStr.length <= 100) {
|
|
250
|
+
keyArgs.push(`${k}=${vStr}`)
|
|
251
|
+
} else {
|
|
252
|
+
keyArgs.push(`${k}=${vStr.slice(0, 80)}...`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
lines.push(`- Tool: ${name}(${keyArgs.join(", ").slice(0, 300)})`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return lines.slice(0, 20).join("\n")
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getUserText(turn: { info: any; parts: any[] }[]): string {
|
|
264
|
+
for (const msg of turn) {
|
|
265
|
+
if (msg.info.role === "user") {
|
|
266
|
+
for (const part of msg.parts) {
|
|
267
|
+
if (part.type === "text" && part.text) {
|
|
268
|
+
return part.text.trim()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return ""
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Detect memsearch command
|
|
277
|
+
async function detectMemsearch(): Promise<string[]> {
|
|
278
|
+
try {
|
|
279
|
+
await $`which memsearch`.quiet()
|
|
280
|
+
return ["memsearch"]
|
|
281
|
+
} catch {}
|
|
282
|
+
try {
|
|
283
|
+
await $`which uvx`.quiet()
|
|
284
|
+
return ["uvx", "--from", "memsearch[local]", "memsearch"]
|
|
285
|
+
} catch {}
|
|
286
|
+
throw new Error("memsearch not found. Install it with: pip install 'memsearch[local]' or install uv")
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Summarize a transcript via `opencode run`
|
|
290
|
+
async function summarizeWithOpencode(transcript: string, tempFile: string): Promise<string> {
|
|
291
|
+
// Write transcript to temp file
|
|
292
|
+
await writeFile(tempFile, transcript)
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
// Disable all plugins during summarization to avoid memsearch plugin
|
|
296
|
+
// interfering with the LLM output (e.g. injecting "[memsearch] Memory available")
|
|
297
|
+
const rawOutput = await $`opencode run -f ${tempFile} --model ${HAIKU_MODEL} ${SUMMARIZE_PROMPT}`
|
|
298
|
+
.env({ ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [] }) })
|
|
299
|
+
.nothrow()
|
|
300
|
+
.quiet()
|
|
301
|
+
.text()
|
|
302
|
+
|
|
303
|
+
// Parse output: only keep bullet lines that start with "User " or "Agent "
|
|
304
|
+
// (matching the third-person format from the prompt). The agent sometimes
|
|
305
|
+
// appends conversational junk like "- Do you want me to..." which we discard.
|
|
306
|
+
const lines = rawOutput.split("\n")
|
|
307
|
+
const bulletLines = lines
|
|
308
|
+
.filter((l) => l.trimStart().startsWith("- "))
|
|
309
|
+
.filter((l) => {
|
|
310
|
+
const content = l.trimStart().slice(2) // strip "- "
|
|
311
|
+
return content.startsWith("User ") || content.startsWith("Agent ")
|
|
312
|
+
})
|
|
313
|
+
return bulletLines.join("\n").trim()
|
|
314
|
+
} finally {
|
|
315
|
+
try {
|
|
316
|
+
await unlink(tempFile)
|
|
317
|
+
} catch {}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Main ---
|
|
322
|
+
|
|
323
|
+
async function main() {
|
|
324
|
+
const { days } = parseArgs()
|
|
325
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000
|
|
326
|
+
|
|
327
|
+
console.log(`Seeding memories from the last ${days} days...`)
|
|
328
|
+
console.log()
|
|
329
|
+
|
|
330
|
+
// Setup
|
|
331
|
+
const memsearchCmd = await detectMemsearch()
|
|
332
|
+
console.log(`Using memsearch: ${memsearchCmd.join(" ")}`)
|
|
333
|
+
|
|
334
|
+
await mkdir(TEMP_DIR, { recursive: true })
|
|
335
|
+
|
|
336
|
+
// Open database (read-only)
|
|
337
|
+
const db = new Database(DB_PATH, { readonly: true })
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
// List sessions
|
|
341
|
+
const allSessions = listSessionsFromDb(db, cutoff)
|
|
342
|
+
console.log(`Found ${allSessions.length} sessions in the last ${days} days.`)
|
|
343
|
+
console.log()
|
|
344
|
+
|
|
345
|
+
if (allSessions.length === 0) {
|
|
346
|
+
console.log("No sessions to process.")
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Group by directory for display
|
|
351
|
+
const byDir = new Map<string, DbSession[]>()
|
|
352
|
+
for (const s of allSessions) {
|
|
353
|
+
if (!byDir.has(s.directory)) byDir.set(s.directory, [])
|
|
354
|
+
byDir.get(s.directory)!.push(s)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
console.log(`Projects:`)
|
|
358
|
+
for (const [dir, sessions] of byDir) {
|
|
359
|
+
console.log(` ${dir} (${sessions.length} sessions)`)
|
|
360
|
+
}
|
|
361
|
+
console.log()
|
|
362
|
+
|
|
363
|
+
// Track which memory dirs we need to index at the end
|
|
364
|
+
const memoryDirs = new Map<string, string>() // memoryDir -> collectionName
|
|
365
|
+
|
|
366
|
+
let sessionNum = 0
|
|
367
|
+
let totalTurns = 0
|
|
368
|
+
let totalSummarized = 0
|
|
369
|
+
|
|
370
|
+
for (const session of allSessions) {
|
|
371
|
+
sessionNum++
|
|
372
|
+
const sessionDir = session.directory
|
|
373
|
+
const memsearchDir = join(sessionDir, ".memsearch")
|
|
374
|
+
const memoryDir = join(memsearchDir, "memory")
|
|
375
|
+
const collectionName = deriveCollectionName(sessionDir)
|
|
376
|
+
memoryDirs.set(memoryDir, collectionName)
|
|
377
|
+
|
|
378
|
+
await mkdir(memoryDir, { recursive: true })
|
|
379
|
+
|
|
380
|
+
const titleDisplay = session.title.length > 50
|
|
381
|
+
? session.title.slice(0, 50) + "..."
|
|
382
|
+
: session.title
|
|
383
|
+
|
|
384
|
+
// Read messages from DB
|
|
385
|
+
const messages = getSessionMessages(db, session.id)
|
|
386
|
+
|
|
387
|
+
if (messages.length < 2) {
|
|
388
|
+
console.log(` [${sessionNum}/${allSessions.length}] "${titleDisplay}" — ${messages.length} messages, skipping`)
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Split into turns
|
|
393
|
+
const turns = splitIntoTurns(messages)
|
|
394
|
+
const substantiveTurns = turns.filter((t) => {
|
|
395
|
+
const userText = getUserText(t)
|
|
396
|
+
return userText.length >= 10 && t.length >= 2
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
if (substantiveTurns.length === 0) {
|
|
400
|
+
console.log(` [${sessionNum}/${allSessions.length}] "${titleDisplay}" — no substantive turns, skipping`)
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
console.log(` [${sessionNum}/${allSessions.length}] "${titleDisplay}" — ${substantiveTurns.length} turns`)
|
|
405
|
+
|
|
406
|
+
const sessionDate = formatDate(session.time_created)
|
|
407
|
+
const sessionTime = formatTime(session.time_created)
|
|
408
|
+
const memoryFile = join(memoryDir, `${sessionDate}.md`)
|
|
409
|
+
|
|
410
|
+
// Write session heading
|
|
411
|
+
await appendFile(memoryFile, `\n## Session ${sessionTime} — ${session.title}\n\n`)
|
|
412
|
+
|
|
413
|
+
// Process each turn
|
|
414
|
+
for (let turnIdx = 0; turnIdx < substantiveTurns.length; turnIdx++) {
|
|
415
|
+
const turn = substantiveTurns[turnIdx]
|
|
416
|
+
totalTurns++
|
|
417
|
+
|
|
418
|
+
const turnTime = turn[0].info.time?.created
|
|
419
|
+
? formatTime(turn[0].info.time.created)
|
|
420
|
+
: sessionTime
|
|
421
|
+
|
|
422
|
+
const transcript = formatTurnTranscript(turn)
|
|
423
|
+
if (!transcript || transcript.split("\n").length < 3) continue
|
|
424
|
+
|
|
425
|
+
// Summarize via opencode run (separate process per turn, no memory accumulation)
|
|
426
|
+
const tempFile = join(TEMP_DIR, `turn-${sessionNum}-${turnIdx}.txt`)
|
|
427
|
+
let summary = ""
|
|
428
|
+
try {
|
|
429
|
+
summary = await summarizeWithOpencode(transcript, tempFile)
|
|
430
|
+
if (summary) totalSummarized++
|
|
431
|
+
} catch {
|
|
432
|
+
// LLM failed
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!summary) {
|
|
436
|
+
summary = formatConciseSummary(turn)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!summary) continue
|
|
440
|
+
|
|
441
|
+
const entry = `### ${turnTime}\n<!-- session:${session.id} -->\n${summary}\n\n`
|
|
442
|
+
await appendFile(memoryFile, entry)
|
|
443
|
+
|
|
444
|
+
// Print progress on long sessions
|
|
445
|
+
if (substantiveTurns.length > 5 && (turnIdx + 1) % 5 === 0) {
|
|
446
|
+
process.stdout.write(` (${turnIdx + 1}/${substantiveTurns.length} turns)\n`)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
console.log()
|
|
452
|
+
console.log(`Processed ${totalTurns} turns, summarized ${totalSummarized} with LLM.`)
|
|
453
|
+
console.log()
|
|
454
|
+
|
|
455
|
+
// Index all memory directories
|
|
456
|
+
for (const [memDir, collectionName] of memoryDirs) {
|
|
457
|
+
console.log(`Indexing ${memDir} (collection: ${collectionName})...`)
|
|
458
|
+
try {
|
|
459
|
+
const fullArgs = [...memsearchCmd, "index", memDir, "--collection", collectionName]
|
|
460
|
+
await $`${fullArgs}`.nothrow().quiet()
|
|
461
|
+
console.log(` Done.`)
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error(` Failed to index: ${err}`)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
console.log()
|
|
468
|
+
console.log("Seeding complete!")
|
|
469
|
+
} finally {
|
|
470
|
+
db.close()
|
|
471
|
+
// Clean up temp dir
|
|
472
|
+
try {
|
|
473
|
+
await $`rm -rf ${TEMP_DIR}`.nothrow().quiet()
|
|
474
|
+
} catch {}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
main().catch((err) => {
|
|
479
|
+
console.error("Fatal error:", err)
|
|
480
|
+
process.exit(1)
|
|
481
|
+
})
|