@zilliz/memsearch-opencode 0.3.3 → 0.3.5
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
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
You are distilling reusable skills from {{AGENT_NAME}}'s recent memory journals.
|
|
2
|
+
|
|
3
|
+
Project directory: {{PROJECT_DIR}}
|
|
4
|
+
Input memory directory: {{INPUT_DIR}}
|
|
5
|
+
|
|
6
|
+
A "skill" is procedural memory: a reusable, multi-step procedure for getting a
|
|
7
|
+
recurring task done (how to run the app, how to deploy, how to debug a class of
|
|
8
|
+
error). It is NOT a fact, a decision, or a preference — those belong in
|
|
9
|
+
PROJECT.md and USER.md.
|
|
10
|
+
|
|
11
|
+
You will receive recent memory journal entries and a list of existing skills
|
|
12
|
+
(which you MAY revise). Propose or revise skills only when they clearly earn it.
|
|
13
|
+
|
|
14
|
+
Be conservative. Quality over quantity. Most runs should return an empty list.
|
|
15
|
+
|
|
16
|
+
A NEW workflow qualifies as a candidate ONLY when ALL of these hold:
|
|
17
|
+
- It is a repeatable multi-step procedure, not a one-off action or a fact.
|
|
18
|
+
- It recurs across at least {{MIN_OCCURRENCES}} distinct sessions or days in the
|
|
19
|
+
journals below.
|
|
20
|
+
- It generalizes into a reusable capability. If it is tightly coupled to one
|
|
21
|
+
specific ticket, one specific value, or a one-time condition, do NOT propose it.
|
|
22
|
+
- The recent runs were not immediately corrected, abandoned, or undone (a
|
|
23
|
+
workflow the user kept fighting is not a good skill).
|
|
24
|
+
- It is not already covered by an existing skill.
|
|
25
|
+
|
|
26
|
+
You may also REVISE an existing skill (re-emit it with the SAME name and a
|
|
27
|
+
corrected body) when the recent journals show that procedure has genuinely
|
|
28
|
+
changed — a tool, command, flag, or step is now done differently. Only revise when
|
|
29
|
+
there is clear evidence of change; do not rewrite for style.
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
- Return only a JSON object, with no prose outside JSON.
|
|
33
|
+
- Use {"skills": []} when nothing in the journals qualifies. This is the common case.
|
|
34
|
+
- For each candidate, write a clean, portable SKILL.md body in "body": step-by-step
|
|
35
|
+
instructions an agent can follow. Do not include YAML frontmatter — only the
|
|
36
|
+
markdown body. Keep it concise and concrete.
|
|
37
|
+
- "name" must be a short lowercase slug (letters, digits, dashes), e.g. "run-tests".
|
|
38
|
+
It becomes the skill's command name. Do not reuse an existing skill name.
|
|
39
|
+
- "description" is one line: what the skill does AND when it should trigger.
|
|
40
|
+
Lead with the verbs a user would actually type ("run", "deploy", "debug").
|
|
41
|
+
- "occurrences" is how many distinct sessions/days you saw this workflow.
|
|
42
|
+
- "sources" lists the journal file names the workflow was observed in.
|
|
43
|
+
- Do not copy raw journal text wholesale into the body; abstract it into a procedure.
|
|
44
|
+
|
|
45
|
+
How to write a good skill body (this is what makes the skill usable):
|
|
46
|
+
- Write imperative, numbered steps an agent can follow top to bottom.
|
|
47
|
+
- Be concrete: real commands, file paths, and flags — not vague prose.
|
|
48
|
+
- Keep it focused and short. A skill loads only when triggered, so include just
|
|
49
|
+
what is needed to do this one task well, and push rare detail to the end.
|
|
50
|
+
- State any preconditions, and where useful, when NOT to use the skill.
|
|
51
|
+
- Never bake in secrets, tokens, or one-off values; describe them as placeholders.
|
|
52
|
+
- Make it self-contained: an agent on a clean checkout should be able to follow it.
|
|
53
|
+
- The "description" is the trigger signal — if it does not name the situation and
|
|
54
|
+
the verbs a user would type, the skill will never fire. Spend care on it.
|
|
55
|
+
|
|
56
|
+
JSON examples:
|
|
57
|
+
{"skills":[]}
|
|
58
|
+
|
|
59
|
+
{"skills":[{"name":"run-tests","description":"Run the project's test suite and report failures. Use when asked to run tests, check tests, or verify a change.","body":"## Run the tests\n\n1. ...\n2. ...","occurrences":4,"sources":["2026-06-12.md","2026-06-14.md"],"reason":"Test run procedure recurred across 4 sessions."}]}
|
|
@@ -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:
|
|
@@ -130,7 +363,7 @@ def ensure_memsearch_importable() -> None:
|
|
|
130
363
|
def apply_plugin_prompt_defaults(cfg) -> None:
|
|
131
364
|
plugin_dir = Path(__file__).resolve().parent.parent
|
|
132
365
|
prompts_dir = plugin_dir / "prompts"
|
|
133
|
-
for task in ("project_review", "user_profile"):
|
|
366
|
+
for task in ("project_review", "user_profile", "memory_to_skill"):
|
|
134
367
|
if getattr(cfg.prompts, task, ""):
|
|
135
368
|
continue
|
|
136
369
|
prompt_file = prompts_dir / f"{task}.txt"
|
|
@@ -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 += [
|
|
@@ -155,7 +388,9 @@ def run_native_provider(ctx, prompt: str) -> str:
|
|
|
155
388
|
return run_command(cmd, env=env, cwd=ctx.project_dir, timeout=120)
|
|
156
389
|
|
|
157
390
|
if ctx.platform == "codex":
|
|
158
|
-
with tempfile.NamedTemporaryFile(
|
|
391
|
+
with tempfile.NamedTemporaryFile(
|
|
392
|
+
prefix="memsearch-codex-maintenance-", suffix=".txt", delete=False
|
|
393
|
+
) as output_file:
|
|
159
394
|
output_path = Path(output_file.name)
|
|
160
395
|
cmd = [
|
|
161
396
|
"codex",
|
|
@@ -193,11 +428,15 @@ def run_native_provider(ctx, prompt: str) -> str:
|
|
|
193
428
|
return extract_task_json_output(run_command(cmd, env=env, cwd=ctx.project_dir, timeout=120))
|
|
194
429
|
|
|
195
430
|
if ctx.platform == "opencode":
|
|
196
|
-
isolated = ensure_opencode_isolated_config()
|
|
431
|
+
isolated = ensure_opencode_isolated_config(ctx.project_dir)
|
|
197
432
|
cmd = ["opencode", "run"]
|
|
198
433
|
if model:
|
|
199
434
|
cmd += ["-m", model]
|
|
200
435
|
cmd.append(prompt)
|
|
436
|
+
env["OPENCODE_CONFIG"] = ""
|
|
437
|
+
env["OPENCODE_CONFIG_DIR"] = ""
|
|
438
|
+
env["OPENCODE_CONFIG_CONTENT"] = ""
|
|
439
|
+
env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
|
|
201
440
|
env["XDG_CONFIG_HOME"] = str(isolated)
|
|
202
441
|
env["XDG_DATA_HOME"] = str(isolated / "data")
|
|
203
442
|
return run_command(cmd, env=env, cwd=ctx.project_dir, timeout=120)
|
|
@@ -239,6 +478,20 @@ def main() -> int:
|
|
|
239
478
|
force=args.force,
|
|
240
479
|
llm_runner=llm_runner,
|
|
241
480
|
)
|
|
481
|
+
|
|
482
|
+
# Distill recurring workflows into candidate skills (procedural memory).
|
|
483
|
+
# Same session-end trigger, due-state, and llm_runner as the tasks above;
|
|
484
|
+
# this only ever touches the candidate store, never an agent's skills dir.
|
|
485
|
+
from memsearch.skills import distill as distill_skills
|
|
486
|
+
|
|
487
|
+
skill_result = distill_skills(
|
|
488
|
+
platform=args.platform,
|
|
489
|
+
project_dir=project_dir,
|
|
490
|
+
memsearch_dir=args.memsearch_dir,
|
|
491
|
+
cfg=cfg,
|
|
492
|
+
force=args.force,
|
|
493
|
+
llm_runner=llm_runner,
|
|
494
|
+
)
|
|
242
495
|
except (KeyError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
|
|
243
496
|
sys.stderr.write(f"Maintenance error: {exc}\n")
|
|
244
497
|
return 1
|
|
@@ -247,12 +500,15 @@ def main() -> int:
|
|
|
247
500
|
os.chdir(old_cwd)
|
|
248
501
|
|
|
249
502
|
payload = [result.__dict__ for result in results]
|
|
503
|
+
payload.append({"task": "memory_to_skill", **skill_result.__dict__})
|
|
250
504
|
if args.json_output:
|
|
251
505
|
sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
252
506
|
else:
|
|
253
507
|
for result in results:
|
|
254
508
|
detail = f": {result.reason}" if result.reason else ""
|
|
255
509
|
sys.stdout.write(f"{result.task}: {result.action}{detail}\n")
|
|
510
|
+
skill_detail = f": {skill_result.reason}" if skill_result.reason else ""
|
|
511
|
+
sys.stdout.write(f"memory_to_skill: {skill_result.action}{skill_detail}\n")
|
|
256
512
|
return 0
|
|
257
513
|
|
|
258
514
|
|
|
@@ -19,6 +19,7 @@ When this skill is triggered, inspect the user's request text. If there is no co
|
|
|
19
19
|
- "Not capturing/search empty/no memory": troubleshoot files, config, and index health.
|
|
20
20
|
- "Use OpenAI/Gemini/Anthropic/native/model": configure provider routing.
|
|
21
21
|
- "PROJECT.md/USER.md/profile/review": configure advanced maintenance.
|
|
22
|
+
- "skill/distill/extract a skill/memory-to-skill": procedural-memory distillation — enable or tune it here, or use the dedicated `/memory-to-skill` skill to review and install candidates.
|
|
22
23
|
- "Prompt": explain or configure prompt overrides.
|
|
23
24
|
|
|
24
25
|
Ask the user before enabling external or paid providers, changing output paths, re-indexing, deleting state, or broadening what gets indexed.
|
|
@@ -145,6 +146,11 @@ model = ""
|
|
|
145
146
|
min_interval_hours = 24
|
|
146
147
|
input_dir = ".memsearch/memory"
|
|
147
148
|
output_file = ".memsearch/USER.md"
|
|
149
|
+
|
|
150
|
+
[plugins.opencode.memory_to_skill]
|
|
151
|
+
enabled = false
|
|
152
|
+
min_occurrences = 3 # how many times a workflow must recur before it is distilled
|
|
153
|
+
paths = [] # where installed skills are copied; empty = ask the user
|
|
148
154
|
```
|
|
149
155
|
|
|
150
156
|
Provider rules:
|
|
@@ -192,6 +198,7 @@ Prompt overrides:
|
|
|
192
198
|
summarize = ""
|
|
193
199
|
project_review = ""
|
|
194
200
|
user_profile = ""
|
|
201
|
+
memory_to_skill = ""
|
|
195
202
|
```
|
|
196
203
|
|
|
197
204
|
Empty prompt paths mean use the built-in MemSearch prompts. Custom prompt files may use `{{AGENT_NAME}}`, `{{TASK_NAME}}`, `{{PROJECT_DIR}}`, `{{INPUT_DIR}}`, and `{{OUTPUT_FILE}}`; the runner appends existing output, recent journals, and digest automatically.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: memory-to-skill
|
|
3
|
+
description: "Turn workflows from your MemSearch memory into reusable skills. Use when the user asks to make/create/extract/distill a skill from what they just did or from past work, review skill candidates, install a distilled skill, or 'turn this into a skill'. Manages MemSearch procedural-memory candidates under .memsearch/skill-candidates/, not OpenCode's own skills system."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You manage MemSearch's **procedural memory**: skills distilled from the work you
|
|
7
|
+
repeat — a third layer beside the daily journals (episodic) and PROJECT.md /
|
|
8
|
+
USER.md (semantic). State once that this is MemSearch skill distillation, not
|
|
9
|
+
OpenCode's built-in skills system.
|
|
10
|
+
|
|
11
|
+
Stages: **0** memory journals → **1** candidate (`.memsearch/skill-candidates/`,
|
|
12
|
+
a git-tracked store that keeps evolving) → **2** installed (an agent skill dir).
|
|
13
|
+
Candidates are never installed automatically; installing is always a human step.
|
|
14
|
+
|
|
15
|
+
## Intent routing
|
|
16
|
+
|
|
17
|
+
- "make/turn this into a skill", "from what we just did" → **A. Capture now**.
|
|
18
|
+
- "what skills / review candidates / install X" → **B. Review & install**.
|
|
19
|
+
- "mine my history / find recurring workflows" → **C. Distill from history**.
|
|
20
|
+
- "enable / configure / how eager" → **D. Configure**.
|
|
21
|
+
- Unclear or empty → run **B**'s `list`; if empty, offer A or C.
|
|
22
|
+
|
|
23
|
+
## A. Capture what you just did (0→1→2)
|
|
24
|
+
|
|
25
|
+
You already have the context, so **draft the skill yourself** — do not call the
|
|
26
|
+
background distiller for this. Write a SKILL.md **body** (markdown, no
|
|
27
|
+
frontmatter): imperative numbered steps for the recurring task, concrete commands
|
|
28
|
+
and paths, no secrets, self-contained. Then persist it as a candidate:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
printf '%s' "## <title>\n\n1. ...\n2. ..." | memsearch skills add \
|
|
32
|
+
--name "<short-slug>" \
|
|
33
|
+
--description "<what it does AND when it should trigger — lead with the verbs a user types>" \
|
|
34
|
+
--body-file -
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`add` handles slugging, standard frontmatter, meta.json, and the git commit — no
|
|
38
|
+
LLM is involved. Then show it to the user and install it (see **B**). Finally,
|
|
39
|
+
check whether background distillation is on; if not, offer to enable it (so
|
|
40
|
+
recurring workflows get captured automatically going forward) — do not force it.
|
|
41
|
+
|
|
42
|
+
## B. Review & install candidates (1→2)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
memsearch skills list # add -j for sources / installed paths
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Pick one, resolve where to install (ask if unset), then install:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
memsearch config get plugins.opencode.memory_to_skill.paths 2>/dev/null || echo "[]"
|
|
52
|
+
memsearch skills install <name> --path .agents/skills
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If the list is **empty**, background distillation is likely off or has not run.
|
|
56
|
+
Offer the user a choice: capture from recent work now (**A**), distill from
|
|
57
|
+
history (**C**), or enable the background pass (**D**).
|
|
58
|
+
|
|
59
|
+
## C. Mine history for recurring workflows (0→1)
|
|
60
|
+
|
|
61
|
+
To pull skills out of past work (not just the current session), read the recent
|
|
62
|
+
journals yourself — they live in `.memsearch/memory/*.md` — and look for
|
|
63
|
+
multi-step procedures that recur across several sessions. Draft each genuinely
|
|
64
|
+
reusable one and persist it with `memsearch skills add` (one call per skill), the
|
|
65
|
+
same way as **A**. Use your own judgment: only propose procedures that recur and
|
|
66
|
+
generalize, not one-offs from a single day.
|
|
67
|
+
|
|
68
|
+
The background pass does this automatically when enabled; doing it here on demand
|
|
69
|
+
uses your own reasoning and needs no provider configuration.
|
|
70
|
+
|
|
71
|
+
## D. Configure
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
memsearch config get plugins.opencode.memory_to_skill.enabled 2>/dev/null || echo "false"
|
|
75
|
+
# enable the background pass (do not enable silently)
|
|
76
|
+
memsearch config set plugins.opencode.memory_to_skill.enabled true --project
|
|
77
|
+
# how eagerly history-mining distils (default 3; lower = more eager)
|
|
78
|
+
memsearch config set plugins.opencode.memory_to_skill.min_occurrences 3 --project
|
|
79
|
+
# pre-set install targets (otherwise you are asked at install time)
|
|
80
|
+
memsearch config set plugins.opencode.memory_to_skill.paths '[".agents/skills"]' --project
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Note: `enabled` only gates the **background** (session-end) pass. The explicit
|
|
84
|
+
commands above (`skills add`, `skills install`) always work, and you can mine history (C) directly.
|
|
85
|
+
|
|
86
|
+
## Install paths
|
|
87
|
+
|
|
88
|
+
- `.agents/skills` — **project-local (recommended)**: a skill from this project's
|
|
89
|
+
memory is usually most relevant here.
|
|
90
|
+
- `~/.agents/skills` — global: available across all your projects.
|
|
91
|
+
- a custom path, or several (one skill can be installed to multiple dirs).
|
|
92
|
+
|
|
93
|
+
Each agent reads skills from its own directory: Claude Code `.claude/skills/`;
|
|
94
|
+
Codex and OpenCode `.agents/skills/` (the shared standard, also read by Cursor
|
|
95
|
+
etc.); OpenClaw `.openclaw/skills/`. Claude Code does **not** read `.agents/skills/`.
|
|
96
|
+
Install to multiple paths to cover several agents.
|
|
97
|
+
|
|
98
|
+
## Guardrails
|
|
99
|
+
|
|
100
|
+
- Never enable the feature, change install paths, or install a candidate without
|
|
101
|
+
the user's go-ahead.
|
|
102
|
+
- Do not hand-edit the store; create candidates with `memsearch skills add` and let
|
|
103
|
+
the git-tracked store at `.memsearch/skill-candidates/` keep history.
|