@zilliz/memsearch-opencode 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/package.json +4 -2
- package/prompts/summarize.txt +14 -0
- package/scripts/capture-daemon.py +71 -29
package/README.md
CHANGED
|
@@ -30,6 +30,8 @@ uv tool install 'memsearch[onnx]'
|
|
|
30
30
|
}
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
> Windows note: the current plugin shells out to `bash` and `python3` helper scripts. Plain Windows installs are not supported yet; use WSL2 (recommended) or a POSIX-compatible shell such as Git Bash. See issue #387.
|
|
34
|
+
|
|
33
35
|
### Install from Source (development)
|
|
34
36
|
|
|
35
37
|
```bash
|
|
@@ -73,6 +75,17 @@ OpenCode Session
|
|
|
73
75
|
└── memory_transcript ──→ parse-transcript.py (SQLite reader)
|
|
74
76
|
```
|
|
75
77
|
|
|
78
|
+
## Verify It Works
|
|
79
|
+
|
|
80
|
+
After restarting OpenCode, chat for a few turns in a project and confirm memory capture is happening:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ls .memsearch/memory/
|
|
84
|
+
cat .memsearch/memory/$(date +%Y-%m-%d).md
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If the plugin is active, daily markdown files should appear and grow as conversations finish.
|
|
88
|
+
|
|
76
89
|
## Recall Memories
|
|
77
90
|
|
|
78
91
|
**Manual invocation** — explicitly invoke the skill with a query:
|
|
@@ -130,6 +143,14 @@ memsearch config set embedding.provider openai
|
|
|
130
143
|
export OPENAI_API_KEY=sk-...
|
|
131
144
|
```
|
|
132
145
|
|
|
146
|
+
To override only the OpenCode capture summarization model:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
memsearch config set plugins.opencode.summarize.model anthropic/claude-haiku
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Leave it empty or unset to keep the current `small_model` / plugin default behavior. This setting does not fall back to `llm.model`.
|
|
153
|
+
|
|
133
154
|
## How It Works
|
|
134
155
|
|
|
135
156
|
1. **Capture**: After each conversation turn, the plugin extracts the user+assistant exchange, summarizes it via LLM, and appends to a daily markdown file.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zilliz/memsearch-opencode",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "memsearch plugin for OpenCode — semantic memory search across sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"index.ts",
|
|
12
|
-
"scripts
|
|
12
|
+
"scripts/*.py",
|
|
13
|
+
"scripts/*.sh",
|
|
13
14
|
"skills/",
|
|
15
|
+
"prompts/",
|
|
14
16
|
"README.md"
|
|
15
17
|
],
|
|
16
18
|
"keywords": [
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
You are a third-person note-taker. You will receive a transcript of ONE conversation turn between a human and {{AGENT_NAME}}.
|
|
2
|
+
|
|
3
|
+
Your job is to record what happened as factual third-person notes. You are an EXTERNAL OBSERVER — you are NOT {{AGENT_NAME}}, NOT an assistant. Do NOT answer the human's question, do NOT give suggestions, do NOT offer help. ONLY record what occurred.
|
|
4
|
+
|
|
5
|
+
Output 2-6 bullet points, each starting with '- '. NOTHING else.
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- Write in third person: 'User asked...', '{{AGENT_NAME}} read file X', '{{AGENT_NAME}} ran command Y'
|
|
9
|
+
- First bullet: what the user asked or wanted (one sentence)
|
|
10
|
+
- Remaining bullets: what was done — tools called, files read/edited, commands run, key findings
|
|
11
|
+
- Be specific: mention file names, function names, tool names, and concrete outcomes
|
|
12
|
+
- Do NOT answer the human's question yourself — just note what was discussed
|
|
13
|
+
- Do NOT add any text before or after the bullet points
|
|
14
|
+
- Write in the same language as the human's message in the transcript
|
|
@@ -11,19 +11,26 @@ extracts the last turn, summarizes it as bullet points, and writes to
|
|
|
11
11
|
It also triggers memsearch indexing after writing.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
import
|
|
14
|
+
import argparse
|
|
15
|
+
import contextlib
|
|
15
16
|
import json
|
|
16
|
-
import sys
|
|
17
17
|
import os
|
|
18
|
-
import
|
|
18
|
+
import shlex
|
|
19
19
|
import shutil
|
|
20
|
-
import subprocess
|
|
21
|
-
import argparse
|
|
22
20
|
import signal
|
|
21
|
+
import sqlite3
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
23
25
|
from datetime import datetime
|
|
24
26
|
from pathlib import Path
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def split_memsearch_cmd(memsearch_cmd):
|
|
30
|
+
"""Split the configured memsearch command preserving quoted arguments."""
|
|
31
|
+
return shlex.split(memsearch_cmd)
|
|
32
|
+
|
|
33
|
+
|
|
27
34
|
def get_small_model():
|
|
28
35
|
"""Read small_model from opencode.json config (fallback to model, then empty)."""
|
|
29
36
|
config_paths = [
|
|
@@ -41,6 +48,20 @@ def get_small_model():
|
|
|
41
48
|
return ""
|
|
42
49
|
|
|
43
50
|
|
|
51
|
+
def get_plugin_summarize_model(memsearch_cmd=None):
|
|
52
|
+
"""Read the memsearch OpenCode summarize model override."""
|
|
53
|
+
if not memsearch_cmd:
|
|
54
|
+
return ""
|
|
55
|
+
try:
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
[*split_memsearch_cmd(memsearch_cmd), "config", "get", "plugins.opencode.summarize.model"],
|
|
58
|
+
capture_output=True, text=True, timeout=5,
|
|
59
|
+
)
|
|
60
|
+
return result.stdout.strip()
|
|
61
|
+
except Exception:
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
|
|
44
65
|
def ensure_isolated_config():
|
|
45
66
|
"""Create isolated config dir without plugins/ to prevent recursion."""
|
|
46
67
|
isolated = "/tmp/opencode-memsearch-summarize/opencode"
|
|
@@ -48,31 +69,56 @@ def ensure_isolated_config():
|
|
|
48
69
|
# Copy opencode.json (provider config) but NOT plugins/
|
|
49
70
|
src = os.path.expanduser("~/.config/opencode/opencode.json")
|
|
50
71
|
dst = os.path.join(isolated, "opencode.json")
|
|
72
|
+
if os.path.islink(dst) or (os.path.lexists(dst) and not os.path.isfile(dst)):
|
|
73
|
+
with contextlib.suppress(OSError):
|
|
74
|
+
os.remove(dst)
|
|
51
75
|
if os.path.exists(src) and not os.path.exists(dst):
|
|
52
76
|
shutil.copy2(src, dst)
|
|
53
77
|
return os.path.dirname(isolated) # /tmp/opencode-memsearch-summarize
|
|
54
78
|
|
|
55
79
|
|
|
56
|
-
def
|
|
80
|
+
def _load_summarize_prompt(agent_name, memsearch_cmd=None):
|
|
81
|
+
"""Load summarization prompt: user custom > plugin built-in > inline fallback."""
|
|
82
|
+
# Try user-custom prompt via config
|
|
83
|
+
if memsearch_cmd:
|
|
84
|
+
try:
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
[*split_memsearch_cmd(memsearch_cmd), "config", "get", "prompts.summarize"],
|
|
87
|
+
capture_output=True, text=True, timeout=5,
|
|
88
|
+
)
|
|
89
|
+
custom_path = result.stdout.strip()
|
|
90
|
+
if custom_path and os.path.isfile(custom_path):
|
|
91
|
+
template = Path(custom_path).read_text(encoding="utf-8")
|
|
92
|
+
return template.replace("{{AGENT_NAME}}", agent_name)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
# Plugin built-in template
|
|
97
|
+
builtin = Path(__file__).resolve().parent.parent / "prompts" / "summarize.txt"
|
|
98
|
+
if builtin.is_file():
|
|
99
|
+
template = builtin.read_text(encoding="utf-8")
|
|
100
|
+
return template.replace("{{AGENT_NAME}}", agent_name)
|
|
101
|
+
|
|
102
|
+
# Inline fallback
|
|
103
|
+
return (
|
|
104
|
+
"You are a third-person note-taker. Summarize the transcript as "
|
|
105
|
+
"2-6 bullet points. Write in third person. Output ONLY bullet points."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def summarize_with_llm(turn_text, small_model, memsearch_cmd=None):
|
|
57
110
|
"""Summarize using opencode run in isolated env (no plugins -> no recursion)."""
|
|
58
|
-
|
|
59
|
-
# the LLM treating the instruction itself as the conversation content.
|
|
60
|
-
# Clear delimiters (===) separate the transcript from the task.
|
|
111
|
+
system_prompt = _load_summarize_prompt("OpenCode", memsearch_cmd)
|
|
61
112
|
full_prompt = (
|
|
62
|
-
f"
|
|
63
|
-
f"{turn_text}
|
|
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."
|
|
113
|
+
f"{system_prompt}\n\n"
|
|
114
|
+
f"Transcript:\n{turn_text}"
|
|
70
115
|
)
|
|
71
116
|
isolated_dir = ensure_isolated_config()
|
|
72
117
|
|
|
118
|
+
summarize_model = get_plugin_summarize_model(memsearch_cmd) or small_model
|
|
73
119
|
cmd = ["opencode", "run"]
|
|
74
|
-
if
|
|
75
|
-
cmd += ["-m",
|
|
120
|
+
if summarize_model:
|
|
121
|
+
cmd += ["-m", summarize_model]
|
|
76
122
|
cmd.append(full_prompt)
|
|
77
123
|
|
|
78
124
|
try:
|
|
@@ -89,7 +135,7 @@ def summarize_with_llm(turn_text, small_model):
|
|
|
89
135
|
output = result.stdout.strip()
|
|
90
136
|
# Extract bullet points (skip any opencode run header lines)
|
|
91
137
|
lines = output.split("\n")
|
|
92
|
-
bullets = [
|
|
138
|
+
bullets = [line for line in lines if line.strip().startswith("- ")]
|
|
93
139
|
if bullets:
|
|
94
140
|
return "\n".join(bullets)
|
|
95
141
|
except Exception:
|
|
@@ -239,7 +285,7 @@ def main():
|
|
|
239
285
|
|
|
240
286
|
db_path = get_db_path()
|
|
241
287
|
if not os.path.exists(db_path):
|
|
242
|
-
|
|
288
|
+
sys.stderr.write(f"OpenCode database not found at {db_path}\n")
|
|
243
289
|
sys.exit(1)
|
|
244
290
|
|
|
245
291
|
memory_dir = os.path.join(args.project_dir, ".memsearch", "memory")
|
|
@@ -252,10 +298,8 @@ def main():
|
|
|
252
298
|
|
|
253
299
|
# Clean up on exit
|
|
254
300
|
def cleanup(signum=None, frame=None):
|
|
255
|
-
|
|
301
|
+
with contextlib.suppress(OSError):
|
|
256
302
|
os.remove(pid_file)
|
|
257
|
-
except OSError:
|
|
258
|
-
pass
|
|
259
303
|
sys.exit(0)
|
|
260
304
|
|
|
261
305
|
signal.signal(signal.SIGTERM, cleanup)
|
|
@@ -266,10 +310,8 @@ def main():
|
|
|
266
310
|
state_file = os.path.join(args.project_dir, ".memsearch", ".last_msg_time")
|
|
267
311
|
last_msg_time = 0
|
|
268
312
|
if os.path.exists(state_file):
|
|
269
|
-
|
|
270
|
-
last_msg_time = int(
|
|
271
|
-
except (ValueError, OSError):
|
|
272
|
-
pass
|
|
313
|
+
with contextlib.suppress(ValueError, OSError), open(state_file) as f:
|
|
314
|
+
last_msg_time = int(f.read().strip())
|
|
273
315
|
|
|
274
316
|
while True:
|
|
275
317
|
try:
|
|
@@ -279,7 +321,7 @@ def main():
|
|
|
279
321
|
for session_id, turn_text, msg_time in new_turns:
|
|
280
322
|
if turn_text and len(turn_text) > 10:
|
|
281
323
|
# Summarize with LLM, fallback to raw text
|
|
282
|
-
summary = summarize_with_llm(turn_text, small_model)
|
|
324
|
+
summary = summarize_with_llm(turn_text, small_model, args.memsearch_cmd)
|
|
283
325
|
write_capture(memory_dir, summary if summary else turn_text, session_id, db_path)
|
|
284
326
|
if msg_time > last_msg_time:
|
|
285
327
|
last_msg_time = msg_time
|