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.
Files changed (35) hide show
  1. package/README.md +43 -0
  2. package/adapters/cursor/agents/executor.md +1 -1
  3. package/adapters/cursor/agents/implementer.md +1 -1
  4. package/adapters/cursor/agents/reviewer.md +1 -0
  5. package/adapters/opencode/agents.json +3 -3
  6. package/adapters/opencode/plugin.js +131 -29
  7. package/config/models.json +379 -33
  8. package/hooks/session-start.py +27 -0
  9. package/package.json +18 -4
  10. package/roles/executor.md +1 -1
  11. package/roles/implementer.md +1 -1
  12. package/roles/reviewer.md +1 -0
  13. package/scripts/doctor.py +284 -0
  14. package/scripts/ghreview.py +554 -0
  15. package/scripts/memory.py +705 -0
  16. package/scripts/render_adapters.py +244 -97
  17. package/scripts/resolve_attach_target.py +357 -0
  18. package/scripts/setup.py +161 -0
  19. package/skills/delegation/SKILL.md +1 -1
  20. package/skills/doctor/SKILL.md +105 -0
  21. package/skills/freshness/SKILL.md +118 -0
  22. package/skills/memory/SKILL.md +144 -0
  23. package/skills/resolve-ticket/SKILL.md +269 -0
  24. package/skills/review-pr/SKILL.md +317 -0
  25. package/skills/setup/SKILL.md +85 -0
  26. package/skills/using-leo/SKILL.md +8 -1
  27. package/skills/using-leo/references/claude-mapping.md +22 -1
  28. package/skills/using-leo/references/codex-mapping.md +17 -7
  29. package/skills/using-leo/references/cursor-mapping.md +18 -6
  30. package/skills/using-leo/references/hermes-mapping.md +17 -7
  31. package/skills/using-leo/references/opencode-mapping.md +16 -8
  32. package/skills/verification/SKILL.md +7 -0
  33. package/skills/visual-verification/SKILL.md +114 -0
  34. package/skills/watch-review/SKILL.md +125 -0
  35. 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:])