agent-memory-brain 0.2.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.
Files changed (54) hide show
  1. agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
  2. agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
  3. agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
  4. agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
  6. devmem/__init__.py +3 -0
  7. devmem/ado.py +433 -0
  8. devmem/agent_colors.py +74 -0
  9. devmem/cli.py +500 -0
  10. devmem/db.py +1069 -0
  11. devmem/dispatch.py +478 -0
  12. devmem/embed.py +111 -0
  13. devmem/filelock.py +53 -0
  14. devmem/git_sync.py +261 -0
  15. devmem/guard.py +214 -0
  16. devmem/hook_log.py +49 -0
  17. devmem/ingest/__init__.py +1 -0
  18. devmem/ingest/claude.py +87 -0
  19. devmem/ingest/codex.py +175 -0
  20. devmem/ingest/runner.py +318 -0
  21. devmem/ingest/subagents.py +250 -0
  22. devmem/maintenance.py +801 -0
  23. devmem/mcp_server.py +542 -0
  24. devmem/messages.py +684 -0
  25. devmem/panel/__init__.py +1 -0
  26. devmem/panel/app.py +971 -0
  27. devmem/panel/state.py +358 -0
  28. devmem/panel/static/base.css +27 -0
  29. devmem/panel/static/brain.css +473 -0
  30. devmem/panel/static/brain.js +1118 -0
  31. devmem/panel/static/brain3d.js +600 -0
  32. devmem/panel/static/chat.js +18 -0
  33. devmem/panel/static/common.js +208 -0
  34. devmem/panel/static/site.css +508 -0
  35. devmem/panel/static/tokens.css +19 -0
  36. devmem/panel/templates/ado.html +62 -0
  37. devmem/panel/templates/agents.html +35 -0
  38. devmem/panel/templates/audit.html +19 -0
  39. devmem/panel/templates/base.html +42 -0
  40. devmem/panel/templates/brain.html +101 -0
  41. devmem/panel/templates/maintenance.html +32 -0
  42. devmem/panel/templates/memory.html +71 -0
  43. devmem/panel/templates/message_thread.html +49 -0
  44. devmem/panel/templates/messages.html +8 -0
  45. devmem/panel/templates/overview.html +109 -0
  46. devmem/panel/templates/project.html +48 -0
  47. devmem/panel/templates/projects.html +21 -0
  48. devmem/panel/templates/session.html +49 -0
  49. devmem/panel/templates/sessions.html +67 -0
  50. devmem/panel/templates/subagent.html +27 -0
  51. devmem/procs.py +10 -0
  52. devmem/projects.py +17 -0
  53. devmem/secrets.py +9 -0
  54. devmem/skills.py +105 -0
