@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 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("[Human]") || l.startsWith("[Assistant]"))
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 [Human] and [Assistant] labels. When turn_id is present, " +
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zilliz/memsearch-opencode",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "memsearch plugin for OpenCode — semantic memory search across sessions",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -1,14 +1,15 @@
1
- You are a third-person note-taker. You will receive a transcript of ONE conversation turn between a human and {{AGENT_NAME}}.
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 the human's question, do NOT give suggestions, do NOT offer help. ONLY record what occurred.
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-6 bullet points, each starting with '- '. NOTHING else.
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: '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
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 get_small_model() -> str:
61
- """Read small_model from opencode.json config (fallback to model, then empty)."""
62
- config_paths = [
63
- os.path.expanduser("~/.config/opencode/opencode.json"),
64
- "opencode.json",
65
- ]
66
- for p in config_paths:
67
- if os.path.exists(p):
68
- try:
69
- with open(p, encoding="utf-8") as f:
70
- cfg = json.load(f)
71
- return cfg.get("small_model", cfg.get("model", ""))
72
- except Exception:
73
- pass
74
- return ""
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
- isolated = os.path.expanduser("~/.codex/tmp/opencode-memsearch-summarize/opencode")
128
- os.makedirs(isolated, exist_ok=True)
129
- # Copy opencode.json (provider config) but NOT plugins/
130
- src = os.path.expanduser("~/.config/opencode/opencode.json")
131
- dst = os.path.join(isolated, "opencode.json")
132
- if os.path.islink(dst) or (os.path.lexists(dst) and not os.path.isfile(dst)):
133
- with contextlib.suppress(OSError):
134
- os.remove(dst)
135
- if os.path.exists(src) and not os.path.exists(dst):
136
- shutil.copy2(src, dst)
137
- return os.path.dirname(isolated)
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-6 bullet points. Write in third person. Output ONLY bullet points."
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(turn_text: str, small_model: str, memsearch_cmd: str | None = None) -> str | None:
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
- summary = summarize_with_llm(turn_text, small_model, memsearch_cmd)
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
- isolated = home / ".codex" / "tmp" / "opencode-memsearch-maintenance" / "opencode"
287
+ root = home / ".codex" / "tmp" / "opencode-memsearch-maintenance"
288
+ isolated = root / "opencode"
60
289
  isolated.mkdir(parents=True, exist_ok=True)
61
- src = home / ".config" / "opencode" / "opencode.json"
62
- if src.is_file():
63
- shutil.copy2(src, isolated / "opencode.json")
64
- return isolated
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 = "[Human]" if message.role == "user" else "[Assistant]"
61
+ label = "[User]" if message.role == "user" else "[Assistant]"
55
62
  text = message.text.strip()
56
- if len(text) > max_chars:
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
- elif part_type == "tool" and part_data.get("state"):
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
- Start every user-facing answer with:
9
-
10
- > This is MemSearch memory config, not OpenCode's own memory config.
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. After any answer, make clear that this is MemSearch memory configuration, not OpenCode's own memory/config system.
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.