@zilliz/memsearch-opencode 0.3.3 → 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/package.json +1 -1
- package/scripts/capture-daemon.py +258 -32
- package/scripts/maintenance-runner.py +245 -8
package/package.json
CHANGED
|
@@ -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:
|
|
@@ -171,7 +387,12 @@ def _load_summarize_prompt(agent_name: str, memsearch_cmd: str | None = None) ->
|
|
|
171
387
|
)
|
|
172
388
|
|
|
173
389
|
|
|
174
|
-
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:
|
|
175
396
|
"""Summarize using configured provider routing."""
|
|
176
397
|
if not get_plugin_summarize_enabled(memsearch_cmd):
|
|
177
398
|
return None
|
|
@@ -202,7 +423,7 @@ def summarize_with_llm(turn_text: str, small_model: str, memsearch_cmd: str | No
|
|
|
202
423
|
return None
|
|
203
424
|
|
|
204
425
|
# Native path: summarize using opencode run in isolated env (no plugins -> no recursion).
|
|
205
|
-
isolated_dir = ensure_isolated_config()
|
|
426
|
+
isolated_dir = ensure_isolated_config(project_dir)
|
|
206
427
|
|
|
207
428
|
summarize_model = get_plugin_summarize_model(memsearch_cmd) or small_model
|
|
208
429
|
cmd = ["opencode", "run"]
|
|
@@ -215,6 +436,10 @@ def summarize_with_llm(turn_text: str, small_model: str, memsearch_cmd: str | No
|
|
|
215
436
|
cmd,
|
|
216
437
|
env={
|
|
217
438
|
**os.environ,
|
|
439
|
+
"OPENCODE_CONFIG": "",
|
|
440
|
+
"OPENCODE_CONFIG_DIR": "",
|
|
441
|
+
"OPENCODE_CONFIG_CONTENT": "",
|
|
442
|
+
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
|
|
218
443
|
"XDG_CONFIG_HOME": isolated_dir,
|
|
219
444
|
"XDG_DATA_HOME": os.path.join(isolated_dir, "data"),
|
|
220
445
|
"MEMSEARCH_NO_WATCH": "1",
|
|
@@ -491,7 +716,8 @@ def capture_session_turns(
|
|
|
491
716
|
if not capture_exists(memory_dir, session_id, turn.turn_id):
|
|
492
717
|
if not get_plugin_summarize_enabled(memsearch_cmd):
|
|
493
718
|
continue
|
|
494
|
-
|
|
719
|
+
project_dir = Path(memory_dir).resolve().parent.parent
|
|
720
|
+
summary = summarize_with_llm(turn_text, small_model, memsearch_cmd, project_dir)
|
|
495
721
|
write_capture(
|
|
496
722
|
memory_dir,
|
|
497
723
|
summary if summary else turn_text,
|
|
@@ -538,7 +764,7 @@ def main() -> None:
|
|
|
538
764
|
signal.signal(signal.SIGTERM, cleanup)
|
|
539
765
|
signal.signal(signal.SIGINT, cleanup)
|
|
540
766
|
|
|
541
|
-
small_model = get_small_model()
|
|
767
|
+
small_model = get_small_model(args.project_dir)
|
|
542
768
|
turn_db = open_turn_db(args.project_dir)
|
|
543
769
|
tail_turn_cache: dict[str, TailTurnObservation] = {}
|
|
544
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)
|