devmem/git_sync.py ADDED
@@ -0,0 +1,261 @@
1
+ """Private Git memory sync. State is deliberately kept outside SQLite."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import tempfile
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+
13
+ from .db import connection, now, save_memory, update_memory
14
+ from .filelock import acquire, release
15
+ from .messages import guard
16
+ from .procs import executable
17
+
18
+ _KINDS = {"note", "decision", "preference", "snippet", "todo"}
19
+
20
+
21
+ def _path(name: str, default: str) -> Path:
22
+ return Path(os.environ.get(name, default)).expanduser()
23
+
24
+
25
+ def config_path() -> Path: return _path("DEVMEM_GIT_SYNC", "~/.devmem/git-sync.json")
26
+ def state_path() -> Path: return _path("DEVMEM_GIT_SYNC_STATE", "~/.devmem/git-sync-state.json")
27
+ def key_path() -> Path: return _path("DEVMEM_GIT_SYNC_KEY", "~/.devmem/git-sync-key")
28
+ def repo_path() -> Path: return _path("DEVMEM_GIT_DIR", "~/.devmem/git-memories")
29
+ def lock_path() -> Path: return _path("DEVMEM_GIT_SYNC_LOCK", "~/.devmem/git-sync.lock")
30
+
31
+
32
+ def _read(path: Path, fallback):
33
+ try:
34
+ with path.open(encoding="utf-8") as f: return json.load(f)
35
+ except FileNotFoundError: return fallback
36
+ except (OSError, ValueError) as exc: raise ValueError(f"invalid {path.name}: {exc}") from exc
37
+
38
+
39
+ def config() -> dict:
40
+ data = _read(config_path(), {})
41
+ if not isinstance(data, dict): raise ValueError("git sync config must be an object")
42
+ return data
43
+
44
+
45
+ def _atomic(path: Path, data: dict) -> None:
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ fd, temp = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
48
+ try:
49
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
50
+ json.dump(data, f, separators=(",", ":")); f.write("\n"); f.flush(); os.fsync(f.fileno())
51
+ os.chmod(temp, 0o600); os.replace(temp, path)
52
+ finally:
53
+ if os.path.exists(temp): os.unlink(temp)
54
+
55
+
56
+ def write_config(data: dict) -> None: _atomic(config_path(), data)
57
+
58
+
59
+ def _state() -> dict:
60
+ data = _read(state_path(), {"memories": {}, "out_of_sync": [], "last_sync": None, "last_error": None, "pushed": 0, "pulled": 0})
61
+ if not isinstance(data, dict): raise ValueError("git sync state must be an object")
62
+ data.setdefault("memories", {}); data.setdefault("out_of_sync", [])
63
+ return data
64
+
65
+
66
+ def _git_env(cfg: dict) -> dict:
67
+ env = os.environ.copy()
68
+ if not os.environ.get("DEVMEM_GIT_REMOTE"):
69
+ key = key_path()
70
+ known = key.with_name("git-sync-known_hosts")
71
+ env["GIT_SSH_COMMAND"] = f"ssh -i {key} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={known}"
72
+ return env
73
+
74
+
75
+ def _run(cfg: dict, *args: str, cwd: Path | None = None) -> str:
76
+ result = subprocess.run([executable("git"), *args], cwd=cwd, env=_git_env(cfg), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
77
+ if result.returncode: raise RuntimeError((result.stderr or result.stdout or "git failed").strip()[:500])
78
+ return result.stdout.strip()
79
+
80
+
81
+ def _remote(cfg: dict) -> str:
82
+ injected = os.environ.get("DEVMEM_GIT_REMOTE")
83
+ if injected: return injected
84
+ repo = cfg.get("repo", "")
85
+ if not re.fullmatch(r"[^/\s]+/[^/\s]+", repo): raise ValueError("repo must be owner/name")
86
+ return f"ssh://git@ssh.github.com:443/{repo}.git" if cfg.get("ssh_port") == 443 else f"git@github.com:{repo}.git"
87
+
88
+
89
+ def _project(value: str | None) -> str:
90
+ return Path(value).name if value else "global"
91
+
92
+
93
+ def _tags(value: str) -> list[str]: return [x.strip() for x in value.split(",") if x.strip()]
94
+ def _scope(row: dict, cfg: dict) -> bool:
95
+ return _project(row.get("project")) in cfg.get("projects", []) or (not row.get("project") and cfg.get("global_tag") in _tags(row.get("tags") or ""))
96
+
97
+
98
+ def in_scope(row: dict) -> bool:
99
+ try: return _scope(row, config())
100
+ except ValueError: return False
101
+
102
+
103
+ def _slug(value: str) -> str:
104
+ result = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
105
+ return result[:60] or "memory"
106
+
107
+
108
+ def _frontmatter(meta: dict, content: str) -> str:
109
+ tags = ", ".join(meta["tags"])
110
+ review = f"review_after: {meta['review_after']}\n" if meta.get("review_after") else ""
111
+ ident = f"id: {meta['id']}\n" if meta.get("id") is not None else ""
112
+ updated = f"updated: {meta['updated']}\n" if meta.get("updated") else ""
113
+ return f"---\n{ident}kind: {meta['kind']}\ntitle: {meta['title']}\nproject: {meta['project']}\ntags: [{tags}]\n{updated}{review}---\n{content.rstrip()}\n"
114
+
115
+
116
+ def _parse(path: Path) -> tuple[dict, str]:
117
+ text = path.read_text(encoding="utf-8")
118
+ if not text.startswith("---\n"): raise ValueError("frontmatter is required")
119
+ head, sep, content = text[4:].partition("\n---\n")
120
+ if not sep: raise ValueError("frontmatter is not closed")
121
+ data = {}
122
+ for line in head.splitlines():
123
+ key, mark, value = line.partition(":")
124
+ if not mark: raise ValueError("invalid frontmatter")
125
+ value = value.strip(); data[key.strip()] = value
126
+ data["tags"] = [x.strip() for x in data.get("tags", "[]").strip("[]").split(",") if x.strip()]
127
+ if data.get("kind", "note") not in _KINDS: raise ValueError("invalid kind")
128
+ data["kind"] = data.get("kind", "note"); data["project"] = data.get("project", "global")
129
+ return data, content.rstrip("\n")
130
+
131
+
132
+ def _blob(path: Path) -> str:
133
+ data = path.read_bytes(); return hashlib.sha1(f"blob {len(data)}\0".encode() + data).hexdigest()
134
+
135
+
136
+ def _write_memory(root: Path, row: dict) -> Path:
137
+ folder = _project(row.get("project")); path = root / "memories" / folder / f"{row['id']}-{_slug(row['title'])}.md"
138
+ guard(row["content"], row["title"])
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ path.write_text(_frontmatter({"id": row["id"], "kind": row["kind"], "title": row["title"], "project": folder, "tags": _tags(row["tags"]), "updated": row["updated_at"].replace("+00:00", "Z"), "review_after": row.get("review_after")}, row["content"]), encoding="utf-8")
141
+ return path
142
+
143
+
144
+ def _layout(root: Path) -> None:
145
+ readme = root / "README.md"; agents = root / "AGENTS.md"
146
+ if not readme.exists(): readme.write_text("# devmem memories\n\nPersonal memories synchronized by devmem.\n", encoding="utf-8")
147
+ if not agents.exists(): agents.write_text("# Memory instructions\n\nSearch before editing. Add new memories in `inbox/`; edit existing files in place and bump `updated`. Never put secrets here.\n", encoding="utf-8")
148
+ (root / "memories").mkdir(exist_ok=True); (root / "inbox").mkdir(exist_ok=True)
149
+
150
+
151
+ def _ensure_repo(cfg: dict) -> Path:
152
+ root = repo_path()
153
+ if not (root / ".git").is_dir():
154
+ root.parent.mkdir(parents=True, exist_ok=True); _run(cfg, "clone", _remote(cfg), str(root))
155
+ _run(cfg, "config", "user.name", str(cfg.get("author_name", "devmem")), cwd=root)
156
+ _run(cfg, "config", "user.email", str(cfg.get("author_email", "devmem@localhost")), cwd=root)
157
+ # A run that failed before committing leaves files behind; the DB and the remote are the truth, so drop them.
158
+ try: _run(cfg, "reset", "--hard", cwd=root)
159
+ except RuntimeError: pass # no commit yet
160
+ _run(cfg, "clean", "-fd", cwd=root)
161
+ _run(cfg, "fetch", "origin", cwd=root)
162
+ branch = cfg.get("branch", "main")
163
+ try: _run(cfg, "rev-parse", "--verify", f"origin/{branch}", cwd=root)
164
+ except RuntimeError:
165
+ _run(cfg, "symbolic-ref", "HEAD", f"refs/heads/{branch}", cwd=root) # an empty remote: first commit goes to the configured branch
166
+ return root
167
+ try: _run(cfg, "rebase", f"origin/{branch}", cwd=root)
168
+ except RuntimeError as exc:
169
+ _run(cfg, "rebase", "--abort", cwd=root)
170
+ raise RuntimeError("git conflict: " + str(exc)) from exc
171
+ return root
172
+
173
+
174
+ def _commit(cfg: dict, root: Path, summary: dict, dry_run: bool) -> bool:
175
+ status = _run(cfg, "status", "--porcelain", cwd=root)
176
+ if not status: return False
177
+ if dry_run: return True
178
+ _run(cfg, "add", "-A", cwd=root)
179
+ _run(cfg, "commit", "-m", f"devmem sync: +{summary['pushed']} ~{summary['pulled']} -{summary['deleted']}", cwd=root)
180
+ try: _run(cfg, "push", "origin", cfg.get("branch", "main"), cwd=root)
181
+ except RuntimeError:
182
+ _run(cfg, "fetch", "origin", cwd=root); _run(cfg, "rebase", f"origin/{cfg.get('branch', 'main')}", cwd=root); _run(cfg, "push", "origin", cfg.get("branch", "main"), cwd=root)
183
+ return True
184
+
185
+
186
+ def sync(*, dry_run: bool = False) -> dict:
187
+ cfg = config(); result = {"pushed": 0, "pulled": 0, "deleted": 0, "errors": [], "out_of_sync": []}
188
+ if not cfg.get("enabled"): return {**result, "disabled": True}
189
+ if dry_run:
190
+ with connection() as conn:
191
+ result["pushed"] = sum(1 for row in conn.execute("SELECT * FROM memories") if _scope(dict(row), cfg))
192
+ result["dry_run"] = True
193
+ return result
194
+ lock_path().parent.mkdir(parents=True, exist_ok=True)
195
+ with lock_path().open("a+") as lock:
196
+ try: acquire(lock)
197
+ except BlockingIOError: return {**result, "locked": True}
198
+ state = _state()
199
+ try:
200
+ root = _ensure_repo(cfg); _layout(root)
201
+ files = {str(path.relative_to(root)): path for path in (root / "memories").glob("**/*.md")}
202
+ with connection() as conn:
203
+ rows = {str(row["id"]): dict(row) for row in conn.execute("SELECT * FROM memories").fetchall()}
204
+ known = dict(state["memories"]) # only saved when the whole run succeeds
205
+ for ident, prior in list(known.items()):
206
+ path = root / prior["path"]
207
+ if ident not in rows:
208
+ if path.exists(): path.unlink(); result["deleted"] += 1
209
+ known.pop(ident, None); continue
210
+ if not path.exists():
211
+ result["out_of_sync"].append(int(ident)); continue
212
+ for ident, row in rows.items():
213
+ if not _scope(row, cfg):
214
+ prior = known.pop(ident, None)
215
+ if prior and (root / prior["path"]).exists(): (root / prior["path"]).unlink(); result["deleted"] += 1
216
+ continue
217
+ prior = known.get(ident); path = root / prior["path"] if prior else None
218
+ if prior and ident in result["out_of_sync"]: continue
219
+ if not prior or not path or not path.exists():
220
+ try: path = _write_memory(root, row); result["pushed"] += 1
221
+ except ValueError as exc: result["errors"].append(f"#{ident}: {exc}"); continue
222
+ else:
223
+ sha = _blob(path); local_changed = row["updated_at"] != prior.get("db_updated_at"); file_changed = sha != prior.get("blob")
224
+ if file_changed:
225
+ try: meta, content = _parse(path); guard(content, meta.get("title"))
226
+ except (OSError, ValueError) as exc: result["errors"].append(f"{prior['path']}: {exc}"); continue
227
+ remote_newer = not local_changed or meta.get("updated", "") > row["updated_at"].replace("+00:00", "Z")
228
+ if remote_newer:
229
+ update_memory(conn, int(ident), content=content, title=meta.get("title", row["title"]), kind=meta["kind"], tags=", ".join(meta["tags"]), review_after=meta.get("review_after") or "")
230
+ row = dict(conn.execute("SELECT * FROM memories WHERE id=?", (ident,)).fetchone()); rows[ident] = row; result["pulled"] += 1
231
+ else:
232
+ if local_changed: # both sides edited: the losing remote version goes into history
233
+ conn.execute("INSERT INTO memory_revisions(memory_id,project,kind,title,content,tags,pinned,created_at,op,revised_at) VALUES (?,?,?,?,?,?,?,?,'update',?)",
234
+ (int(ident), row["project"], meta["kind"], meta.get("title") or row["title"], content, ", ".join(meta["tags"]), row["pinned"], row["created_at"], now()))
235
+ try: _write_memory(root, row); result["pushed"] += 1
236
+ except ValueError as exc: result["errors"].append(f"#{ident}: {exc}"); continue
237
+ elif local_changed:
238
+ try: _write_memory(root, row); result["pushed"] += 1
239
+ except ValueError as exc: result["errors"].append(f"#{ident}: {exc}"); continue
240
+ known[ident] = {"path": str(path.relative_to(root)), "blob": _blob(path), "db_updated_at": row["updated_at"]}
241
+ for inbox in (root / "inbox").glob("*.md"):
242
+ try:
243
+ meta, content = _parse(inbox); guard(content, meta.get("title")); project = meta["project"]
244
+ tags = meta["tags"] or ([cfg.get("global_tag", "personal")] if project == "global" else [])
245
+ memory_id = save_memory(conn, content=content, title=meta.get("title") or content[:80], kind=meta["kind"], tags=", ".join(tags), project=None if project == "global" else project, review_after=meta.get("review_after") or "", source_agent="git-sync")
246
+ row = dict(conn.execute("SELECT * FROM memories WHERE id=?", (memory_id,)).fetchone()); path = _write_memory(root, row); inbox.unlink(); known[str(memory_id)] = {"path": str(path.relative_to(root)), "blob": _blob(path), "db_updated_at": row["updated_at"]}; result["pulled"] += 1
247
+ if not _scope(row, cfg): result["errors"].append(f"{inbox.name}: imported outside allowlist")
248
+ except (OSError, ValueError) as exc: result["errors"].append(f"{inbox.name}: {exc}")
249
+ if not dry_run: _commit(cfg, root, result, False)
250
+ state.update({"memories": known, "out_of_sync": result["out_of_sync"], "last_sync": now(), "last_error": "; ".join(result["errors"]) or None, "pushed": result["pushed"], "pulled": result["pulled"]})
251
+ if not dry_run: _atomic(state_path(), state)
252
+ except Exception as exc:
253
+ result["errors"].append(str(exc)); state["last_error"] = str(exc); state["last_sync"] = now()
254
+ if not dry_run: _atomic(state_path(), state)
255
+ finally: release(lock)
256
+ return result
257
+
258
+
259
+ def status() -> dict:
260
+ state = _state(); cfg = config()
261
+ return {"enabled": bool(cfg.get("enabled")), "repo": cfg.get("repo"), "last_sync": state.get("last_sync"), "pushed": state.get("pushed", 0), "pulled": state.get("pulled", 0), "error": state.get("last_error"), "out_of_sync": state.get("out_of_sync", [])}
devmem/guard.py ADDED
@@ -0,0 +1,214 @@
1
+ """Fast, stdlib-only rule guard for Claude PreToolUse hooks."""
2
+ from __future__ import annotations
3
+
4
+ import fnmatch
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import sqlite3
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from .hook_log import guard_log
14
+ from .projects import project_for_cwd
15
+
16
+ _FIELDS = {"tool", "command", "path", "action"}
17
+ _ASSIGNMENT = re.compile(r"""^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+""")
18
+ _SEGMENT = re.compile(r"&&|\|\||;|\||&|\n|\$\(|[()`{}]")
19
+ _TOKEN_SPLIT = re.compile(r"[\s<>;|&()`]+")
20
+ _TOOL = re.compile(r"\*|[A-Za-z][A-Za-z0-9_-]*")
21
+ _NEAR_GUARD = re.compile(r"^\W*guard\s*:", re.I)
22
+ _KNOWN_TOOLS = {"Bash", "Read", "Edit", "Write", "NotebookEdit", "Glob", "Grep", "WebFetch", "WebSearch", "Agent", "Task", "TodoWrite"}
23
+
24
+
25
+ def parse_rules(content: str) -> tuple[list[dict], list[dict], list[dict]]:
26
+ """Return valid GUARD rules and invalid-line reports from one memory."""
27
+ rules, invalid, notices = [], [], []
28
+ for line in content.splitlines():
29
+ if not line.startswith("GUARD:"):
30
+ if _NEAR_GUARD.match(line): invalid.append({"line": line, "reason": "GUARD: must start the line, in capitals"})
31
+ continue
32
+ text = line.removeprefix("GUARD:").strip()
33
+ try:
34
+ rule = json.loads(text)
35
+ except json.JSONDecodeError:
36
+ invalid.append({"line": line, "reason": "invalid JSON"}); continue
37
+ if not isinstance(rule, dict):
38
+ invalid.append({"line": line, "reason": "must be a JSON object"}); continue
39
+ unknown = set(rule) - _FIELDS
40
+ if unknown:
41
+ invalid.append({"line": line, "reason": "unknown field " + sorted(unknown)[0]}); continue
42
+ if not isinstance(rule.get("tool"), str) or not _TOOL.fullmatch(rule["tool"]):
43
+ invalid.append({"line": line, "reason": "invalid tool name"}); continue
44
+ if rule.get("action") not in ("deny", "ask"):
45
+ invalid.append({"line": line, "reason": "action must be deny or ask"}); continue
46
+ if any(key in rule and not isinstance(rule[key], str) for key in ("command", "path")):
47
+ invalid.append({"line": line, "reason": "command and path must be strings"}); continue
48
+ if any(key in rule and not rule[key] for key in ("command", "path")):
49
+ invalid.append({"line": line, "reason": "command and path must be non-empty"}); continue
50
+ if "command" in rule and rule["tool"] != "Bash":
51
+ invalid.append({"line": line, "reason": "command is only valid for Bash"}); continue
52
+ if rule["tool"] not in _KNOWN_TOOLS and not rule["tool"].startswith("mcp__") and rule["tool"] != "*":
53
+ notices.append({"line": line, "reason": f"tool {rule['tool']} is not a known Claude tool (matching is case-sensitive)"})
54
+ known = next((name for name in _KNOWN_TOOLS if name.lower() == rule["tool"].lower()), None)
55
+ if known: notices.append({"line": line, "reason": f"did you mean {known}?"})
56
+ rules.append(rule)
57
+ return rules, invalid, notices
58
+
59
+
60
+ def parse_text_rules(content: str) -> tuple[list[str], list[dict]]:
61
+ """Return plain-language RULE lines and malformed-line reports from one memory."""
62
+ rules, invalid = [], []
63
+ for line in content.splitlines():
64
+ if not line.startswith("RULE:"):
65
+ continue
66
+ if not line.startswith("RULE: ") or not line.removeprefix("RULE: ").strip():
67
+ invalid.append({"line": line, "reason": "RULE: must be followed by a sentence"})
68
+ continue
69
+ rules.append(line.removeprefix("RULE: ").strip())
70
+ return rules, invalid
71
+
72
+
73
+ def _rows(conn: sqlite3.Connection, cwd: str | None, all_scopes: bool = False) -> list[sqlite3.Row]:
74
+ project = project_for_cwd(cwd)
75
+ scope, params = "1=1", ()
76
+ if not all_scopes: scope, params = "(m.project IS NULL OR m.project=?)", (project,)
77
+ return conn.execute("""SELECT m.id,m.title,m.content,m.project FROM memories m
78
+ WHERE """ + scope + """ AND NOT EXISTS
79
+ (SELECT 1 FROM memory_links l WHERE l.to_id=m.id AND l.type='supersedes')
80
+ AND (instr(m.content,'GUARD:') > 0 OR instr(m.content,'RULE:') > 0) ORDER BY m.id""", params).fetchall()
81
+
82
+
83
+ def rules_data(conn: sqlite3.Connection, cwd: str | None = None, *, all_scopes: bool = False) -> dict:
84
+ """Return GUARD and plain-text rules from the applicable non-superseded memories."""
85
+ rules, text_rules, invalid, notices = [], [], [], []
86
+ for row in _rows(conn, cwd, all_scopes):
87
+ parsed, bad, note = parse_rules(row["content"])
88
+ text, text_bad = parse_text_rules(row["content"])
89
+ rules.extend({**rule, "id": row["id"], "title": row["title"]} for rule in parsed)
90
+ text_rules.extend({"text": item, "id": row["id"], "title": row["title"]} for item in text)
91
+ invalid.extend({**item, "id": row["id"], "title": row["title"]} for item in bad)
92
+ invalid.extend({**item, "id": row["id"], "title": row["title"]} for item in text_bad)
93
+ notices.extend({**item, "id": row["id"], "title": row["title"]} for item in note)
94
+ return {"rules": rules, "text_rules": text_rules, "invalid": invalid, "notices": notices, "project": project_for_cwd(cwd) if cwd else None}
95
+
96
+
97
+ def warnings(content: str) -> list[str]:
98
+ """Human-readable warnings for malformed GUARD and RULE lines without rejecting a memory."""
99
+ parsed = parse_rules(content)
100
+ _, text_invalid = parse_text_rules(content)
101
+ return [f"{item['line']}: {item['reason']}" for item in parsed[1] + parsed[2] + text_invalid]
102
+
103
+
104
+ def _glob_variants(pattern: str) -> list[str]:
105
+ """Each `**/` may also match zero directories: try every combination of dropping them (only all-or-none past six, to bound the blowup)."""
106
+ parts = pattern.split("**/")
107
+ if len(parts) > 7: return [pattern, pattern.replace("**/", "")]
108
+ variants = [parts[0]]
109
+ for part in parts[1:]: variants = [v + glue + part for v in variants for glue in ("**/", "")]
110
+ return variants
111
+
112
+
113
+ def _glob(pattern: str, value: str) -> bool:
114
+ return any(fnmatch.fnmatchcase(value, variant) for variant in _glob_variants(pattern))
115
+
116
+
117
+ def _paths(value: str, cwd: str | None) -> list[str]:
118
+ base = cwd or os.getcwd()
119
+ absolute = os.path.abspath(os.path.join(base, os.path.expandvars(os.path.expanduser(value))))
120
+ return [value, absolute]
121
+
122
+
123
+ def _path_matches(pattern: str, value: str, cwd: str | None) -> bool:
124
+ paths = _paths(value, cwd)
125
+ if not pattern.startswith(("/", "~", "**/")):
126
+ for directory in (project_for_cwd(cwd), cwd):
127
+ if directory:
128
+ try:
129
+ if os.path.commonpath((paths[1], os.path.abspath(directory))) == os.path.abspath(directory):
130
+ paths.append(os.path.relpath(paths[1], directory))
131
+ except ValueError: pass
132
+ return any(_glob(pattern, path) for path in paths)
133
+
134
+
135
+ def _bash_commands(command: str) -> list[str]:
136
+ pieces = [command, *(part.strip() for part in _SEGMENT.split(command) if part.strip())]
137
+ result = []
138
+ for piece in pieces:
139
+ value = _ASSIGNMENT.sub("", piece).strip()
140
+ while True:
141
+ prior = value
142
+ value = re.sub(r"^(?:then|do|else|elif|!|time|nohup|command|exec)\s+", "", value)
143
+ value = re.sub(r"^sudo(?:(?:\s+--\S+)|(?:\s+-(?:u|g|h|p|C|T)\s+\S+)|(?:\s+-\S+))*\s+", "", value)
144
+ value = re.sub(r"^nice(?:\s+-n\s+\S+)?\s+", "", value)
145
+ value = re.sub(r"^timeout(?:\s+-\S+)*(?:\s+\S+)?\s+", "", value)
146
+ value = re.sub(r"^env(?:\s+-\S+|\s+[A-Za-z_][A-Za-z0-9_]*=\S+)*\s+", "", value)
147
+ if value == prior: break
148
+ result.extend((piece, value, re.sub(r"\s+", " ", value).strip()))
149
+ return result
150
+
151
+
152
+ def _tokens(command: str) -> list[str]:
153
+ """Raw tokens plus shell-unquoted ones, so `cat '.env'`, `cat<.env` and `--file=.env` are seen too."""
154
+ raw = _TOKEN_SPLIT.split(command)
155
+ try: unquoted = shlex.split(_TOKEN_SPLIT.sub(" ", command))
156
+ except ValueError: unquoted = []
157
+ tokens = [token.strip("'\"") for token in raw + unquoted if token.strip("'\"")]
158
+ return tokens + [token.rsplit("=", 1)[1] for token in tokens if "=" in token and not token.endswith("=")]
159
+
160
+
161
+ def matches(rule: dict, tool_name: str, tool_input: dict, cwd: str | None) -> bool:
162
+ if rule["tool"] not in ("*", tool_name):
163
+ return False
164
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
165
+ if "command" in rule:
166
+ if tool_name != "Bash" or not isinstance(command, str) or not any(_glob(rule["command"], item) for item in _bash_commands(command)):
167
+ return False
168
+ path = next((tool_input[key] for key in ("file_path", "notebook_path", "path") if isinstance(tool_input.get(key), str)), None) if isinstance(tool_input, dict) else None
169
+ if "path" in rule:
170
+ path_match = isinstance(path, str) and _path_matches(rule["path"], path, cwd)
171
+ token_match = tool_name == "Bash" and isinstance(command, str) and any(_path_matches(rule["path"], token, cwd) for token in _tokens(command))
172
+ if not path_match and not token_match:
173
+ return False
174
+ return True
175
+
176
+
177
+ def evaluate(data: dict, payload: dict) -> tuple[str, list[dict]]:
178
+ """Return deny, ask, or none and all rules at the winning level."""
179
+ tool_name, tool_input, cwd = payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")
180
+ if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
181
+ return "none", []
182
+ matched = []
183
+ for rule in data["rules"]:
184
+ try:
185
+ if matches(rule, tool_name, tool_input, cwd if isinstance(cwd, str) else None): matched.append(rule)
186
+ except Exception as exc:
187
+ guard_log(f"guard rule #{rule.get('id', '?')} failed: {type(exc).__name__}: {exc}", db_path=os.environ.get("DEVMEM_DB", "~/.devmem/devmem.db"), tool_name=tool_name, cwd=cwd if isinstance(cwd, str) else None)
188
+ action = "deny" if any(rule["action"] == "deny" for rule in matched) else "ask" if matched else "none"
189
+ return action, [rule for rule in matched if rule["action"] == action]
190
+
191
+
192
+ def readonly_rules(cwd: str | None = None) -> dict:
193
+ path = Path(os.environ.get("DEVMEM_DB", "~/.devmem/devmem.db")).expanduser().resolve()
194
+ uri = path.as_uri() + "?mode=ro"
195
+ with sqlite3.connect(uri, uri=True, timeout=0.5) as conn:
196
+ conn.row_factory = sqlite3.Row
197
+ return rules_data(conn, cwd)
198
+
199
+
200
+ def hook() -> None:
201
+ """Run quietly and fail open for Claude's PreToolUse hook; errors go to the guard log."""
202
+ try:
203
+ payload = json.load(sys.stdin)
204
+ if not isinstance(payload, dict):
205
+ raise ValueError("hook input is not an object")
206
+ action, matched = evaluate(readonly_rules(payload.get("cwd")), payload)
207
+ if action == "none":
208
+ return
209
+ reason = "; ".join(f"#{rule['id']}: {rule['title']}" for rule in matched)
210
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": action,
211
+ "permissionDecisionReason": "devmem rule " + reason}}, separators=(",", ":")))
212
+ except Exception as exc:
213
+ payload = locals().get("payload", {})
214
+ guard_log(f"guard hook failed: {type(exc).__name__}: {exc}", db_path=os.environ.get("DEVMEM_DB", "~/.devmem/devmem.db"), tool_name=str(payload.get("tool_name", "")) if isinstance(payload, dict) else "", cwd=payload.get("cwd") if isinstance(payload, dict) and isinstance(payload.get("cwd"), str) else None)
devmem/hook_log.py ADDED
@@ -0,0 +1,49 @@
1
+ """Small hook-safe maintenance logger."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+
10
+ def _maintain_log() -> Path:
11
+ return Path(os.environ.get("DEVMEM_MAINTAIN_LOG", "~/.devmem/maintain.log")).expanduser()
12
+
13
+
14
+ def _open_maintain_log():
15
+ """Append handle for detached maintenance output; rotated at 1 MB so it cannot grow without bound."""
16
+ path = _maintain_log(); path.parent.mkdir(parents=True, exist_ok=True)
17
+ if path.is_file() and path.stat().st_size > 1_000_000: path.replace(path.with_name(path.name + ".1"))
18
+ return open(path, "ab")
19
+
20
+
21
+ def _log(message: str) -> None:
22
+ try:
23
+ with _open_maintain_log() as log: log.write(f"{datetime.now(UTC).isoformat()} {message}\n".encode())
24
+ except OSError:
25
+ pass
26
+
27
+
28
+ def _guard_log() -> Path:
29
+ return Path(os.environ.get("DEVMEM_GUARD_LOG", "~/.devmem/guard.log")).expanduser()
30
+
31
+
32
+ def guard_log(message: str, *, db_path: str, tool_name: str, cwd: str | None) -> None:
33
+ """Append a rate-limited guard failure without affecting the hook."""
34
+ try:
35
+ path = _guard_log(); state_path = path.with_name(path.name + ".state.json")
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ try: state = json.loads(state_path.read_text(encoding="utf-8"))
38
+ except Exception: state = {}
39
+ stamp = datetime.now(UTC).timestamp()
40
+ if stamp - float(state.get(message, 0)) < 600: return
41
+ state[message] = stamp
42
+ state_path.write_text(json.dumps(state), encoding="utf-8")
43
+ try:
44
+ if path.is_file() and path.stat().st_size > 1_000_000: path.replace(path.with_name(path.name + ".1"))
45
+ except FileNotFoundError: pass
46
+ with open(path, "ab") as log:
47
+ log.write(f"{datetime.now(UTC).isoformat()} db={db_path} tool={tool_name} cwd={cwd or ''} {message}\n".encode(errors="replace"))
48
+ except Exception:
49
+ pass
@@ -0,0 +1 @@
1
+ """Parsers and incremental log ingestion."""
@@ -0,0 +1,87 @@
1
+ """Claude Code JSONL parsing (pure functions deliberately accept synthetic logs)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Iterator
7
+
8
+ SUMMARY_MAX = 120
9
+ # Only short, model-written hints are kept. Never commands, file contents, prompts or edit strings.
10
+ SUMMARY_KEYS = ("description", "file_path", "path", "pattern")
11
+
12
+
13
+ def safe_summary(tool_input: Any) -> str:
14
+ if not isinstance(tool_input, dict):
15
+ return ""
16
+ for key in SUMMARY_KEYS:
17
+ value = tool_input.get(key)
18
+ if isinstance(value, str) and value.strip():
19
+ text = " ".join(value.split())
20
+ return text if len(text) <= SUMMARY_MAX else text[: SUMMARY_MAX - 1] + "…"
21
+ return ""
22
+
23
+
24
+ def _str(value) -> str | None:
25
+ """Logs are not trusted to be well-typed: a dict where a string belongs would make SQLite reject the whole file."""
26
+ return value if isinstance(value, str) and value else None
27
+
28
+
29
+ def _usage(obj: dict[str, Any]) -> dict[str, Any] | None:
30
+ message = obj.get("message") or {}
31
+ if not isinstance(message, dict):
32
+ return None
33
+ usage = message.get("usage")
34
+ if not isinstance(usage, dict):
35
+ return None
36
+ details = usage.get("output_tokens_details") or {}
37
+ return {
38
+ "agent": "claude",
39
+ "session_id": _str(obj.get("sessionId")), "project": _str(obj.get("cwd")),
40
+ "model": _str(message.get("model")) or "claude", "ts": _str(obj.get("timestamp")),
41
+ "input_tokens": int(usage.get("input_tokens", 0) or 0),
42
+ "output_tokens": int(usage.get("output_tokens", 0) or 0),
43
+ "cache_read_tokens": int(usage.get("cache_read_input_tokens", 0) or 0),
44
+ "cache_write_tokens": int(usage.get("cache_creation_input_tokens", 0) or 0),
45
+ "reasoning_tokens": int(details.get("thinking_tokens", 0) or 0),
46
+ "dedup_key": (obj.get("requestId"), message.get("id")),
47
+ }
48
+
49
+
50
+ def _activities(obj: dict[str, Any]) -> list[dict[str, Any]]:
51
+ # Only real assistant turns: other record types (e.g. request replays) repeat old messages without timestamps.
52
+ if obj.get("type") != "assistant":
53
+ return []
54
+ message = obj.get("message") or {}
55
+ content = message.get("content") if isinstance(message, dict) else None
56
+ if not isinstance(content, list):
57
+ return []
58
+ agent = "claude-subagent" if obj.get("isSidechain") is True else "claude"
59
+ events = []
60
+ for item in content:
61
+ if isinstance(item, dict) and item.get("type") == "tool_use":
62
+ events.append({
63
+ "agent": agent, "session_id": _str(obj.get("sessionId")), "project": _str(obj.get("cwd")),
64
+ "ts": _str(obj.get("timestamp")), "kind": "tool", "name": str(item.get("name") or "tool")[:80],
65
+ "summary": safe_summary(item.get("input")),
66
+ })
67
+ return events
68
+
69
+
70
+ def parse_record(line: str) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
71
+ """Return (usage event or None, tool activity events) for one JSONL line."""
72
+ try:
73
+ obj = json.loads(line)
74
+ except json.JSONDecodeError:
75
+ return None, []
76
+ if not isinstance(obj, dict):
77
+ return None, []
78
+ return _usage(obj), _activities(obj)
79
+
80
+
81
+ def parse_line(line: str) -> dict[str, Any] | None:
82
+ return parse_record(line)[0]
83
+
84
+
85
+ def files(root: Path) -> Iterator[Path]:
86
+ if root.exists():
87
+ yield from root.glob("projects/**/*.jsonl")