agent-memory-git 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,483 @@
1
+ """Wiring agent-memory into Claude Code and Codex, and taking it out again.
2
+
3
+ Every change is recorded in install-state.json so that uninstall reverts
4
+ exactly what install added, without rewriting files wholesale: both tools
5
+ keep writing their own state into these files while we are installed.
6
+ """
7
+
8
+ import copy
9
+ import datetime as _dt
10
+ import difflib
11
+ import json
12
+ import os
13
+ import re
14
+ import shlex
15
+ import subprocess
16
+ import sys
17
+ import tomllib
18
+ from pathlib import Path
19
+
20
+ from . import config, gitsync
21
+ from .util import HOME
22
+
23
+ STATE_FILE = config.CONFIG_DIR / "install-state.json"
24
+ BEGIN = "<!-- agent-memory:begin -->"
25
+ END = "<!-- agent-memory:end -->"
26
+ MISSING = "__missing__"
27
+
28
+ CLAUDE_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR", HOME / ".claude"))
29
+ CODEX_DIR = Path(os.environ.get("CODEX_HOME", HOME / ".codex"))
30
+ LOCAL_BIN = HOME / ".local" / "bin" / "mem"
31
+
32
+ SH_BEGIN = "# >>> agent-memory >>>"
33
+ SH_END = "# <<< agent-memory <<<"
34
+ PATH_LINE = 'export PATH="$HOME/.local/bin:$PATH"'
35
+
36
+ HOOK_EVENTS = [("SessionStart", "session-start", 15),
37
+ ("Stop", "stop", 15),
38
+ ("SessionEnd", "session-end", 3)] # Codex clamps SessionEnd to 3s
39
+
40
+
41
+ # ---------------------------------------------------------------- helpers
42
+
43
+ def hook_command(event, tool):
44
+ return " ".join(shlex.quote(a) for a in config.mem_command()) + f" hook {event} --tool {tool}"
45
+
46
+
47
+ def _is_ours(group):
48
+ return any(("agent-memory" in h.get("command", "") or "agent_memory" in h.get("command", ""))
49
+ and " hook " in h.get("command", "")
50
+ for h in group.get("hooks", []))
51
+
52
+
53
+ def _home_rel(path):
54
+ path = Path(path)
55
+ try:
56
+ return "~/" + path.relative_to(HOME).as_posix()
57
+ except ValueError:
58
+ return "/" + path.as_posix() # Claude Code reads "//abs" as an absolute path
59
+
60
+
61
+ def _read(path):
62
+ try:
63
+ return Path(path).read_text("utf-8")
64
+ except FileNotFoundError:
65
+ return None
66
+
67
+
68
+ def _block(text, inner, begin=BEGIN, end_mark=END):
69
+ """Insert or replace our marked block in a file's text."""
70
+ text = text or ""
71
+ block = f"{begin}\n{inner.rstrip()}\n{end_mark}\n"
72
+ if begin in text and end_mark in text:
73
+ start, end = text.index(begin), text.index(end_mark) + len(end_mark)
74
+ return text[:start] + block.rstrip("\n") + text[end:]
75
+ sep = "" if not text or text.endswith("\n\n") else ("\n" if text.endswith("\n") else "\n\n")
76
+ return text + sep + block
77
+
78
+
79
+ def _unblock(text, begin=BEGIN, end_mark=END):
80
+ if not text or begin not in text or end_mark not in text:
81
+ return text
82
+ start, end = text.index(begin), text.index(end_mark) + len(end_mark)
83
+ out = text[:start].rstrip("\n") + text[end:].lstrip("\n")
84
+ out = out.strip("\n")
85
+ return out + "\n" if out else ""
86
+
87
+
88
+ def _toml_section(text, section):
89
+ """(start, end) line indexes of a [section] body, or None."""
90
+ lines = text.split("\n")
91
+ header = re.compile(r"^\s*\[" + re.escape(section) + r"\]\s*(#.*)?$")
92
+ for i, line in enumerate(lines):
93
+ if header.match(line):
94
+ j = i + 1
95
+ while j < len(lines) and not re.match(r"^\s*\[", lines[j]):
96
+ j += 1
97
+ return i, j
98
+ return None
99
+
100
+
101
+ def toml_get(text, section, key):
102
+ try:
103
+ return tomllib.loads(text).get(section, {}).get(key, MISSING)
104
+ except tomllib.TOMLDecodeError:
105
+ return MISSING
106
+
107
+
108
+ def toml_set(text, section, key, literal):
109
+ """Set `key = literal` in [section] (creating either) with minimal edits."""
110
+ lines = text.split("\n")
111
+ span = _toml_section(text, section)
112
+ pattern = re.compile(r"^\s*" + re.escape(key) + r"\s*=")
113
+ if span:
114
+ i, j = span
115
+ for k in range(i + 1, j):
116
+ if pattern.match(lines[k]):
117
+ lines[k] = f"{key} = {literal}"
118
+ return "\n".join(lines)
119
+ lines.insert(i + 1, f"{key} = {literal}")
120
+ return "\n".join(lines)
121
+ if lines and lines[-1] == "":
122
+ lines.pop()
123
+ lines += ["", f"[{section}]", f"{key} = {literal}", ""]
124
+ return "\n".join(lines)
125
+
126
+
127
+ def toml_unset(text, section, key):
128
+ span = _toml_section(text, section)
129
+ if not span:
130
+ return text
131
+ lines = text.split("\n")
132
+ i, j = span
133
+ pattern = re.compile(r"^\s*" + re.escape(key) + r"\s*=")
134
+ lines = [line for n, line in enumerate(lines) if not (i < n < j and pattern.match(line))]
135
+ span = _toml_section("\n".join(lines), section)
136
+ if span: # drop the section if we emptied it
137
+ i, j = span
138
+ if all(not line.strip() for line in lines[i + 1:j]):
139
+ del lines[i:j]
140
+ return "\n".join(lines)
141
+
142
+
143
+ def _toml_literal(value):
144
+ return json.dumps(value) if not isinstance(value, bool) else ("true" if value else "false")
145
+
146
+
147
+ def _login_path():
148
+ """PATH of a fresh login shell, unaffected by whatever launched us."""
149
+ shell = os.environ.get("SHELL", "/bin/zsh")
150
+ env = {"HOME": str(HOME), "USER": os.environ.get("USER", ""), "SHELL": shell, "TERM": "dumb"}
151
+ try:
152
+ r = subprocess.run([shell, "-lic", 'printf "__P__%s__P__" "$PATH"'], env=env,
153
+ capture_output=True, text=True, timeout=15)
154
+ except (OSError, subprocess.SubprocessError):
155
+ return None
156
+ m = re.search(r"__P__(.*?)__P__", r.stdout, re.S)
157
+ return m.group(1).split(":") if m else None
158
+
159
+
160
+ def _rc_file():
161
+ shell = os.path.basename(os.environ.get("SHELL", "zsh"))
162
+ return HOME / (".bashrc" if shell == "bash" else ".zshrc")
163
+
164
+
165
+ def _path_plan(plan, state):
166
+ """Make `mem` reachable from a new terminal when ~/.local/bin is not on PATH."""
167
+ path = _login_path()
168
+ if path is None or str(LOCAL_BIN.parent) in path:
169
+ return
170
+ rc = _rc_file()
171
+ plan.edit(rc, _block(_read(rc), PATH_LINE, SH_BEGIN, SH_END))
172
+ state["shell_rc"] = str(rc)
173
+
174
+
175
+ # ---------------------------------------------------------------- planning
176
+
177
+ class Plan:
178
+ def __init__(self):
179
+ self.files = {} # path -> (old_text or None, new_text or None)
180
+ self.state = {}
181
+
182
+ def edit(self, path, new_text):
183
+ path = Path(path)
184
+ old = self.files[path][0] if path in self.files else _read(path)
185
+ self.files[path] = (old, new_text)
186
+
187
+ def show(self):
188
+ for path, (old, new) in self.files.items():
189
+ if old == new:
190
+ continue
191
+ a = (old or "").splitlines(keepends=True)
192
+ b = (new or "").splitlines(keepends=True)
193
+ label = "(new file)" if old is None else "(delete)" if new is None else ""
194
+ print(f"=== {path} {label}")
195
+ sys.stdout.writelines(difflib.unified_diff(a, b, "before", "after", n=1))
196
+ print()
197
+
198
+ def apply(self, backup_dir):
199
+ backup_dir.mkdir(parents=True, exist_ok=True)
200
+ for path, (old, new) in self.files.items():
201
+ if old == new:
202
+ continue
203
+ if old is not None:
204
+ dest = backup_dir / str(path).lstrip("/").replace("/", "__")
205
+ dest.write_text(old, "utf-8")
206
+ if new is None:
207
+ path.unlink(missing_ok=True)
208
+ else:
209
+ path.parent.mkdir(parents=True, exist_ok=True)
210
+ path.write_text(new, "utf-8")
211
+
212
+
213
+ def _claude_plan(plan, data_dir, state):
214
+ settings_path = CLAUDE_DIR / "settings.json"
215
+ text = _read(settings_path)
216
+ settings = json.loads(text) if text else {}
217
+ new = copy.deepcopy(settings)
218
+
219
+ state.setdefault("claude.autoMemoryEnabled", settings.get("autoMemoryEnabled", MISSING))
220
+ new["autoMemoryEnabled"] = False
221
+
222
+ hooks = new.setdefault("hooks", {})
223
+ for event, name, timeout in HOOK_EVENTS:
224
+ groups = [g for g in hooks.get(event, []) if not _is_ours(g)]
225
+ groups.append({"hooks": [{"type": "command", "command": hook_command(name, "claude-code"),
226
+ "timeout": timeout}]})
227
+ hooks[event] = groups
228
+
229
+ data = _home_rel(data_dir)
230
+ rules = [f"Read({data}/**)", f"Edit({data}/**)",
231
+ f"Bash({config.BIN} search:*)", f"Bash({config.BIN} list:*)",
232
+ f"Bash({config.BIN} show:*)",
233
+ "Bash(mem search:*)", "Bash(mem list:*)", "Bash(mem show:*)"]
234
+ allow = new.setdefault("permissions", {}).setdefault("allow", [])
235
+ added = [r for r in rules if r not in allow]
236
+ allow.extend(added)
237
+ state["claude.permissions"] = sorted(set(state.get("claude.permissions", [])) | set(added))
238
+ plan.edit(settings_path, json.dumps(new, indent=2, ensure_ascii=False) + "\n")
239
+
240
+ # Import a copy in the config folder: its path stays the same whichever way the package is
241
+ # installed or upgraded, and `mem install` refreshes it.
242
+ plan.edit(config.PROTOCOL_COPY, config.PROTOCOL.read_text("utf-8"))
243
+ claude_md = CLAUDE_DIR / "CLAUDE.md"
244
+ plan.edit(claude_md, _block(_read(claude_md), f"@{config.PROTOCOL_COPY}"))
245
+
246
+
247
+ def _codex_plan(plan, data_dir, state):
248
+ hooks_path = CODEX_DIR / "hooks.json"
249
+ text = _read(hooks_path)
250
+ doc = json.loads(text) if text else {}
251
+ new = copy.deepcopy(doc)
252
+ hooks = new.setdefault("hooks", {})
253
+ for event, name, timeout in HOOK_EVENTS:
254
+ groups = [g for g in hooks.get(event, []) if not _is_ours(g)]
255
+ handler = {"type": "command", "command": hook_command(name, "codex"), "timeout": timeout}
256
+ if event == "SessionStart":
257
+ handler["statusMessage"] = "Loading shared memory"
258
+ handler["additionalContextLimit"] = 0 # keep the index inline, never spill to disk
259
+ groups.append({"hooks": [handler]})
260
+ hooks[event] = groups
261
+ plan.edit(hooks_path, json.dumps(new, indent=2, ensure_ascii=False) + "\n")
262
+
263
+ toml_path = CODEX_DIR / "config.toml"
264
+ toml = _read(toml_path) or ""
265
+ for key in ("generate_memories", "use_memories"):
266
+ state.setdefault(f"codex.memories.{key}", toml_get(toml, "memories", key))
267
+ toml = toml_set(toml, "memories", key, "false")
268
+ roots = toml_get(toml, "sandbox_workspace_write", "writable_roots")
269
+ roots = [] if roots == MISSING else list(roots)
270
+ state.setdefault("codex.writable_roots.had_key", toml_get(
271
+ _read(toml_path) or "", "sandbox_workspace_write", "writable_roots") != MISSING)
272
+ if str(data_dir) not in roots:
273
+ roots.append(str(data_dir))
274
+ state["codex.writable_root"] = str(data_dir)
275
+ toml = toml_set(toml, "sandbox_workspace_write", "writable_roots", json.dumps(roots))
276
+ tomllib.loads(toml) # refuse to write a config Codex could not parse
277
+ plan.edit(toml_path, toml)
278
+
279
+ agents_md = CODEX_DIR / "AGENTS.md"
280
+ protocol = config.PROTOCOL.read_text("utf-8")
281
+ plan.edit(agents_md, _block(_read(agents_md), protocol))
282
+
283
+
284
+ # ---------------------------------------------------------------- commands
285
+
286
+ def _load_state():
287
+ try:
288
+ return json.loads(STATE_FILE.read_text("utf-8"))
289
+ except (OSError, ValueError):
290
+ return {}
291
+
292
+
293
+ def init_data(data_dir):
294
+ data_dir.mkdir(parents=True, exist_ok=True)
295
+ (data_dir / "memories").mkdir(exist_ok=True)
296
+ gi = data_dir / ".gitignore"
297
+ if not gi.exists():
298
+ gi.write_text("staging/\n.cache/\n.DS_Store\n", "utf-8")
299
+ readme = data_dir / "README.md"
300
+ if not readme.exists():
301
+ readme.write_text(
302
+ "# agent-memory data\n\n"
303
+ "One Markdown file per memory under `memories/YYYY/MM/`, written by Claude Code and\n"
304
+ "Codex through the agent-memory hooks. Edit or delete files freely; every change is\n"
305
+ "a git commit.\n", "utf-8")
306
+ if not (data_dir / ".git").exists():
307
+ gitsync.git(data_dir, "init", "-q", "-b", "main")
308
+ gitsync.git(data_dir, "add", "-A")
309
+ gitsync.git(data_dir, "-c", "commit.gpgsign=false", "commit", "-q", "--no-verify",
310
+ "-m", "Initialize memory repository")
311
+ cfg = config.load()
312
+ cfg["data_dir"] = str(data_dir)
313
+ config.save(cfg)
314
+ print(f"data repository ready at {data_dir}; config written to {config.CONFIG_FILE}")
315
+
316
+
317
+ def install(dry_run=False, claude=True, codex=True):
318
+ data_dir = config.data_dir()
319
+ if not data_dir or not (data_dir / ".git").exists():
320
+ sys.exit("run `mem init <data-dir>` first")
321
+ state = _load_state()
322
+ plan = Plan()
323
+ if claude and CLAUDE_DIR.is_dir():
324
+ _claude_plan(plan, data_dir, state)
325
+ if codex and CODEX_DIR.is_dir():
326
+ _codex_plan(plan, data_dir, state)
327
+ _path_plan(plan, state)
328
+
329
+ plan.show()
330
+ link_ok = LOCAL_BIN.is_symlink() and LOCAL_BIN.resolve() == config.BIN.resolve()
331
+ if not link_ok:
332
+ print(f"=== {LOCAL_BIN} -> {config.BIN} (symlink)\n")
333
+ if dry_run:
334
+ print("dry run: nothing changed")
335
+ return
336
+
337
+ stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
338
+ plan.apply(config.CONFIG_DIR / "backups" / stamp)
339
+ if not link_ok and not LOCAL_BIN.exists():
340
+ LOCAL_BIN.parent.mkdir(parents=True, exist_ok=True)
341
+ LOCAL_BIN.symlink_to(config.BIN)
342
+ state["local_bin"] = str(LOCAL_BIN)
343
+ state["installed_at"] = stamp
344
+ state["claude"] = state.get("claude") or (claude and CLAUDE_DIR.is_dir())
345
+ state["codex"] = state.get("codex") or (codex and CODEX_DIR.is_dir())
346
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
347
+ STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", "utf-8")
348
+ print(f"installed; backups in {config.CONFIG_DIR / 'backups' / stamp}")
349
+ if state.get("codex"):
350
+ print("Codex: open /hooks in Codex once and trust the three agent-memory hooks.")
351
+
352
+
353
+ def uninstall(dry_run=False):
354
+ state = _load_state()
355
+ if not state:
356
+ sys.exit("nothing to uninstall (no install-state.json)")
357
+ plan = Plan()
358
+
359
+ if state.get("claude"):
360
+ path = CLAUDE_DIR / "settings.json"
361
+ text = _read(path)
362
+ if text:
363
+ s = json.loads(text)
364
+ prev = state.get("claude.autoMemoryEnabled", MISSING)
365
+ if prev == MISSING:
366
+ s.pop("autoMemoryEnabled", None)
367
+ else:
368
+ s["autoMemoryEnabled"] = prev
369
+ for event, _, _ in HOOK_EVENTS:
370
+ groups = [g for g in s.get("hooks", {}).get(event, []) if not _is_ours(g)]
371
+ if groups:
372
+ s["hooks"][event] = groups
373
+ else:
374
+ s.get("hooks", {}).pop(event, None)
375
+ if "hooks" in s and not s["hooks"]:
376
+ del s["hooks"]
377
+ ours = set(state.get("claude.permissions", []))
378
+ allow = s.get("permissions", {}).get("allow")
379
+ if allow is not None:
380
+ s["permissions"]["allow"] = [r for r in allow if r not in ours]
381
+ plan.edit(path, json.dumps(s, indent=2, ensure_ascii=False) + "\n")
382
+ md = CLAUDE_DIR / "CLAUDE.md"
383
+ text = _read(md)
384
+ if text is not None:
385
+ out = _unblock(text)
386
+ plan.edit(md, out if out else None)
387
+ if config.PROTOCOL_COPY.exists():
388
+ plan.edit(config.PROTOCOL_COPY, None)
389
+
390
+ if state.get("codex"):
391
+ path = CODEX_DIR / "hooks.json"
392
+ text = _read(path)
393
+ if text:
394
+ doc = json.loads(text)
395
+ for event, _, _ in HOOK_EVENTS:
396
+ groups = [g for g in doc.get("hooks", {}).get(event, []) if not _is_ours(g)]
397
+ if groups:
398
+ doc["hooks"][event] = groups
399
+ else:
400
+ doc.get("hooks", {}).pop(event, None)
401
+ plan.edit(path, json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
402
+ path = CODEX_DIR / "config.toml"
403
+ toml = _read(path) or ""
404
+ for key in ("generate_memories", "use_memories"):
405
+ prev = state.get(f"codex.memories.{key}", MISSING)
406
+ toml = toml_unset(toml, "memories", key) if prev == MISSING else \
407
+ toml_set(toml, "memories", key, _toml_literal(prev))
408
+ root = state.get("codex.writable_root")
409
+ roots = toml_get(toml, "sandbox_workspace_write", "writable_roots")
410
+ if root and roots != MISSING:
411
+ roots = [r for r in roots if r != root]
412
+ if roots or state.get("codex.writable_roots.had_key"):
413
+ toml = toml_set(toml, "sandbox_workspace_write", "writable_roots", json.dumps(roots))
414
+ else:
415
+ toml = toml_unset(toml, "sandbox_workspace_write", "writable_roots")
416
+ tomllib.loads(toml)
417
+ plan.edit(path, toml)
418
+ md = CODEX_DIR / "AGENTS.md"
419
+ text = _read(md)
420
+ if text is not None:
421
+ plan.edit(md, _unblock(text)) # keep the file: it existed (empty) before
422
+
423
+ rc = state.get("shell_rc")
424
+ if rc:
425
+ text = _read(rc)
426
+ if text is not None:
427
+ plan.edit(Path(rc), _unblock(text, SH_BEGIN, SH_END))
428
+
429
+ plan.show()
430
+ local = state.get("local_bin")
431
+ if local:
432
+ print(f"=== remove symlink {local}\n")
433
+ if dry_run:
434
+ print("dry run: nothing changed")
435
+ return
436
+ stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
437
+ plan.apply(config.CONFIG_DIR / "backups" / f"uninstall-{stamp}")
438
+ if local and Path(local).is_symlink():
439
+ Path(local).unlink()
440
+ STATE_FILE.rename(STATE_FILE.with_name(f"install-state.uninstalled-{stamp}.json"))
441
+ print("uninstalled. Native memory settings restored; the data repository is untouched.")
442
+
443
+
444
+ def doctor():
445
+ ok = True
446
+
447
+ def check(cond, msg):
448
+ nonlocal ok
449
+ print(("ok " if cond else "FAIL ") + msg)
450
+ ok &= bool(cond)
451
+
452
+ data = config.data_dir()
453
+ check(data and (data / ".git").exists(), f"data repository: {data}")
454
+ state = _load_state()
455
+ check(bool(state), f"install state: {STATE_FILE}")
456
+ if state.get("claude"):
457
+ s = json.loads(_read(CLAUDE_DIR / "settings.json") or "{}")
458
+ check(s.get("autoMemoryEnabled") is False, "Claude auto memory disabled")
459
+ for event, _, _ in HOOK_EVENTS:
460
+ check(any(_is_ours(g) for g in s.get("hooks", {}).get(event, [])), f"Claude {event} hook")
461
+ check(f"@{config.PROTOCOL_COPY}" in (_read(CLAUDE_DIR / "CLAUDE.md") or ""), "Claude CLAUDE.md imports PROTOCOL.md")
462
+ check(_read(config.PROTOCOL_COPY) == config.PROTOCOL.read_text("utf-8"),
463
+ "Claude's PROTOCOL.md copy is current (else run `mem install`)")
464
+ if state.get("codex"):
465
+ doc = json.loads(_read(CODEX_DIR / "hooks.json") or "{}")
466
+ for event, _, _ in HOOK_EVENTS:
467
+ check(any(_is_ours(g) for g in doc.get("hooks", {}).get(event, [])), f"Codex {event} hook")
468
+ toml = _read(CODEX_DIR / "config.toml") or ""
469
+ check(toml_get(toml, "memories", "use_memories") is False, "Codex native memories disabled")
470
+ check(config.PROTOCOL.read_text("utf-8").rstrip() in (_read(CODEX_DIR / "AGENTS.md") or ""),
471
+ "Codex AGENTS.md has the current protocol (else run `mem install`)")
472
+ path = _login_path()
473
+ if path is not None:
474
+ check(str(LOCAL_BIN.parent) in path or SH_BEGIN in (_read(_rc_file()) or ""),
475
+ f"`mem` on a new terminal's PATH ({LOCAL_BIN.parent})")
476
+ if data:
477
+ check(gitsync.has_remote(data), "data repository has a remote (optional)")
478
+ staged = list((data / "staging").glob("*/*.md")) if (data / "staging").exists() else []
479
+ check(not staged, f"no stuck staged files ({len(staged)})")
480
+ logf = data / ".cache" / "agent-memory.log"
481
+ errors = (_read(logf) or "").count("ERROR")
482
+ check(errors == 0, f"hook errors in {logf}: {errors}")
483
+ return 0 if ok else 1
agent_memory/lint.py ADDED
@@ -0,0 +1,45 @@
1
+ """Checks that a memory follows the body skeleton in PROTOCOL.md.
2
+
3
+ They only report. A memory that breaks a rule is still worth keeping until
4
+ someone tidies it, so nothing is rejected.
5
+ """
6
+
7
+ import re
8
+
9
+ DESCRIPTION_MAX = 150 # the index cuts longer descriptions (select.line)
10
+ BODY_MAX = 1500
11
+ NEEDS_REASON = ("feedback", "project")
12
+
13
+ _WHY = re.compile(r"\*\*\s*why\s*:?\s*\*\*", re.I)
14
+ _HOW = re.compile(r"\*\*\s*how to apply\s*:?\s*\*\*", re.I)
15
+ _WIKI = re.compile(r"\[\[([^\]]+)\]\]")
16
+ KINDS = {"long": "too long", "reason": "without Why or How to apply", "heading": "opening with a heading",
17
+ "description": "with a description the index cuts", "links": "with broken links", "empty": "empty"}
18
+
19
+
20
+ def check(m, body, names):
21
+ """What is wrong with one memory, as (kind, message) pairs; empty when nothing is.
22
+
23
+ m is a memory as store.load_all returns it; names holds every memory's name,
24
+ for checking [[links]].
25
+ """
26
+ out = []
27
+ desc = " ".join(m["description"].split())
28
+ if len(desc) > DESCRIPTION_MAX:
29
+ out.append(("description", f"description is {len(desc)} characters; the index shows {DESCRIPTION_MAX}"))
30
+ text = body.strip()
31
+ if not text:
32
+ return out + [("empty", "body is empty")]
33
+ if len(text) > BODY_MAX:
34
+ out.append(("long", f"body is {len(text):,} characters; keep it under about {BODY_MAX:,}"))
35
+ first = next(line for line in text.splitlines() if line.strip())
36
+ if first.lstrip().startswith("#"):
37
+ out.append(("heading", "opens with a heading instead of the fact"))
38
+ if m["type"] in NEEDS_REASON:
39
+ missing = [label for label, rx in (("**Why:**", _WHY), ("**How to apply:**", _HOW)) if not rx.search(body)]
40
+ if missing:
41
+ out.append(("reason", "no " + " or ".join(missing)))
42
+ broken = sorted({n.strip() for n in _WIKI.findall(body)} - set(names))
43
+ if broken:
44
+ out.append(("links", "links to " + ", ".join(f"[[{n}]]" for n in broken) + ", which no memory is named"))
45
+ return out
agent_memory/select.py ADDED
@@ -0,0 +1,105 @@
1
+ """Choosing which memories a session sees, and rendering the index."""
2
+
3
+ from .context import describe
4
+ from .util import is_under, parse_time, specific
5
+
6
+ TYPE_ORDER = {"user": 0, "feedback": 1, "reference": 2, "project": 3}
7
+
8
+ # Claude Code caps injected hook context at 10,000 characters.
9
+ DEFAULT_BUDGET = 9000
10
+ GLOBAL_SHARE = 0.5
11
+
12
+
13
+ def location_score(source, ctx):
14
+ """How strongly a memory recorded at `source` belongs to the session at `ctx`.
15
+
16
+ 0 means unrelated. Higher is closer: same folder, same git checkout, same
17
+ remote (another clone or worktree), then parent or child folders.
18
+ """
19
+ best = 0
20
+ remote = source.get("git_remote")
21
+ if remote and remote == ctx.get("git_remote"):
22
+ best = max(best, 80)
23
+ here, there = ctx.get("cwd"), source.get("cwd")
24
+ if here and there:
25
+ if here == there:
26
+ best = max(best, 100)
27
+ root = source.get("git_root")
28
+ if root and root == ctx.get("git_root") and source.get("host") in (None, ctx.get("host")):
29
+ best = max(best, 90)
30
+ if is_under(here, there) and specific(there):
31
+ best = max(best, 70)
32
+ if is_under(there, here) and specific(here):
33
+ best = max(best, 60)
34
+ return best
35
+
36
+
37
+ def _recency(m):
38
+ t = parse_time(m.get("updated")) or parse_time(m.get("created"))
39
+ return t.timestamp() if t else 0.0
40
+
41
+
42
+ def visible(memories, ctx, include_all=False):
43
+ """(global, here) lists visible from ctx, each sorted best-first."""
44
+ globals_, here = [], []
45
+ for m in memories:
46
+ if m["scope"] == "global":
47
+ globals_.append(m)
48
+ continue
49
+ score = location_score(m["source"], ctx)
50
+ if score or include_all:
51
+ here.append((score, m))
52
+ globals_.sort(key=lambda m: (TYPE_ORDER.get(m["type"], 9), -_recency(m)))
53
+ here.sort(key=lambda sm: (-sm[0], -_recency(sm[1])))
54
+ return globals_, [m for _, m in here]
55
+
56
+
57
+ def line(m):
58
+ desc = " ".join(m["description"].split())
59
+ if len(desc) > 150:
60
+ desc = desc[:149] + "…"
61
+ return f"- [{m['type']}] {desc} → {m['rel']}"
62
+
63
+
64
+ def _take(items, budget):
65
+ out, used = [], 0
66
+ for m in items:
67
+ text = line(m)
68
+ if used + len(text) + 1 > budget:
69
+ break
70
+ out.append(text)
71
+ used += len(text) + 1
72
+ return out, used
73
+
74
+
75
+ THROWAWAY_NOTE = ("throwaway folder: `here` memories will be filed under the project "
76
+ "this session works in")
77
+
78
+
79
+ def render(memories, ctx, paths, budget=DEFAULT_BUDGET, throwaway=False):
80
+ globals_, here = visible(memories, ctx)
81
+ location = describe(ctx) + (f" ({THROWAWAY_NOTE})" if throwaway else "")
82
+ header = [
83
+ "# Shared memory index",
84
+ f"memories: {paths['memories']}",
85
+ f"staging (write new memories here): {paths['staging']}",
86
+ f"mem: {paths['mem']}",
87
+ f"location: {location}",
88
+ ]
89
+ budget -= sum(len(h) + 1 for h in header) + 400 # headings and overflow notes
90
+
91
+ g_lines, g_used = _take(globals_, int(budget * GLOBAL_SHARE))
92
+ h_lines, h_used = _take(here, budget - g_used)
93
+ if len(g_lines) < len(globals_) and h_used < budget - g_used:
94
+ g_lines, _ = _take(globals_, budget - h_used)
95
+
96
+ out = list(header)
97
+ out += ["", f"## Global ({len(globals_)})"]
98
+ out += g_lines or ["(none)"]
99
+ if len(g_lines) < len(globals_):
100
+ out.append(f"({len(globals_) - len(g_lines)} more: `mem list --global`)")
101
+ out += ["", f"## This location ({len(here)})"]
102
+ out += h_lines or ["(none)"]
103
+ if len(h_lines) < len(here):
104
+ out.append(f"({len(here) - len(h_lines)} more: `mem list --here`)")
105
+ return "\n".join(out) + "\n"