leos-agent 6.1.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/adapters/cursor/agents/executor.md +1 -1
- package/adapters/cursor/agents/implementer.md +1 -1
- package/adapters/cursor/agents/reviewer.md +1 -0
- package/adapters/opencode/agents.json +3 -3
- package/adapters/opencode/plugin.js +131 -29
- package/config/models.json +379 -33
- package/hooks/session-start.py +27 -0
- package/package.json +18 -4
- package/roles/executor.md +1 -1
- package/roles/implementer.md +1 -1
- package/roles/reviewer.md +1 -0
- package/scripts/doctor.py +284 -0
- package/scripts/ghreview.py +554 -0
- package/scripts/memory.py +705 -0
- package/scripts/render_adapters.py +244 -97
- package/scripts/resolve_attach_target.py +357 -0
- package/scripts/setup.py +161 -0
- package/skills/delegation/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +105 -0
- package/skills/freshness/SKILL.md +118 -0
- package/skills/memory/SKILL.md +144 -0
- package/skills/resolve-ticket/SKILL.md +269 -0
- package/skills/review-pr/SKILL.md +317 -0
- package/skills/setup/SKILL.md +85 -0
- package/skills/using-leo/SKILL.md +8 -1
- package/skills/using-leo/references/claude-mapping.md +22 -1
- package/skills/using-leo/references/codex-mapping.md +17 -7
- package/skills/using-leo/references/cursor-mapping.md +18 -6
- package/skills/using-leo/references/hermes-mapping.md +17 -7
- package/skills/using-leo/references/opencode-mapping.md +16 -8
- package/skills/verification/SKILL.md +7 -0
- package/skills/visual-verification/SKILL.md +114 -0
- package/skills/watch-review/SKILL.md +125 -0
- package/skills/writing-skills/SKILL.md +134 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""memory: durable, cross-harness facts for leos-agent.
|
|
3
|
+
|
|
4
|
+
Same CODE/DATA split as state.py: this file ships inside a plugin cache that an
|
|
5
|
+
update can wipe, so the store is always resolved from the environment, never
|
|
6
|
+
from __file__. The canonical store is the only writable copy of a memory:
|
|
7
|
+
|
|
8
|
+
${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/memory/
|
|
9
|
+
global/<slug>.md one fact per file
|
|
10
|
+
repo/<repo-slug>/<slug>.md one fact per file, scoped to a repo/project
|
|
11
|
+
MEMORY.md generated index, human and model readable
|
|
12
|
+
index.json generated index, machine readable
|
|
13
|
+
.trash/ forgotten facts, never auto-pruned
|
|
14
|
+
|
|
15
|
+
The .md files are the sole source of truth. MEMORY.md, index.json, and every
|
|
16
|
+
projected block are derived and rebuilt from scratch on each mutation, which is
|
|
17
|
+
what makes concurrent writers safe: nothing is ever merged into a derived file.
|
|
18
|
+
|
|
19
|
+
Harness-native memory surfaces receive a one-way PROJECTION of the GLOBAL facts
|
|
20
|
+
only. Every per-user surface (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ...) is
|
|
21
|
+
loaded in every repo, so projecting repo-scoped facts there would leak one
|
|
22
|
+
project's memories into unrelated sessions. Repo facts reach the model through
|
|
23
|
+
the session-start context block instead, which knows the working directory.
|
|
24
|
+
Do not "fix" this by projecting repo facts.
|
|
25
|
+
|
|
26
|
+
memory.py write <scope> <type> <title> [--repo KEY] body on stdin
|
|
27
|
+
memory.py list [<scope>] [--repo KEY]
|
|
28
|
+
memory.py read <ref>
|
|
29
|
+
memory.py forget <ref>
|
|
30
|
+
memory.py reindex
|
|
31
|
+
memory.py project
|
|
32
|
+
memory.py context [--repo KEY | --cwd DIR]
|
|
33
|
+
memory.py session [--cwd DIR]
|
|
34
|
+
memory.py key [<dir>]
|
|
35
|
+
memory.py path [<ref>]
|
|
36
|
+
|
|
37
|
+
A <ref> is the store-relative path without .md: "global/<slug>" or
|
|
38
|
+
"repo/<repo-slug>/<slug>". Mutating verbs are serialized with the same flock
|
|
39
|
+
mechanism state.py uses; list/read/context/key/path are lock-free because every
|
|
40
|
+
write is atomic, so a reader sees the old file or the new one, never a torn one.
|
|
41
|
+
context and session always exit 0 and print nothing on failure: they run inside
|
|
42
|
+
session bootstraps that must degrade to "no memory", never take the policy down.
|
|
43
|
+
|
|
44
|
+
Set LEOS_AGENT_NO_PROJECT=1 to disable writing to native surfaces entirely.
|
|
45
|
+
Exit codes: 0 ok, non-zero on error (except context/session, which never fail).
|
|
46
|
+
"""
|
|
47
|
+
import datetime
|
|
48
|
+
import hashlib
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import re
|
|
52
|
+
import shutil
|
|
53
|
+
import subprocess
|
|
54
|
+
import sys
|
|
55
|
+
import tempfile
|
|
56
|
+
import unicodedata
|
|
57
|
+
|
|
58
|
+
# state.py is a sibling. Running memory.py as a script puts scripts/ on sys.path
|
|
59
|
+
# automatically, but hooks/session-start.py imports this module (its own
|
|
60
|
+
# sys.path[0] is hooks/) and the tests load it with spec_from_file_location,
|
|
61
|
+
# which adds nothing. Fixing the path here keeps all three entry points working.
|
|
62
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
63
|
+
if _HERE not in sys.path:
|
|
64
|
+
sys.path.insert(0, _HERE)
|
|
65
|
+
import state # noqa: E402
|
|
66
|
+
|
|
67
|
+
# The closed type enum. This is the primary anti-noise gate: a fact that fits
|
|
68
|
+
# none of these is not a memory. Keep in sync with skills/memory/SKILL.md.
|
|
69
|
+
TYPES = ("preference", "convention", "environment", "decision", "person")
|
|
70
|
+
|
|
71
|
+
SETUP_STATE = "setup"
|
|
72
|
+
MEMORY_CONTEXT_LIMIT = 4000
|
|
73
|
+
MAX_BODY = 1200
|
|
74
|
+
MAX_TITLE = 80
|
|
75
|
+
MAX_DESCRIPTION = 160
|
|
76
|
+
MAX_FACTS_PER_SCOPE = 500
|
|
77
|
+
|
|
78
|
+
BEGIN = "<!-- BEGIN leos-agent memory (generated by scripts/memory.py; edits are overwritten) -->"
|
|
79
|
+
END = "<!-- END leos-agent memory -->"
|
|
80
|
+
|
|
81
|
+
FRONTMATTER_KEYS = ("title", "type", "description", "scope", "repo", "created", "updated")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --------------------------------------------------------------------------
|
|
85
|
+
# paths
|
|
86
|
+
# --------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def memory_root():
|
|
89
|
+
return os.path.join(state._data_root(), "memory")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _lock_path():
|
|
93
|
+
return os.path.join(memory_root(), "index.json")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fact_slug(title):
|
|
97
|
+
s = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
|
|
98
|
+
s = re.sub(r"[^A-Za-z0-9]+", "-", s).strip("-").lower()[:48].strip("-")
|
|
99
|
+
return s or "fact"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def repo_slug(key):
|
|
103
|
+
"""Directory name for a repo key.
|
|
104
|
+
|
|
105
|
+
The hash is taken over the ORIGINAL key, before lowercasing and character
|
|
106
|
+
substitution, so "owner/repo", "owner-repo" and "Owner/Repo" land in three
|
|
107
|
+
different directories. The readable half is lowercased so the directory
|
|
108
|
+
name itself is byte-identical on case-sensitive ext4 and case-insensitive
|
|
109
|
+
APFS — without that split the two platforms disagree about collisions.
|
|
110
|
+
"""
|
|
111
|
+
readable = re.sub(r"[^A-Za-z0-9._-]+", "-", key).strip("-.").lower()[:60].strip("-.")
|
|
112
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
|
|
113
|
+
return f"{readable or 'project'}--{digest}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _git(cwd, *args):
|
|
117
|
+
try:
|
|
118
|
+
done = subprocess.run(
|
|
119
|
+
["git", "-C", cwd, *args],
|
|
120
|
+
capture_output=True, text=True, timeout=2,
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
return None
|
|
124
|
+
out = done.stdout.strip()
|
|
125
|
+
return out if done.returncode == 0 and out else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def repo_key(cwd=None):
|
|
129
|
+
"""state.py's key convention, implemented once: owner/repo, else abs path."""
|
|
130
|
+
cwd = cwd or os.getcwd()
|
|
131
|
+
url = _git(cwd, "remote", "get-url", "origin")
|
|
132
|
+
if url:
|
|
133
|
+
m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$", url)
|
|
134
|
+
if m:
|
|
135
|
+
return f"{m.group(1)}/{m.group(2)}"
|
|
136
|
+
return _git(cwd, "rev-parse", "--show-toplevel") or os.path.abspath(cwd)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ref_path(ref):
|
|
140
|
+
root = memory_root()
|
|
141
|
+
parts = [p for p in ref.split("/") if p]
|
|
142
|
+
if ".." in parts or os.path.isabs(ref) or len(parts) not in (2, 3):
|
|
143
|
+
sys.exit(f"memory: {ref!r} is not a valid memory ref")
|
|
144
|
+
if parts[0] not in ("global", "repo"):
|
|
145
|
+
sys.exit(f"memory: {ref!r} must start with 'global/' or 'repo/'")
|
|
146
|
+
return os.path.join(root, *parts) + ".md"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --------------------------------------------------------------------------
|
|
150
|
+
# fact files
|
|
151
|
+
# --------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _atomic_text(path, text, mode=0o644):
|
|
154
|
+
"""Markdown twin of state.atomic_write, which json-dumps its argument."""
|
|
155
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
156
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
|
|
157
|
+
try:
|
|
158
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
159
|
+
fh.write(text)
|
|
160
|
+
os.chmod(tmp, mode) # mkstemp is 0600; these are readable config files
|
|
161
|
+
os.replace(tmp, path)
|
|
162
|
+
except BaseException:
|
|
163
|
+
try:
|
|
164
|
+
os.unlink(tmp)
|
|
165
|
+
except OSError:
|
|
166
|
+
pass
|
|
167
|
+
raise
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def render_fact(meta, body):
|
|
171
|
+
lines = ["---"]
|
|
172
|
+
for key in FRONTMATTER_KEYS:
|
|
173
|
+
value = meta.get(key)
|
|
174
|
+
if value is None:
|
|
175
|
+
continue
|
|
176
|
+
# JSON-quoted scalars are valid YAML and parse with json.loads, which
|
|
177
|
+
# keeps colons, quotes and em-dashes safe without a YAML dependency.
|
|
178
|
+
lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}")
|
|
179
|
+
lines.append("---")
|
|
180
|
+
lines.append("")
|
|
181
|
+
lines.append(body.strip())
|
|
182
|
+
return "\n".join(lines) + "\n"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_fact(path):
|
|
186
|
+
"""Return (meta, body) or None when the file is not a readable fact."""
|
|
187
|
+
try:
|
|
188
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
189
|
+
text = fh.read()
|
|
190
|
+
except OSError:
|
|
191
|
+
return None
|
|
192
|
+
lines = text.splitlines()
|
|
193
|
+
if not lines or lines[0].strip() != "---":
|
|
194
|
+
return None
|
|
195
|
+
meta = {}
|
|
196
|
+
for index, line in enumerate(lines[1:], 1):
|
|
197
|
+
if line.strip() == "---":
|
|
198
|
+
body = "\n".join(lines[index + 1:]).strip()
|
|
199
|
+
if "title" not in meta or "type" not in meta:
|
|
200
|
+
return None
|
|
201
|
+
return meta, body
|
|
202
|
+
key, _, raw = line.partition(":")
|
|
203
|
+
key = key.strip()
|
|
204
|
+
if key not in FRONTMATTER_KEYS:
|
|
205
|
+
continue
|
|
206
|
+
raw = raw.strip()
|
|
207
|
+
try:
|
|
208
|
+
meta[key] = json.loads(raw)
|
|
209
|
+
except json.JSONDecodeError:
|
|
210
|
+
meta[key] = raw.strip('"')
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _now():
|
|
215
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _scope_dir(scope, repo=None):
|
|
219
|
+
if scope == "global":
|
|
220
|
+
return os.path.join(memory_root(), "global")
|
|
221
|
+
return os.path.join(memory_root(), "repo", repo_slug(repo))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _iter_facts():
|
|
225
|
+
"""Yield (ref, meta, body) for every readable fact, plus unreadable refs."""
|
|
226
|
+
root = memory_root()
|
|
227
|
+
unreadable = []
|
|
228
|
+
facts = []
|
|
229
|
+
for scope_rel in ("global",) + tuple(
|
|
230
|
+
os.path.join("repo", d)
|
|
231
|
+
for d in sorted(_listdir(os.path.join(root, "repo")))
|
|
232
|
+
):
|
|
233
|
+
directory = os.path.join(root, scope_rel)
|
|
234
|
+
for name in sorted(_listdir(directory)):
|
|
235
|
+
if not name.endswith(".md"):
|
|
236
|
+
continue
|
|
237
|
+
ref = f"{scope_rel}/{name[:-3]}"
|
|
238
|
+
parsed = parse_fact(os.path.join(directory, name))
|
|
239
|
+
if parsed is None:
|
|
240
|
+
unreadable.append(ref)
|
|
241
|
+
continue
|
|
242
|
+
facts.append((ref, parsed[0], parsed[1]))
|
|
243
|
+
return facts, unreadable
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _listdir(path):
|
|
247
|
+
try:
|
|
248
|
+
return [n for n in os.listdir(path) if not n.startswith(".")]
|
|
249
|
+
except OSError:
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --------------------------------------------------------------------------
|
|
254
|
+
# index
|
|
255
|
+
# --------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def reindex():
|
|
258
|
+
facts, unreadable = _iter_facts()
|
|
259
|
+
entries = []
|
|
260
|
+
repos = {}
|
|
261
|
+
for ref, meta, _ in facts:
|
|
262
|
+
entries.append({
|
|
263
|
+
"ref": ref,
|
|
264
|
+
"title": meta.get("title", ref),
|
|
265
|
+
"type": meta.get("type", "convention"),
|
|
266
|
+
"description": meta.get("description", ""),
|
|
267
|
+
"scope": meta.get("scope", "global"),
|
|
268
|
+
"repo": meta.get("repo"),
|
|
269
|
+
"updated": meta.get("updated", ""),
|
|
270
|
+
})
|
|
271
|
+
if meta.get("scope") == "repo" and meta.get("repo"):
|
|
272
|
+
repos[repo_slug(meta["repo"])] = meta["repo"]
|
|
273
|
+
index = {
|
|
274
|
+
"version": 1,
|
|
275
|
+
"generated": _now(),
|
|
276
|
+
"facts": entries,
|
|
277
|
+
"repos": repos,
|
|
278
|
+
"unreadable": unreadable,
|
|
279
|
+
}
|
|
280
|
+
state.atomic_write(os.path.join(memory_root(), "index.json"), index)
|
|
281
|
+
_atomic_text(os.path.join(memory_root(), "MEMORY.md"), _render_memory_md(index))
|
|
282
|
+
return index
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _load_index():
|
|
286
|
+
"""Never state.load(): it sys.exit()s on corruption, breaking fail-open."""
|
|
287
|
+
try:
|
|
288
|
+
with open(os.path.join(memory_root(), "index.json"), encoding="utf-8") as fh:
|
|
289
|
+
data = json.load(fh)
|
|
290
|
+
if isinstance(data, dict) and isinstance(data.get("facts"), list):
|
|
291
|
+
return data
|
|
292
|
+
except Exception:
|
|
293
|
+
return None
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _entry_line(entry):
|
|
298
|
+
line = f"- [{entry['title']}]({entry['ref']}.md) — {entry['type']}: {entry['description']}"
|
|
299
|
+
return line[:MAX_DESCRIPTION + 120]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _render_memory_md(index):
|
|
303
|
+
out = [
|
|
304
|
+
"<!-- Generated by scripts/memory.py; do not edit. Facts live in the .md files. -->",
|
|
305
|
+
"# Memory index",
|
|
306
|
+
"",
|
|
307
|
+
]
|
|
308
|
+
globals_ = [e for e in index["facts"] if e["scope"] == "global"]
|
|
309
|
+
if globals_:
|
|
310
|
+
out += ["## Global", ""] + [_entry_line(e) for e in globals_] + [""]
|
|
311
|
+
by_repo = {}
|
|
312
|
+
for entry in index["facts"]:
|
|
313
|
+
if entry["scope"] == "repo":
|
|
314
|
+
by_repo.setdefault(entry.get("repo") or "unknown", []).append(entry)
|
|
315
|
+
for repo in sorted(by_repo):
|
|
316
|
+
out += [f"## {repo}", ""] + [_entry_line(e) for e in by_repo[repo]] + [""]
|
|
317
|
+
return "\n".join(out).rstrip("\n") + "\n"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def render_context(index, repo=None, limit=MEMORY_CONTEXT_LIMIT):
|
|
321
|
+
"""The block shared by projection and session injection."""
|
|
322
|
+
if index is None or not index.get("facts"):
|
|
323
|
+
return ""
|
|
324
|
+
globals_ = [e for e in index["facts"] if e["scope"] == "global"]
|
|
325
|
+
mine = [e for e in index["facts"] if e["scope"] == "repo" and e.get("repo") == repo] if repo else []
|
|
326
|
+
if not globals_ and not mine:
|
|
327
|
+
return ""
|
|
328
|
+
head = [
|
|
329
|
+
"# Memory (leos-agent)",
|
|
330
|
+
"Facts already learned. Each line is a pointer, not the fact — read the "
|
|
331
|
+
"file before relying on one, and prefer what you can observe right now.",
|
|
332
|
+
f"Store: {memory_root()}",
|
|
333
|
+
]
|
|
334
|
+
# Both tail lines are appended after the entry loop, so their worst-case
|
|
335
|
+
# length is reserved up front — otherwise the block overshoots the limit by
|
|
336
|
+
# exactly the tail it was about to add.
|
|
337
|
+
unreadable = len(index.get("unreadable") or ())
|
|
338
|
+
reserve = len(f"- …and {len(globals_) + len(mine)} more (see MEMORY.md in the store).") + 1
|
|
339
|
+
if unreadable:
|
|
340
|
+
reserve += len(
|
|
341
|
+
f"- ({unreadable} memory files could not be read — run memory.py reindex)."
|
|
342
|
+
) + 1
|
|
343
|
+
|
|
344
|
+
out = list(head)
|
|
345
|
+
dropped = 0
|
|
346
|
+
for title, entries in (("## Global", globals_), (f"## {repo}", mine)):
|
|
347
|
+
if not entries:
|
|
348
|
+
continue
|
|
349
|
+
section = ["", title, ""]
|
|
350
|
+
for entry in entries:
|
|
351
|
+
line = _entry_line(entry)
|
|
352
|
+
if len("\n".join(out + section + [line])) + reserve > limit:
|
|
353
|
+
dropped += 1
|
|
354
|
+
continue
|
|
355
|
+
section.append(line)
|
|
356
|
+
if len(section) > 3:
|
|
357
|
+
out += section
|
|
358
|
+
if dropped:
|
|
359
|
+
out.append(f"- …and {dropped} more (see MEMORY.md in the store).")
|
|
360
|
+
if unreadable:
|
|
361
|
+
out.append(
|
|
362
|
+
f"- ({unreadable} memory files could not be read — run memory.py reindex)."
|
|
363
|
+
)
|
|
364
|
+
return "\n".join(out).rstrip("\n") + "\n"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# --------------------------------------------------------------------------
|
|
368
|
+
# projection
|
|
369
|
+
# --------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
def _home(var, *parts):
|
|
372
|
+
base = os.environ.get(var)
|
|
373
|
+
if not base:
|
|
374
|
+
base = os.path.join(os.path.expanduser("~"), parts[0])
|
|
375
|
+
parts = parts[1:]
|
|
376
|
+
return os.path.join(base, *parts) if parts else base
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def hermes_home():
|
|
380
|
+
return os.environ.get("HERMES_HOME") or os.path.join(os.path.expanduser("~"), ".hermes")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def hermes_enabled():
|
|
384
|
+
"""Hermes projection is opt-in, through leo:setup.
|
|
385
|
+
|
|
386
|
+
Its only user-owned global file is SOUL.md, the agent's own identity
|
|
387
|
+
prompt and the opening section of every system prompt on that machine —
|
|
388
|
+
a blast radius the other four targets do not have. `hermes plugins
|
|
389
|
+
install` has no install-time hook (register() runs at session start), so
|
|
390
|
+
there is no moment during installation at which consent could be implied.
|
|
391
|
+
"""
|
|
392
|
+
try:
|
|
393
|
+
data = state.load(state.state_file(SETUP_STATE))
|
|
394
|
+
return bool((data.get("hermes") or {}).get("projectMemory"))
|
|
395
|
+
except SystemExit:
|
|
396
|
+
# state.load() exits hard on a corrupt file. Projection must degrade
|
|
397
|
+
# to "not enabled" rather than take the whole session down with it.
|
|
398
|
+
return False
|
|
399
|
+
except Exception:
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def projection_targets():
|
|
404
|
+
"""(harness, gate_dir, file, owned, require_file) — gate_dir must exist.
|
|
405
|
+
|
|
406
|
+
Never mkdir a harness config directory: its absence means the harness is
|
|
407
|
+
not installed, and creating ~/.cursor for a non-Cursor user is exactly the
|
|
408
|
+
surprise this must not cause.
|
|
409
|
+
|
|
410
|
+
require_file extends that rule one step for Hermes: SOUL.md must already
|
|
411
|
+
exist too. Hermes falls back to a built-in persona when the file is
|
|
412
|
+
absent, so creating it would silently replace the user's agent identity —
|
|
413
|
+
the same class of surprise, one level down.
|
|
414
|
+
"""
|
|
415
|
+
claude = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude")
|
|
416
|
+
codex = os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
|
|
417
|
+
xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
418
|
+
opencode = os.path.join(xdg, "opencode")
|
|
419
|
+
cursor = os.path.join(os.path.expanduser("~"), ".cursor")
|
|
420
|
+
targets = [
|
|
421
|
+
("claude", claude, os.path.join(claude, "CLAUDE.md"), False, False),
|
|
422
|
+
("codex", codex, os.path.join(codex, "AGENTS.md"), False, False),
|
|
423
|
+
("opencode", opencode, os.path.join(opencode, "AGENTS.md"), False, False),
|
|
424
|
+
("cursor", os.path.join(cursor, "rules"),
|
|
425
|
+
os.path.join(cursor, "rules", "leos-agent-memory.mdc"), True, False),
|
|
426
|
+
]
|
|
427
|
+
if hermes_enabled():
|
|
428
|
+
home = hermes_home()
|
|
429
|
+
targets.append(("hermes", home, os.path.join(home, "SOUL.md"), False, True))
|
|
430
|
+
return targets
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _backup_once(path):
|
|
434
|
+
"""One copy of the user's original, before Leo's first ever write to it."""
|
|
435
|
+
backup = path + ".leo-backup"
|
|
436
|
+
if os.path.exists(path) and not os.path.exists(backup):
|
|
437
|
+
shutil.copy2(path, backup)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def splice(existing, block):
|
|
441
|
+
"""Return the new file text, or None when the markers are unbalanced.
|
|
442
|
+
|
|
443
|
+
Content outside the markers is preserved byte-for-byte. An unbalanced or
|
|
444
|
+
duplicated marker pair means something else edited the file: leave it
|
|
445
|
+
completely alone rather than guessing where the managed region ends.
|
|
446
|
+
"""
|
|
447
|
+
begins = existing.count(BEGIN)
|
|
448
|
+
ends = existing.count(END)
|
|
449
|
+
if begins > 1 or ends > 1 or begins != ends:
|
|
450
|
+
return None
|
|
451
|
+
if begins == 0:
|
|
452
|
+
if not existing.strip():
|
|
453
|
+
return block
|
|
454
|
+
return existing.rstrip("\n") + "\n\n" + block
|
|
455
|
+
start = existing.index(BEGIN)
|
|
456
|
+
stop = existing.index(END) + len(END)
|
|
457
|
+
if stop < start:
|
|
458
|
+
return None
|
|
459
|
+
if not block:
|
|
460
|
+
head = existing[:start].rstrip("\n")
|
|
461
|
+
tail = existing[stop:].lstrip("\n")
|
|
462
|
+
joined = (head + ("\n\n" if head and tail else "") + tail).rstrip("\n")
|
|
463
|
+
return joined + "\n" if joined else ""
|
|
464
|
+
return existing[:start] + block.rstrip("\n") + existing[stop:]
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _wrap(body):
|
|
468
|
+
return f"{BEGIN}\n{body.rstrip()}\n{END}\n" if body else ""
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _hermes_absent(targets):
|
|
472
|
+
"""Hermes always appears in the report, enabled or not — a harness that is
|
|
473
|
+
silently missing from the list reads as one that was projected."""
|
|
474
|
+
if any(t[0] == "hermes" for t in targets):
|
|
475
|
+
return []
|
|
476
|
+
return [{"harness": "hermes", "path": None, "status": "skipped:opt-in-required"}]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def project(index=None):
|
|
480
|
+
if os.environ.get("LEOS_AGENT_NO_PROJECT") == "1":
|
|
481
|
+
targets = projection_targets()
|
|
482
|
+
return [{"harness": h, "path": f, "status": "skipped:disabled"}
|
|
483
|
+
for h, _, f, _, _ in targets] + [
|
|
484
|
+
{"harness": t["harness"], "path": None, "status": "skipped:disabled"}
|
|
485
|
+
for t in _hermes_absent(targets)]
|
|
486
|
+
if index is None:
|
|
487
|
+
index = _load_index() or reindex()
|
|
488
|
+
# GLOBAL facts only — see the module docstring.
|
|
489
|
+
body = render_context({"facts": [e for e in index["facts"] if e["scope"] == "global"],
|
|
490
|
+
"unreadable": index.get("unreadable", [])})
|
|
491
|
+
block = _wrap(body)
|
|
492
|
+
results = []
|
|
493
|
+
targets = projection_targets()
|
|
494
|
+
for harness, gate, path, owned, require_file in targets:
|
|
495
|
+
results.append({"harness": harness, "path": path,
|
|
496
|
+
"status": _project_one(gate, path, owned, block, require_file)})
|
|
497
|
+
results.extend(_hermes_absent(targets))
|
|
498
|
+
return results
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _project_one(gate, path, owned, block, require_file=False):
|
|
502
|
+
try:
|
|
503
|
+
if not os.path.isdir(gate):
|
|
504
|
+
return "skipped:no-dir"
|
|
505
|
+
if require_file and not os.path.exists(path):
|
|
506
|
+
# Hermes only. Creating SOUL.md would displace the built-in
|
|
507
|
+
# persona the harness uses when it is absent.
|
|
508
|
+
return "skipped:no-soul"
|
|
509
|
+
target = os.path.realpath(path) if os.path.islink(path) else path
|
|
510
|
+
existing = ""
|
|
511
|
+
mode = 0o644
|
|
512
|
+
if os.path.exists(target):
|
|
513
|
+
mode = os.stat(target).st_mode & 0o777
|
|
514
|
+
with open(target, encoding="utf-8", errors="replace") as fh:
|
|
515
|
+
existing = fh.read()
|
|
516
|
+
if owned:
|
|
517
|
+
if not block:
|
|
518
|
+
if os.path.exists(target):
|
|
519
|
+
os.unlink(target)
|
|
520
|
+
return "removed"
|
|
521
|
+
return "unchanged"
|
|
522
|
+
new = ("---\ndescription: Leo's Agent projected memory (generated)\n"
|
|
523
|
+
"alwaysApply: true\n---\n" + block)
|
|
524
|
+
else:
|
|
525
|
+
new = splice(existing, block)
|
|
526
|
+
if new is None:
|
|
527
|
+
return "error: unbalanced markers"
|
|
528
|
+
if new == existing:
|
|
529
|
+
return "unchanged"
|
|
530
|
+
if existing:
|
|
531
|
+
_backup_once(target)
|
|
532
|
+
if not new.strip():
|
|
533
|
+
os.unlink(target)
|
|
534
|
+
return "removed"
|
|
535
|
+
_atomic_text(target, new, mode)
|
|
536
|
+
return "written"
|
|
537
|
+
except OSError as exc:
|
|
538
|
+
return f"error: {exc.strerror or exc}"
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# --------------------------------------------------------------------------
|
|
542
|
+
# verbs
|
|
543
|
+
# --------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
def write_fact(scope, type_, title, body, repo=None):
|
|
546
|
+
if scope not in ("global", "repo"):
|
|
547
|
+
sys.exit("memory: scope must be 'global' or 'repo'")
|
|
548
|
+
if type_ not in TYPES:
|
|
549
|
+
sys.exit(f"memory: type must be one of {', '.join(TYPES)}")
|
|
550
|
+
if scope == "repo" and not repo:
|
|
551
|
+
sys.exit("memory: --repo KEY is required for a repo-scoped memory")
|
|
552
|
+
title = title.strip()
|
|
553
|
+
if not title or len(title) > MAX_TITLE:
|
|
554
|
+
sys.exit(f"memory: title must be 1..{MAX_TITLE} characters")
|
|
555
|
+
body = body.strip()
|
|
556
|
+
if not body:
|
|
557
|
+
sys.exit("memory: body is empty — pass the fact on stdin")
|
|
558
|
+
if len(body) > MAX_BODY:
|
|
559
|
+
sys.exit(f"memory: body is {len(body)} chars (max {MAX_BODY}) — shorten it or split it in two")
|
|
560
|
+
|
|
561
|
+
directory = _scope_dir(scope, repo)
|
|
562
|
+
slug = fact_slug(title)
|
|
563
|
+
with state._locked(_lock_path()):
|
|
564
|
+
os.makedirs(directory, exist_ok=True)
|
|
565
|
+
if len(_listdir(directory)) >= MAX_FACTS_PER_SCOPE:
|
|
566
|
+
sys.exit(f"memory: this scope is full ({MAX_FACTS_PER_SCOPE} facts) — consolidate or forget first")
|
|
567
|
+
path, action, created = _resolve_slot(directory, slug, type_)
|
|
568
|
+
now = _now()
|
|
569
|
+
meta = {
|
|
570
|
+
"title": title,
|
|
571
|
+
"type": type_,
|
|
572
|
+
"description": body.splitlines()[0].strip()[:MAX_DESCRIPTION],
|
|
573
|
+
"scope": scope,
|
|
574
|
+
"created": created or now,
|
|
575
|
+
"updated": now,
|
|
576
|
+
}
|
|
577
|
+
if scope == "repo":
|
|
578
|
+
meta["repo"] = repo
|
|
579
|
+
_atomic_text(path, render_fact(meta, body))
|
|
580
|
+
index = reindex()
|
|
581
|
+
projection = project(index)
|
|
582
|
+
rel = os.path.relpath(path, memory_root())[:-3]
|
|
583
|
+
return {"action": action, "ref": rel, "path": path,
|
|
584
|
+
"description": meta["description"], "projection": projection}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _resolve_slot(directory, slug, type_):
|
|
588
|
+
"""Same title+type updates in place; same title, different type gets -2."""
|
|
589
|
+
candidate = os.path.join(directory, f"{slug}.md")
|
|
590
|
+
parsed = parse_fact(candidate) if os.path.exists(candidate) else None
|
|
591
|
+
if parsed is None:
|
|
592
|
+
return candidate, ("created" if not os.path.exists(candidate) else "created"), None
|
|
593
|
+
if parsed[0].get("type") == type_:
|
|
594
|
+
return candidate, "updated", parsed[0].get("created")
|
|
595
|
+
suffix = 2
|
|
596
|
+
while os.path.exists(os.path.join(directory, f"{slug}-{suffix}.md")):
|
|
597
|
+
existing = parse_fact(os.path.join(directory, f"{slug}-{suffix}.md"))
|
|
598
|
+
if existing and existing[0].get("type") == type_:
|
|
599
|
+
return (os.path.join(directory, f"{slug}-{suffix}.md"), "updated",
|
|
600
|
+
existing[0].get("created"))
|
|
601
|
+
suffix += 1
|
|
602
|
+
return os.path.join(directory, f"{slug}-{suffix}.md"), "created", None
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def forget(ref):
|
|
606
|
+
path = ref_path(ref)
|
|
607
|
+
if not os.path.exists(path):
|
|
608
|
+
sys.exit(f"memory: no such memory {ref!r}")
|
|
609
|
+
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
610
|
+
trash = os.path.join(memory_root(), ".trash", os.path.dirname(ref))
|
|
611
|
+
with state._locked(_lock_path()):
|
|
612
|
+
os.makedirs(trash, exist_ok=True)
|
|
613
|
+
# Move, never unlink: automatic capture plus hard delete on the model's
|
|
614
|
+
# own judgment is a data-loss path, and the trash costs nothing.
|
|
615
|
+
destination = os.path.join(trash, f"{os.path.basename(ref)}.{stamp}.md")
|
|
616
|
+
shutil.move(path, destination)
|
|
617
|
+
index = reindex()
|
|
618
|
+
projection = project(index)
|
|
619
|
+
return {"forgotten": ref, "path": destination, "projection": projection}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def session(cwd=None):
|
|
623
|
+
"""One call for the bootstraps: refresh, project, return the block."""
|
|
624
|
+
index = reindex()
|
|
625
|
+
project(index)
|
|
626
|
+
return render_context(index, repo=repo_key(cwd))
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
FLAGS = ("--repo", "--cwd")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _split_args(argv):
|
|
633
|
+
"""Positional args and flags. A flag consumes its value, so a title is
|
|
634
|
+
never silently joined with the repo key that followed --repo."""
|
|
635
|
+
positional, flags, index = [], {}, 0
|
|
636
|
+
while index < len(argv):
|
|
637
|
+
token = argv[index]
|
|
638
|
+
if token in FLAGS:
|
|
639
|
+
flags[token] = argv[index + 1] if index + 1 < len(argv) else None
|
|
640
|
+
index += 2
|
|
641
|
+
continue
|
|
642
|
+
if token.startswith("--"):
|
|
643
|
+
name, _, inline = token.partition("=")
|
|
644
|
+
if name in FLAGS and inline:
|
|
645
|
+
flags[name] = inline
|
|
646
|
+
index += 1
|
|
647
|
+
continue
|
|
648
|
+
positional.append(token)
|
|
649
|
+
index += 1
|
|
650
|
+
return positional, flags
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def main(argv):
|
|
654
|
+
verb = argv[0] if argv else ""
|
|
655
|
+
rest, flags = _split_args(argv[1:])
|
|
656
|
+
repo = flags.get("--repo")
|
|
657
|
+
cwd = flags.get("--cwd")
|
|
658
|
+
|
|
659
|
+
if verb in ("write", "save") and len(rest) >= 3:
|
|
660
|
+
body = sys.stdin.read()
|
|
661
|
+
print(json.dumps(write_fact(rest[0], rest[1], " ".join(rest[2:]), body, repo),
|
|
662
|
+
indent=1, sort_keys=True))
|
|
663
|
+
elif verb == "list":
|
|
664
|
+
index = _load_index() or reindex()
|
|
665
|
+
entries = index["facts"]
|
|
666
|
+
if rest:
|
|
667
|
+
entries = [e for e in entries if e["scope"] == rest[0]]
|
|
668
|
+
if repo:
|
|
669
|
+
entries = [e for e in entries if e.get("repo") == repo]
|
|
670
|
+
print(json.dumps(entries, indent=1, sort_keys=True))
|
|
671
|
+
elif verb in ("read", "recall") and rest:
|
|
672
|
+
path = ref_path(rest[0])
|
|
673
|
+
if not os.path.exists(path):
|
|
674
|
+
sys.exit(f"memory: no such memory {rest[0]!r}")
|
|
675
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
676
|
+
sys.stdout.write(fh.read())
|
|
677
|
+
elif verb == "forget" and rest:
|
|
678
|
+
print(json.dumps(forget(rest[0]), indent=1, sort_keys=True))
|
|
679
|
+
elif verb == "reindex":
|
|
680
|
+
index = reindex()
|
|
681
|
+
print(json.dumps({"facts": len(index["facts"]), "repos": len(index["repos"]),
|
|
682
|
+
"unreadable": index["unreadable"]}, indent=1, sort_keys=True))
|
|
683
|
+
elif verb == "project":
|
|
684
|
+
print(json.dumps({"targets": project()}, indent=1, sort_keys=True))
|
|
685
|
+
elif verb == "context":
|
|
686
|
+
try:
|
|
687
|
+
sys.stdout.write(render_context(_load_index() or reindex(),
|
|
688
|
+
repo=repo or repo_key(cwd)))
|
|
689
|
+
except Exception:
|
|
690
|
+
pass
|
|
691
|
+
elif verb == "session":
|
|
692
|
+
try:
|
|
693
|
+
sys.stdout.write(session(cwd))
|
|
694
|
+
except Exception:
|
|
695
|
+
pass
|
|
696
|
+
elif verb == "key":
|
|
697
|
+
print(repo_key(rest[0] if rest else None))
|
|
698
|
+
elif verb == "path":
|
|
699
|
+
print(ref_path(rest[0]) if rest else memory_root())
|
|
700
|
+
else:
|
|
701
|
+
sys.exit(__doc__.strip())
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
if __name__ == "__main__":
|
|
705
|
+
main(sys.argv[1:])
|