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.
- agent_memory/PROTOCOL.md +80 -0
- agent_memory/__init__.py +1 -0
- agent_memory/__main__.py +7 -0
- agent_memory/capture.py +207 -0
- agent_memory/cli.py +317 -0
- agent_memory/config.py +53 -0
- agent_memory/context.py +73 -0
- agent_memory/embed_worker.py +90 -0
- agent_memory/frontmatter.py +146 -0
- agent_memory/gitsync.py +119 -0
- agent_memory/hooks.py +95 -0
- agent_memory/importers.py +231 -0
- agent_memory/install.py +483 -0
- agent_memory/lint.py +45 -0
- agent_memory/select.py +105 -0
- agent_memory/sessions.py +64 -0
- agent_memory/store.py +97 -0
- agent_memory/ui.py +410 -0
- agent_memory/ui_server.py +371 -0
- agent_memory/util.py +101 -0
- agent_memory/webui/app.css +192 -0
- agent_memory/webui/app.js +635 -0
- agent_memory/webui/index.html +31 -0
- agent_memory/workdir.py +135 -0
- agent_memory_git-0.1.0.dist-info/METADATA +183 -0
- agent_memory_git-0.1.0.dist-info/RECORD +29 -0
- agent_memory_git-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_git-0.1.0.dist-info/entry_points.txt +3 -0
- agent_memory_git-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_memory/install.py
ADDED
|
@@ -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"
|