@zilliz/memsearch-opencode 0.3.2 → 0.3.4
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/index.ts +2 -2
- package/package.json +1 -1
- package/prompts/summarize.txt +10 -9
- package/scripts/capture-daemon.py +262 -33
- package/scripts/maintenance-runner.py +245 -8
- package/scripts/opencode_turns.py +10 -32
- package/skills/memory-config/SKILL.md +50 -4
package/index.ts
CHANGED
|
@@ -93,7 +93,7 @@ function getRecentMemories(
|
|
|
93
93
|
try {
|
|
94
94
|
const content = readFileSync(join(memDir, file), "utf-8");
|
|
95
95
|
const lines = content.split("\n")
|
|
96
|
-
.filter((l) => /^#{2,4}\s/.test(l) || l.startsWith("- ") || l.startsWith("[
|
|
96
|
+
.filter((l) => /^#{2,4}\s/.test(l) || l.startsWith("- ") || l.startsWith("[User]") || l.startsWith("[Assistant]"))
|
|
97
97
|
.slice(0, maxLinesPerFile);
|
|
98
98
|
if (lines.length > 0) {
|
|
99
99
|
summary.push(`[${file}]`, ...lines);
|
|
@@ -301,7 +301,7 @@ const MemsearchPlugin: Plugin = async ({ project, directory, worktree }) => {
|
|
|
301
301
|
"Retrieve the original conversation from a past OpenCode session. " +
|
|
302
302
|
"Use after memory_get when the expanded result contains a session anchor " +
|
|
303
303
|
"(<!-- session:ID turn:ID db:PATH -->). Returns the formatted " +
|
|
304
|
-
"dialogue with [
|
|
304
|
+
"dialogue with [User] and [Assistant] labels. When turn_id is present, " +
|
|
305
305
|
"the tool returns the target turn plus surrounding context.",
|
|
306
306
|
args: {
|
|
307
307
|
session_id: tool.schema.string().describe("The session ID from the anchor comment"),
|
package/package.json
CHANGED
package/prompts/summarize.txt
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
You are a third-person note-taker. You will receive a transcript of ONE conversation turn between
|
|
1
|
+
You are a third-person note-taker. You will receive a transcript of ONE conversation turn between User and {{AGENT_NAME}}.
|
|
2
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
|
|
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 User's question, do NOT give suggestions, do NOT offer help. ONLY record what occurred.
|
|
4
4
|
|
|
5
|
-
Output 2-
|
|
5
|
+
Output 2-10 bullet points, each starting with '- '. NOTHING else.
|
|
6
|
+
Mandatory language rule: write every bullet in the same primary language as the [User] text. If User mixes languages, use the dominant user-facing language.
|
|
6
7
|
|
|
7
8
|
Rules:
|
|
8
|
-
- Write in third person
|
|
9
|
-
- First bullet: what
|
|
10
|
-
- Remaining bullets: what was done
|
|
11
|
-
- Be specific: mention
|
|
12
|
-
-
|
|
9
|
+
- Write in third person and call the user 'User'
|
|
10
|
+
- First bullet: what User asked or wanted (one sentence)
|
|
11
|
+
- Remaining bullets: what was done, found, changed, configured, tested, explained, decided, or could not be completed
|
|
12
|
+
- Be specific when useful: mention important files read or edited, searches or research performed, refactors, commands or tests run, key findings, and concrete outcomes
|
|
13
|
+
- Prefer the final user-visible outcome over low-level transcript mechanics
|
|
14
|
+
- Do NOT answer User's question yourself — just note what was discussed
|
|
13
15
|
- 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
|
|
@@ -21,7 +21,6 @@ import json
|
|
|
21
21
|
import os
|
|
22
22
|
import re
|
|
23
23
|
import shlex
|
|
24
|
-
import shutil
|
|
25
24
|
import signal
|
|
26
25
|
import sqlite3
|
|
27
26
|
import subprocess
|
|
@@ -57,21 +56,238 @@ def split_memsearch_cmd(memsearch_cmd: str) -> list[str]:
|
|
|
57
56
|
return shlex.split(memsearch_cmd)
|
|
58
57
|
|
|
59
58
|
|
|
60
|
-
def
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
59
|
+
def _strip_jsonc(text: str) -> str:
|
|
60
|
+
"""Remove JSONC comments and trailing commas while preserving string contents."""
|
|
61
|
+
out: list[str] = []
|
|
62
|
+
i = 0
|
|
63
|
+
in_string = False
|
|
64
|
+
string_quote = ""
|
|
65
|
+
escaped = False
|
|
66
|
+
while i < len(text):
|
|
67
|
+
char = text[i]
|
|
68
|
+
nxt = text[i + 1] if i + 1 < len(text) else ""
|
|
69
|
+
if in_string:
|
|
70
|
+
out.append(char)
|
|
71
|
+
if escaped:
|
|
72
|
+
escaped = False
|
|
73
|
+
elif char == "\\":
|
|
74
|
+
escaped = True
|
|
75
|
+
elif char == string_quote:
|
|
76
|
+
in_string = False
|
|
77
|
+
i += 1
|
|
78
|
+
continue
|
|
79
|
+
if char in {'"', "'"}:
|
|
80
|
+
in_string = True
|
|
81
|
+
string_quote = char
|
|
82
|
+
out.append(char)
|
|
83
|
+
i += 1
|
|
84
|
+
continue
|
|
85
|
+
if char == "/" and nxt == "/":
|
|
86
|
+
i += 2
|
|
87
|
+
while i < len(text) and text[i] not in "\r\n":
|
|
88
|
+
i += 1
|
|
89
|
+
continue
|
|
90
|
+
if char == "/" and nxt == "*":
|
|
91
|
+
i += 2
|
|
92
|
+
while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
|
|
93
|
+
i += 1
|
|
94
|
+
i += 2
|
|
95
|
+
continue
|
|
96
|
+
out.append(char)
|
|
97
|
+
i += 1
|
|
98
|
+
|
|
99
|
+
without_comments = "".join(out)
|
|
100
|
+
out = []
|
|
101
|
+
i = 0
|
|
102
|
+
in_string = False
|
|
103
|
+
string_quote = ""
|
|
104
|
+
escaped = False
|
|
105
|
+
while i < len(without_comments):
|
|
106
|
+
char = without_comments[i]
|
|
107
|
+
if in_string:
|
|
108
|
+
out.append(char)
|
|
109
|
+
if escaped:
|
|
110
|
+
escaped = False
|
|
111
|
+
elif char == "\\":
|
|
112
|
+
escaped = True
|
|
113
|
+
elif char == string_quote:
|
|
114
|
+
in_string = False
|
|
115
|
+
i += 1
|
|
116
|
+
continue
|
|
117
|
+
if char in {'"', "'"}:
|
|
118
|
+
in_string = True
|
|
119
|
+
string_quote = char
|
|
120
|
+
out.append(char)
|
|
121
|
+
i += 1
|
|
122
|
+
continue
|
|
123
|
+
if char == ",":
|
|
124
|
+
j = i + 1
|
|
125
|
+
while j < len(without_comments) and without_comments[j].isspace():
|
|
126
|
+
j += 1
|
|
127
|
+
if j < len(without_comments) and without_comments[j] in "]}":
|
|
128
|
+
i += 1
|
|
129
|
+
continue
|
|
130
|
+
out.append(char)
|
|
131
|
+
i += 1
|
|
132
|
+
return "".join(out)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _read_jsonc_config(path: Path) -> dict:
|
|
136
|
+
try:
|
|
137
|
+
data = json.loads(_strip_jsonc(path.read_text(encoding="utf-8")))
|
|
138
|
+
except Exception:
|
|
139
|
+
return {}
|
|
140
|
+
return data if isinstance(data, dict) else {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _deep_merge_config(base: dict, override: dict) -> dict:
|
|
144
|
+
result = dict(base)
|
|
145
|
+
for key, value in override.items():
|
|
146
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
147
|
+
result[key] = _deep_merge_config(result[key], value)
|
|
148
|
+
else:
|
|
149
|
+
result[key] = value
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _rewrite_relative_file_refs(value, config_dir: Path):
|
|
154
|
+
if isinstance(value, dict):
|
|
155
|
+
return {key: _rewrite_relative_file_refs(item, config_dir) for key, item in value.items()}
|
|
156
|
+
if isinstance(value, list):
|
|
157
|
+
return [_rewrite_relative_file_refs(item, config_dir) for item in value]
|
|
158
|
+
if not isinstance(value, str):
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
def replace(match: re.Match[str]) -> str:
|
|
162
|
+
file_ref = match.group(1)
|
|
163
|
+
if file_ref.startswith("~/") or os.path.isabs(file_ref):
|
|
164
|
+
return match.group(0)
|
|
165
|
+
return "{file:" + str((config_dir / file_ref).resolve()) + "}"
|
|
166
|
+
|
|
167
|
+
return re.sub(r"\{file:([^}]+)\}", replace, value)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _load_opencode_config_file(path: Path) -> dict:
|
|
171
|
+
cfg = _read_jsonc_config(path)
|
|
172
|
+
if not cfg:
|
|
173
|
+
return {}
|
|
174
|
+
return _rewrite_relative_file_refs(cfg, path.parent)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _opencode_global_config_dir() -> Path:
|
|
178
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
179
|
+
if xdg_config:
|
|
180
|
+
return Path(xdg_config).expanduser() / "opencode"
|
|
181
|
+
return Path.home() / ".config" / "opencode"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _env_flag_enabled(name: str) -> bool:
|
|
185
|
+
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes"}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _opencode_project_config_files(project_dir: Path) -> list[Path]:
|
|
189
|
+
found: list[Path] = []
|
|
190
|
+
current = project_dir.resolve()
|
|
191
|
+
while True:
|
|
192
|
+
for filename in ("opencode.jsonc", "opencode.json"):
|
|
193
|
+
candidate = current / filename
|
|
194
|
+
if candidate.is_file():
|
|
195
|
+
found.append(candidate)
|
|
196
|
+
parent = current.parent
|
|
197
|
+
if parent == current:
|
|
198
|
+
break
|
|
199
|
+
current = parent
|
|
200
|
+
return list(reversed(found))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _opencode_directory_config_files(project_dir: Path) -> list[Path]:
|
|
204
|
+
dirs: list[Path] = []
|
|
205
|
+
if not _env_flag_enabled("OPENCODE_DISABLE_PROJECT_CONFIG"):
|
|
206
|
+
current = project_dir.resolve()
|
|
207
|
+
while True:
|
|
208
|
+
local = current / ".opencode"
|
|
209
|
+
if local.is_dir() and local not in dirs:
|
|
210
|
+
dirs.append(local)
|
|
211
|
+
parent = current.parent
|
|
212
|
+
if parent == current:
|
|
213
|
+
break
|
|
214
|
+
current = parent
|
|
215
|
+
|
|
216
|
+
home_local = Path.home() / ".opencode"
|
|
217
|
+
if home_local.is_dir() and home_local not in dirs:
|
|
218
|
+
dirs.append(home_local)
|
|
219
|
+
|
|
220
|
+
env_dir = os.environ.get("OPENCODE_CONFIG_DIR", "").strip()
|
|
221
|
+
if env_dir:
|
|
222
|
+
config_dir = Path(env_dir).expanduser()
|
|
223
|
+
if config_dir not in dirs:
|
|
224
|
+
dirs.append(config_dir)
|
|
225
|
+
|
|
226
|
+
files: list[Path] = []
|
|
227
|
+
for directory in dirs:
|
|
228
|
+
for filename in ("opencode.json", "opencode.jsonc"):
|
|
229
|
+
candidate = directory / filename
|
|
230
|
+
if candidate.is_file():
|
|
231
|
+
files.append(candidate)
|
|
232
|
+
return files
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _iter_opencode_config_files(project_dir: str | os.PathLike[str] | None = None) -> list[Path]:
|
|
236
|
+
project = Path(project_dir or os.getcwd()).expanduser().resolve()
|
|
237
|
+
files: list[Path] = []
|
|
238
|
+
|
|
239
|
+
global_dir = _opencode_global_config_dir()
|
|
240
|
+
for filename in ("config.json", "opencode.json", "opencode.jsonc"):
|
|
241
|
+
candidate = global_dir / filename
|
|
242
|
+
if candidate.is_file():
|
|
243
|
+
files.append(candidate)
|
|
244
|
+
|
|
245
|
+
env_config = os.environ.get("OPENCODE_CONFIG", "").strip()
|
|
246
|
+
if env_config:
|
|
247
|
+
candidate = Path(env_config).expanduser()
|
|
248
|
+
if candidate.is_file():
|
|
249
|
+
files.append(candidate)
|
|
250
|
+
|
|
251
|
+
if not _env_flag_enabled("OPENCODE_DISABLE_PROJECT_CONFIG"):
|
|
252
|
+
files.extend(_opencode_project_config_files(project))
|
|
253
|
+
|
|
254
|
+
files.extend(_opencode_directory_config_files(project))
|
|
255
|
+
return files
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def load_opencode_config(project_dir: str | os.PathLike[str] | None = None) -> dict:
|
|
259
|
+
"""Load local OpenCode config sources in OpenCode-compatible precedence order."""
|
|
260
|
+
merged: dict = {}
|
|
261
|
+
for path in _iter_opencode_config_files(project_dir):
|
|
262
|
+
merged = _deep_merge_config(merged, _load_opencode_config_file(path))
|
|
263
|
+
|
|
264
|
+
content = os.environ.get("OPENCODE_CONFIG_CONTENT")
|
|
265
|
+
if content:
|
|
266
|
+
content_dir = Path(project_dir or os.getcwd()).expanduser().resolve()
|
|
267
|
+
cfg = _rewrite_relative_file_refs(_read_jsonc_config_from_text(content), content_dir)
|
|
268
|
+
merged = _deep_merge_config(merged, cfg)
|
|
269
|
+
return merged
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _read_jsonc_config_from_text(text: str) -> dict:
|
|
273
|
+
try:
|
|
274
|
+
data = json.loads(_strip_jsonc(text))
|
|
275
|
+
except Exception:
|
|
276
|
+
return {}
|
|
277
|
+
return data if isinstance(data, dict) else {}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _sanitize_opencode_config(cfg: dict) -> dict:
|
|
281
|
+
sanitized = dict(cfg)
|
|
282
|
+
for key in ("plugin", "plugins", "plugin_origins"):
|
|
283
|
+
sanitized.pop(key, None)
|
|
284
|
+
return sanitized
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_small_model(project_dir: str | os.PathLike[str] | None = None) -> str:
|
|
288
|
+
"""Read small_model from OpenCode config, falling back to model."""
|
|
289
|
+
cfg = load_opencode_config(project_dir)
|
|
290
|
+
return str(cfg.get("small_model", cfg.get("model", "")) or "")
|
|
75
291
|
|
|
76
292
|
|
|
77
293
|
def get_plugin_summarize_model(memsearch_cmd: str | None = None) -> str:
|
|
@@ -122,19 +338,19 @@ def get_plugin_summarize_enabled(memsearch_cmd: str | None = None) -> bool:
|
|
|
122
338
|
return True
|
|
123
339
|
|
|
124
340
|
|
|
125
|
-
def ensure_isolated_config() -> str:
|
|
341
|
+
def ensure_isolated_config(project_dir: str | os.PathLike[str] | None = None) -> str:
|
|
126
342
|
"""Create isolated config dir without plugins/ to prevent recursion."""
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if
|
|
136
|
-
|
|
137
|
-
return
|
|
343
|
+
root = Path.home() / ".codex" / "tmp" / "opencode-memsearch-summarize"
|
|
344
|
+
isolated = root / "opencode"
|
|
345
|
+
isolated.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
for filename in ("config.json", "opencode.json", "opencode.jsonc"):
|
|
347
|
+
stale = isolated / filename
|
|
348
|
+
if stale.is_symlink() or stale.is_file():
|
|
349
|
+
stale.unlink(missing_ok=True)
|
|
350
|
+
cfg = _sanitize_opencode_config(load_opencode_config(project_dir))
|
|
351
|
+
if cfg:
|
|
352
|
+
(isolated / "opencode.json").write_text(json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
353
|
+
return str(root)
|
|
138
354
|
|
|
139
355
|
|
|
140
356
|
def _load_summarize_prompt(agent_name: str, memsearch_cmd: str | None = None) -> str:
|
|
@@ -164,11 +380,19 @@ def _load_summarize_prompt(agent_name: str, memsearch_cmd: str | None = None) ->
|
|
|
164
380
|
# Inline fallback
|
|
165
381
|
return (
|
|
166
382
|
"You are a third-person note-taker. Summarize the transcript as "
|
|
167
|
-
"2-
|
|
383
|
+
"2-10 bullet points. Write in third person. Do NOT answer User's question. "
|
|
384
|
+
"Mandatory language rule: write every bullet in the same primary language as the [User] text. "
|
|
385
|
+
"If User mixes languages, use the dominant user-facing language. "
|
|
386
|
+
"Output ONLY bullet points."
|
|
168
387
|
)
|
|
169
388
|
|
|
170
389
|
|
|
171
|
-
def summarize_with_llm(
|
|
390
|
+
def summarize_with_llm(
|
|
391
|
+
turn_text: str,
|
|
392
|
+
small_model: str,
|
|
393
|
+
memsearch_cmd: str | None = None,
|
|
394
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
395
|
+
) -> str | None:
|
|
172
396
|
"""Summarize using configured provider routing."""
|
|
173
397
|
if not get_plugin_summarize_enabled(memsearch_cmd):
|
|
174
398
|
return None
|
|
@@ -199,7 +423,7 @@ def summarize_with_llm(turn_text: str, small_model: str, memsearch_cmd: str | No
|
|
|
199
423
|
return None
|
|
200
424
|
|
|
201
425
|
# Native path: summarize using opencode run in isolated env (no plugins -> no recursion).
|
|
202
|
-
isolated_dir = ensure_isolated_config()
|
|
426
|
+
isolated_dir = ensure_isolated_config(project_dir)
|
|
203
427
|
|
|
204
428
|
summarize_model = get_plugin_summarize_model(memsearch_cmd) or small_model
|
|
205
429
|
cmd = ["opencode", "run"]
|
|
@@ -212,6 +436,10 @@ def summarize_with_llm(turn_text: str, small_model: str, memsearch_cmd: str | No
|
|
|
212
436
|
cmd,
|
|
213
437
|
env={
|
|
214
438
|
**os.environ,
|
|
439
|
+
"OPENCODE_CONFIG": "",
|
|
440
|
+
"OPENCODE_CONFIG_DIR": "",
|
|
441
|
+
"OPENCODE_CONFIG_CONTENT": "",
|
|
442
|
+
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
|
|
215
443
|
"XDG_CONFIG_HOME": isolated_dir,
|
|
216
444
|
"XDG_DATA_HOME": os.path.join(isolated_dir, "data"),
|
|
217
445
|
"MEMSEARCH_NO_WATCH": "1",
|
|
@@ -488,7 +716,8 @@ def capture_session_turns(
|
|
|
488
716
|
if not capture_exists(memory_dir, session_id, turn.turn_id):
|
|
489
717
|
if not get_plugin_summarize_enabled(memsearch_cmd):
|
|
490
718
|
continue
|
|
491
|
-
|
|
719
|
+
project_dir = Path(memory_dir).resolve().parent.parent
|
|
720
|
+
summary = summarize_with_llm(turn_text, small_model, memsearch_cmd, project_dir)
|
|
492
721
|
write_capture(
|
|
493
722
|
memory_dir,
|
|
494
723
|
summary if summary else turn_text,
|
|
@@ -535,7 +764,7 @@ def main() -> None:
|
|
|
535
764
|
signal.signal(signal.SIGTERM, cleanup)
|
|
536
765
|
signal.signal(signal.SIGINT, cleanup)
|
|
537
766
|
|
|
538
|
-
small_model = get_small_model()
|
|
767
|
+
small_model = get_small_model(args.project_dir)
|
|
539
768
|
turn_db = open_turn_db(args.project_dir)
|
|
540
769
|
tail_turn_cache: dict[str, TailTurnObservation] = {}
|
|
541
770
|
|
|
@@ -12,6 +12,7 @@ import argparse
|
|
|
12
12
|
import contextlib
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
|
+
import re
|
|
15
16
|
import shutil
|
|
16
17
|
import subprocess
|
|
17
18
|
import sys
|
|
@@ -26,6 +27,233 @@ DEFAULT_NATIVE_MODELS = {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
def _strip_jsonc(text: str) -> str:
|
|
31
|
+
"""Remove JSONC comments and trailing commas while preserving string contents."""
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
i = 0
|
|
34
|
+
in_string = False
|
|
35
|
+
string_quote = ""
|
|
36
|
+
escaped = False
|
|
37
|
+
while i < len(text):
|
|
38
|
+
char = text[i]
|
|
39
|
+
nxt = text[i + 1] if i + 1 < len(text) else ""
|
|
40
|
+
if in_string:
|
|
41
|
+
out.append(char)
|
|
42
|
+
if escaped:
|
|
43
|
+
escaped = False
|
|
44
|
+
elif char == "\\":
|
|
45
|
+
escaped = True
|
|
46
|
+
elif char == string_quote:
|
|
47
|
+
in_string = False
|
|
48
|
+
i += 1
|
|
49
|
+
continue
|
|
50
|
+
if char in {'"', "'"}:
|
|
51
|
+
in_string = True
|
|
52
|
+
string_quote = char
|
|
53
|
+
out.append(char)
|
|
54
|
+
i += 1
|
|
55
|
+
continue
|
|
56
|
+
if char == "/" and nxt == "/":
|
|
57
|
+
i += 2
|
|
58
|
+
while i < len(text) and text[i] not in "\r\n":
|
|
59
|
+
i += 1
|
|
60
|
+
continue
|
|
61
|
+
if char == "/" and nxt == "*":
|
|
62
|
+
i += 2
|
|
63
|
+
while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
|
|
64
|
+
i += 1
|
|
65
|
+
i += 2
|
|
66
|
+
continue
|
|
67
|
+
out.append(char)
|
|
68
|
+
i += 1
|
|
69
|
+
|
|
70
|
+
without_comments = "".join(out)
|
|
71
|
+
out = []
|
|
72
|
+
i = 0
|
|
73
|
+
in_string = False
|
|
74
|
+
string_quote = ""
|
|
75
|
+
escaped = False
|
|
76
|
+
while i < len(without_comments):
|
|
77
|
+
char = without_comments[i]
|
|
78
|
+
if in_string:
|
|
79
|
+
out.append(char)
|
|
80
|
+
if escaped:
|
|
81
|
+
escaped = False
|
|
82
|
+
elif char == "\\":
|
|
83
|
+
escaped = True
|
|
84
|
+
elif char == string_quote:
|
|
85
|
+
in_string = False
|
|
86
|
+
i += 1
|
|
87
|
+
continue
|
|
88
|
+
if char in {'"', "'"}:
|
|
89
|
+
in_string = True
|
|
90
|
+
string_quote = char
|
|
91
|
+
out.append(char)
|
|
92
|
+
i += 1
|
|
93
|
+
continue
|
|
94
|
+
if char == ",":
|
|
95
|
+
j = i + 1
|
|
96
|
+
while j < len(without_comments) and without_comments[j].isspace():
|
|
97
|
+
j += 1
|
|
98
|
+
if j < len(without_comments) and without_comments[j] in "]}":
|
|
99
|
+
i += 1
|
|
100
|
+
continue
|
|
101
|
+
out.append(char)
|
|
102
|
+
i += 1
|
|
103
|
+
return "".join(out)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_jsonc_config_from_text(text: str) -> dict:
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(_strip_jsonc(text))
|
|
109
|
+
except Exception:
|
|
110
|
+
return {}
|
|
111
|
+
return data if isinstance(data, dict) else {}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _read_jsonc_config(path: Path) -> dict:
|
|
115
|
+
try:
|
|
116
|
+
return _read_jsonc_config_from_text(path.read_text(encoding="utf-8"))
|
|
117
|
+
except OSError:
|
|
118
|
+
return {}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _deep_merge_config(base: dict, override: dict) -> dict:
|
|
122
|
+
result = dict(base)
|
|
123
|
+
for key, value in override.items():
|
|
124
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
125
|
+
result[key] = _deep_merge_config(result[key], value)
|
|
126
|
+
else:
|
|
127
|
+
result[key] = value
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _rewrite_relative_file_refs(value, config_dir: Path):
|
|
132
|
+
if isinstance(value, dict):
|
|
133
|
+
return {key: _rewrite_relative_file_refs(item, config_dir) for key, item in value.items()}
|
|
134
|
+
if isinstance(value, list):
|
|
135
|
+
return [_rewrite_relative_file_refs(item, config_dir) for item in value]
|
|
136
|
+
if not isinstance(value, str):
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
def replace(match: re.Match[str]) -> str:
|
|
140
|
+
file_ref = match.group(1)
|
|
141
|
+
if file_ref.startswith("~/") or os.path.isabs(file_ref):
|
|
142
|
+
return match.group(0)
|
|
143
|
+
return "{file:" + str((config_dir / file_ref).resolve()) + "}"
|
|
144
|
+
|
|
145
|
+
return re.sub(r"\{file:([^}]+)\}", replace, value)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _load_opencode_config_file(path: Path) -> dict:
|
|
149
|
+
cfg = _read_jsonc_config(path)
|
|
150
|
+
if not cfg:
|
|
151
|
+
return {}
|
|
152
|
+
return _rewrite_relative_file_refs(cfg, path.parent)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _opencode_global_config_dir() -> Path:
|
|
156
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
157
|
+
if xdg_config:
|
|
158
|
+
return Path(xdg_config).expanduser() / "opencode"
|
|
159
|
+
return Path.home() / ".config" / "opencode"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _env_flag_enabled(name: str) -> bool:
|
|
163
|
+
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _opencode_project_config_files(project_dir: Path) -> list[Path]:
|
|
167
|
+
found: list[Path] = []
|
|
168
|
+
current = project_dir.resolve()
|
|
169
|
+
while True:
|
|
170
|
+
for filename in ("opencode.jsonc", "opencode.json"):
|
|
171
|
+
candidate = current / filename
|
|
172
|
+
if candidate.is_file():
|
|
173
|
+
found.append(candidate)
|
|
174
|
+
parent = current.parent
|
|
175
|
+
if parent == current:
|
|
176
|
+
break
|
|
177
|
+
current = parent
|
|
178
|
+
return list(reversed(found))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _opencode_directory_config_files(project_dir: Path) -> list[Path]:
|
|
182
|
+
dirs: list[Path] = []
|
|
183
|
+
if not _env_flag_enabled("OPENCODE_DISABLE_PROJECT_CONFIG"):
|
|
184
|
+
current = project_dir.resolve()
|
|
185
|
+
while True:
|
|
186
|
+
local = current / ".opencode"
|
|
187
|
+
if local.is_dir() and local not in dirs:
|
|
188
|
+
dirs.append(local)
|
|
189
|
+
parent = current.parent
|
|
190
|
+
if parent == current:
|
|
191
|
+
break
|
|
192
|
+
current = parent
|
|
193
|
+
|
|
194
|
+
home_local = Path.home() / ".opencode"
|
|
195
|
+
if home_local.is_dir() and home_local not in dirs:
|
|
196
|
+
dirs.append(home_local)
|
|
197
|
+
|
|
198
|
+
env_dir = os.environ.get("OPENCODE_CONFIG_DIR", "").strip()
|
|
199
|
+
if env_dir:
|
|
200
|
+
config_dir = Path(env_dir).expanduser()
|
|
201
|
+
if config_dir not in dirs:
|
|
202
|
+
dirs.append(config_dir)
|
|
203
|
+
|
|
204
|
+
files: list[Path] = []
|
|
205
|
+
for directory in dirs:
|
|
206
|
+
for filename in ("opencode.json", "opencode.jsonc"):
|
|
207
|
+
candidate = directory / filename
|
|
208
|
+
if candidate.is_file():
|
|
209
|
+
files.append(candidate)
|
|
210
|
+
return files
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _iter_opencode_config_files(project_dir: str | os.PathLike[str] | None = None) -> list[Path]:
|
|
214
|
+
project = Path(project_dir or os.getcwd()).expanduser().resolve()
|
|
215
|
+
files: list[Path] = []
|
|
216
|
+
|
|
217
|
+
global_dir = _opencode_global_config_dir()
|
|
218
|
+
for filename in ("config.json", "opencode.json", "opencode.jsonc"):
|
|
219
|
+
candidate = global_dir / filename
|
|
220
|
+
if candidate.is_file():
|
|
221
|
+
files.append(candidate)
|
|
222
|
+
|
|
223
|
+
env_config = os.environ.get("OPENCODE_CONFIG", "").strip()
|
|
224
|
+
if env_config:
|
|
225
|
+
candidate = Path(env_config).expanduser()
|
|
226
|
+
if candidate.is_file():
|
|
227
|
+
files.append(candidate)
|
|
228
|
+
|
|
229
|
+
if not _env_flag_enabled("OPENCODE_DISABLE_PROJECT_CONFIG"):
|
|
230
|
+
files.extend(_opencode_project_config_files(project))
|
|
231
|
+
|
|
232
|
+
files.extend(_opencode_directory_config_files(project))
|
|
233
|
+
return files
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def load_opencode_config(project_dir: str | os.PathLike[str] | None = None) -> dict:
|
|
237
|
+
"""Load local OpenCode config sources in OpenCode-compatible precedence order."""
|
|
238
|
+
merged: dict = {}
|
|
239
|
+
for path in _iter_opencode_config_files(project_dir):
|
|
240
|
+
merged = _deep_merge_config(merged, _load_opencode_config_file(path))
|
|
241
|
+
|
|
242
|
+
content = os.environ.get("OPENCODE_CONFIG_CONTENT")
|
|
243
|
+
if content:
|
|
244
|
+
content_dir = Path(project_dir or os.getcwd()).expanduser().resolve()
|
|
245
|
+
cfg = _rewrite_relative_file_refs(_read_jsonc_config_from_text(content), content_dir)
|
|
246
|
+
merged = _deep_merge_config(merged, cfg)
|
|
247
|
+
return merged
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _sanitize_opencode_config(cfg: dict) -> dict:
|
|
251
|
+
sanitized = dict(cfg)
|
|
252
|
+
for key in ("plugin", "plugins", "plugin_origins"):
|
|
253
|
+
sanitized.pop(key, None)
|
|
254
|
+
return sanitized
|
|
255
|
+
|
|
256
|
+
|
|
29
257
|
def run_command(cmd: list[str], *, env: dict[str, str], cwd: Path, timeout: int) -> str:
|
|
30
258
|
result = subprocess.run(
|
|
31
259
|
cmd,
|
|
@@ -54,14 +282,19 @@ def extract_task_json_output(output: str) -> str:
|
|
|
54
282
|
return output
|
|
55
283
|
|
|
56
284
|
|
|
57
|
-
def ensure_opencode_isolated_config() -> Path:
|
|
285
|
+
def ensure_opencode_isolated_config(project_dir: str | os.PathLike[str] | None = None) -> Path:
|
|
58
286
|
home = Path.home()
|
|
59
|
-
|
|
287
|
+
root = home / ".codex" / "tmp" / "opencode-memsearch-maintenance"
|
|
288
|
+
isolated = root / "opencode"
|
|
60
289
|
isolated.mkdir(parents=True, exist_ok=True)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
290
|
+
for filename in ("config.json", "opencode.json", "opencode.jsonc"):
|
|
291
|
+
stale = isolated / filename
|
|
292
|
+
if stale.is_symlink() or stale.is_file():
|
|
293
|
+
stale.unlink(missing_ok=True)
|
|
294
|
+
cfg = _sanitize_opencode_config(load_opencode_config(project_dir))
|
|
295
|
+
if cfg:
|
|
296
|
+
(isolated / "opencode.json").write_text(json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
297
|
+
return root
|
|
65
298
|
|
|
66
299
|
|
|
67
300
|
def ensure_memsearch_importable() -> None:
|
|
@@ -143,7 +376,7 @@ def run_native_provider(ctx, prompt: str) -> str:
|
|
|
143
376
|
env = {**os.environ, "MEMSEARCH_NO_WATCH": "1"}
|
|
144
377
|
|
|
145
378
|
if ctx.platform == "claude-code":
|
|
146
|
-
cmd = ["claude", "-p", "--strict-mcp-config", "--no-session-persistence", "--no-chrome"]
|
|
379
|
+
cmd = ["claude", "-p", "--strict-mcp-config", "--tools", "", "--no-session-persistence", "--no-chrome"]
|
|
147
380
|
if model:
|
|
148
381
|
cmd += ["--model", model]
|
|
149
382
|
cmd += [
|
|
@@ -193,11 +426,15 @@ def run_native_provider(ctx, prompt: str) -> str:
|
|
|
193
426
|
return extract_task_json_output(run_command(cmd, env=env, cwd=ctx.project_dir, timeout=120))
|
|
194
427
|
|
|
195
428
|
if ctx.platform == "opencode":
|
|
196
|
-
isolated = ensure_opencode_isolated_config()
|
|
429
|
+
isolated = ensure_opencode_isolated_config(ctx.project_dir)
|
|
197
430
|
cmd = ["opencode", "run"]
|
|
198
431
|
if model:
|
|
199
432
|
cmd += ["-m", model]
|
|
200
433
|
cmd.append(prompt)
|
|
434
|
+
env["OPENCODE_CONFIG"] = ""
|
|
435
|
+
env["OPENCODE_CONFIG_DIR"] = ""
|
|
436
|
+
env["OPENCODE_CONFIG_CONTENT"] = ""
|
|
437
|
+
env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
|
|
201
438
|
env["XDG_CONFIG_HOME"] = str(isolated)
|
|
202
439
|
env["XDG_DATA_HOME"] = str(isolated / "data")
|
|
203
440
|
return run_command(cmd, env=env, cwd=ctx.project_dir, timeout=120)
|
|
@@ -17,6 +17,13 @@ from dataclasses import dataclass, field
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _tail_truncate(text: str, max_chars: int) -> str:
|
|
21
|
+
"""Keep the end of long text, where final conclusions usually appear."""
|
|
22
|
+
if len(text) <= max_chars:
|
|
23
|
+
return text
|
|
24
|
+
return "...(truncated to tail)\n" + text[-max_chars:]
|
|
25
|
+
|
|
26
|
+
|
|
20
27
|
@dataclass
|
|
21
28
|
class OpenCodeMessage:
|
|
22
29
|
"""A single meaningful OpenCode message with rendered text."""
|
|
@@ -51,10 +58,9 @@ class OpenCodeTurn:
|
|
|
51
58
|
f"=== Turn {self.turn_index} ({self.turn_id}) ===",
|
|
52
59
|
]
|
|
53
60
|
for message in self.messages:
|
|
54
|
-
label = "[
|
|
61
|
+
label = "[User]" if message.role == "user" else "[Assistant]"
|
|
55
62
|
text = message.text.strip()
|
|
56
|
-
|
|
57
|
-
text = text[:max_chars] + "\n..."
|
|
63
|
+
text = _tail_truncate(text, max_chars)
|
|
58
64
|
lines.append(f"{label}: {text}")
|
|
59
65
|
lines.append("")
|
|
60
66
|
return "\n".join(lines).strip()
|
|
@@ -283,7 +289,6 @@ def extract_message_text(conn: sqlite3.Connection, message_id: str) -> str:
|
|
|
283
289
|
).fetchall()
|
|
284
290
|
|
|
285
291
|
text_parts: list[str] = []
|
|
286
|
-
tool_parts: list[str] = []
|
|
287
292
|
|
|
288
293
|
for part in parts:
|
|
289
294
|
try:
|
|
@@ -298,36 +303,9 @@ def extract_message_text(conn: sqlite3.Connection, message_id: str) -> str:
|
|
|
298
303
|
text = str(part_data["text"]).strip()
|
|
299
304
|
if text:
|
|
300
305
|
text_parts.append(text)
|
|
301
|
-
|
|
302
|
-
state = part_data.get("state", {})
|
|
303
|
-
status = state.get("status", "unknown")
|
|
304
|
-
tool_name = part_data.get("tool", "unknown")
|
|
305
|
-
|
|
306
|
-
if status == "completed":
|
|
307
|
-
tool_input = state.get("input", {})
|
|
308
|
-
tool_output = state.get("output", "")
|
|
309
|
-
if isinstance(tool_output, str) and len(tool_output) > 300:
|
|
310
|
-
tool_output = tool_output[:300] + "..."
|
|
311
|
-
|
|
312
|
-
input_summary = ""
|
|
313
|
-
if isinstance(tool_input, dict):
|
|
314
|
-
if "command" in tool_input:
|
|
315
|
-
input_summary = f" `{tool_input['command']}`"
|
|
316
|
-
elif "path" in tool_input:
|
|
317
|
-
input_summary = f" {tool_input['path']}"
|
|
318
|
-
elif "query" in tool_input:
|
|
319
|
-
input_summary = f" '{tool_input['query']}'"
|
|
320
|
-
|
|
321
|
-
tool_parts.append(
|
|
322
|
-
f"[Tool: {tool_name}{input_summary}] {tool_output}"
|
|
323
|
-
)
|
|
324
|
-
elif status == "error":
|
|
325
|
-
error = state.get("error", "unknown error")
|
|
326
|
-
tool_parts.append(f"[Tool: {tool_name}] Error: {error}")
|
|
306
|
+
# Skip tool parts; summaries use the readable User/Assistant text.
|
|
327
307
|
|
|
328
308
|
combined = "\n".join(text_parts).strip()
|
|
329
|
-
if tool_parts:
|
|
330
|
-
combined = "\n".join([combined, "\n".join(tool_parts)]).strip()
|
|
331
309
|
return combined
|
|
332
310
|
|
|
333
311
|
|
|
@@ -5,9 +5,9 @@ description: "Diagnose and configure MemSearch memory behavior for the OpenCode
|
|
|
5
5
|
|
|
6
6
|
You are a MemSearch configuration assistant for the OpenCode plugin. This skill manages MemSearch settings only. It is not OpenCode's built-in memory configuration.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
In diagnostic summaries or final answers, state once that this is MemSearch
|
|
9
|
+
memory configuration, not OpenCode's own memory/config system. Do not prepend
|
|
10
|
+
that sentence to every progress update or every paragraph.
|
|
11
11
|
|
|
12
12
|
When this skill is triggered, inspect the user's request text. If there is no concrete request, run a diagnostic. If they ask for a specific setting or change, route the request using the flows below.
|
|
13
13
|
|
|
@@ -31,8 +31,54 @@ memsearch config list --global
|
|
|
31
31
|
memsearch config list --project
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
Check CLI and plugin versions before calling the setup healthy:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
memsearch --version
|
|
38
|
+
uv tool list --show-paths | rg -n 'memsearch|Package|Installed|path'
|
|
39
|
+
curl -fsSL https://pypi.org/pypi/memsearch/json \
|
|
40
|
+
| python3 -c 'import json,sys; print(json.load(sys.stdin)["info"]["version"])'
|
|
41
|
+
```
|
|
42
|
+
|
|
34
43
|
If `memsearch` is unavailable, try `uvx --from memsearch[onnx] memsearch --version`.
|
|
35
44
|
|
|
45
|
+
MemSearch has one shared Python CLI and platform plugins that may be installed
|
|
46
|
+
from different channels:
|
|
47
|
+
|
|
48
|
+
- CLI latest version comes from PyPI package `memsearch`. Update with
|
|
49
|
+
`uv tool install -U "memsearch[onnx]"` or `uv tool upgrade memsearch`.
|
|
50
|
+
- Codex plugin has no independent package/version file. Inspect
|
|
51
|
+
`${CODEX_HOME:-$HOME/.codex}/hooks.json` to find the hook source path, then
|
|
52
|
+
compare that repository with the latest `zilliztech/memsearch` GitHub release:
|
|
53
|
+
`git -C <memsearch-repo> describe --tags --always --dirty` and
|
|
54
|
+
`gh release view --repo zilliztech/memsearch --json tagName,publishedAt,url`.
|
|
55
|
+
Update source installs with `git pull` plus
|
|
56
|
+
`bash plugins/codex/scripts/install.sh`.
|
|
57
|
+
- Claude Code plugin latest marketplace/source version is in
|
|
58
|
+
`plugins/claude-code/.claude-plugin/plugin.json` and
|
|
59
|
+
`.claude-plugin/marketplace.json` in the `zilliztech/memsearch` repo. Check
|
|
60
|
+
the latest source manifest with
|
|
61
|
+
`curl -fsSL https://raw.githubusercontent.com/zilliztech/memsearch/main/plugins/claude-code/.claude-plugin/plugin.json`.
|
|
62
|
+
For marketplace installs, use `claude plugin marketplace update memsearch-plugins`
|
|
63
|
+
then `claude plugin update memsearch`, and restart Claude Code.
|
|
64
|
+
- OpenClaw plugin latest published version comes from
|
|
65
|
+
`clawhub package inspect memsearch`; the source version is
|
|
66
|
+
`plugins/openclaw/package.json`. Update with
|
|
67
|
+
`openclaw plugins install --force clawhub:memsearch`, restore required hook
|
|
68
|
+
permissions, then `openclaw gateway restart`.
|
|
69
|
+
- OpenCode plugin latest published version comes from
|
|
70
|
+
`npm view @zilliz/memsearch-opencode version dist-tags --json`; the source
|
|
71
|
+
version is `plugins/opencode/package.json`. If `~/.config/opencode/opencode.json`
|
|
72
|
+
pins a version, update the pin; otherwise restart OpenCode after package
|
|
73
|
+
refresh.
|
|
74
|
+
|
|
75
|
+
For more detail, fetch the update sections from the public documentation:
|
|
76
|
+
|
|
77
|
+
- Codex: https://zilliztech.github.io/memsearch/platforms/codex/installation/
|
|
78
|
+
- Claude Code: https://zilliztech.github.io/memsearch/platforms/claude-code/installation/
|
|
79
|
+
- OpenClaw: https://zilliztech.github.io/memsearch/platforms/openclaw/installation/
|
|
80
|
+
- OpenCode: https://zilliztech.github.io/memsearch/platforms/opencode/installation/
|
|
81
|
+
|
|
36
82
|
Check memory files:
|
|
37
83
|
|
|
38
84
|
```bash
|
|
@@ -152,6 +198,6 @@ Empty prompt paths mean use the built-in MemSearch prompts. Custom prompt files
|
|
|
152
198
|
|
|
153
199
|
Use `memsearch config set` for changes. After changing anything, show the command, the resolved value, and whether a new session is needed.
|
|
154
200
|
|
|
155
|
-
MemSearch TOML changes are read lazily by the CLI, capture daemon, and maintenance runner, so values such as `plugins.opencode.summarize.*`, `plugins.opencode.project_review.*`, `plugins.opencode.user_profile.*`, `[llm.providers.*]`, `[prompts]`, `milvus.*`, and `embedding.*` usually apply on the next capture, recall, index, or maintenance invocation. Restart OpenCode after `opencode.json` or plugin package changes; if capture behavior still looks stale after TOML edits, restart OpenCode or the capture daemon.
|
|
201
|
+
MemSearch TOML changes are read lazily by the CLI, capture daemon, and maintenance runner, so values such as `plugins.opencode.summarize.*`, `plugins.opencode.project_review.*`, `plugins.opencode.user_profile.*`, `[llm.providers.*]`, `[prompts]`, `milvus.*`, and `embedding.*` usually apply on the next capture, recall, index, or maintenance invocation. Restart OpenCode after `opencode.json` or plugin package changes; if capture behavior still looks stale after TOML edits, restart OpenCode or the capture daemon. In final diagnostic/change summaries, make clear that this is MemSearch memory configuration, not OpenCode's own memory/config system.
|
|
156
202
|
|
|
157
203
|
When useful, remind the user that they can either continue using this `memory-config` skill for guided configuration, or manually run `memsearch config init` for global interactive setup, `memsearch config init --project` for project interactive setup, and `memsearch config set/get/list` for direct CLI changes.
|