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.
- agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
- agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
- agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
- agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
- agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
- devmem/__init__.py +3 -0
- devmem/ado.py +433 -0
- devmem/agent_colors.py +74 -0
- devmem/cli.py +500 -0
- devmem/db.py +1069 -0
- devmem/dispatch.py +478 -0
- devmem/embed.py +111 -0
- devmem/filelock.py +53 -0
- devmem/git_sync.py +261 -0
- devmem/guard.py +214 -0
- devmem/hook_log.py +49 -0
- devmem/ingest/__init__.py +1 -0
- devmem/ingest/claude.py +87 -0
- devmem/ingest/codex.py +175 -0
- devmem/ingest/runner.py +318 -0
- devmem/ingest/subagents.py +250 -0
- devmem/maintenance.py +801 -0
- devmem/mcp_server.py +542 -0
- devmem/messages.py +684 -0
- devmem/panel/__init__.py +1 -0
- devmem/panel/app.py +971 -0
- devmem/panel/state.py +358 -0
- devmem/panel/static/base.css +27 -0
- devmem/panel/static/brain.css +473 -0
- devmem/panel/static/brain.js +1118 -0
- devmem/panel/static/brain3d.js +600 -0
- devmem/panel/static/chat.js +18 -0
- devmem/panel/static/common.js +208 -0
- devmem/panel/static/site.css +508 -0
- devmem/panel/static/tokens.css +19 -0
- devmem/panel/templates/ado.html +62 -0
- devmem/panel/templates/agents.html +35 -0
- devmem/panel/templates/audit.html +19 -0
- devmem/panel/templates/base.html +42 -0
- devmem/panel/templates/brain.html +101 -0
- devmem/panel/templates/maintenance.html +32 -0
- devmem/panel/templates/memory.html +71 -0
- devmem/panel/templates/message_thread.html +49 -0
- devmem/panel/templates/messages.html +8 -0
- devmem/panel/templates/overview.html +109 -0
- devmem/panel/templates/project.html +48 -0
- devmem/panel/templates/projects.html +21 -0
- devmem/panel/templates/session.html +49 -0
- devmem/panel/templates/sessions.html +67 -0
- devmem/panel/templates/subagent.html +27 -0
- devmem/procs.py +10 -0
- devmem/projects.py +17 -0
- devmem/secrets.py +9 -0
- 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."""
|
devmem/ingest/claude.py
ADDED
|
@@ -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")
|