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