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/cli.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from .hook_log import _log, _maintain_log, _open_maintain_log
|
|
12
|
+
from .procs import executable
|
|
13
|
+
from .projects import project_for_cwd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_NO_CONTEXT = "No saved project context."
|
|
17
|
+
_HOOK_HEADER = "devmem memory (auto-loaded at session start; use memory_search / memory_get for more):"
|
|
18
|
+
_REMINDER_RULES = (
|
|
19
|
+
(r"\bgit\s+(switch\s+-c|checkout\s+-b)\b", "New branch started: if this belongs to a tracked task, use task_status_set when its phase changes."),
|
|
20
|
+
(r"\bgit\s+commit\b", "Commit made: use task_status_set if the tracked task's phase changed."),
|
|
21
|
+
(r"\bgit\s+merge\b", "Merge done: use task_status_set to mark the tracked task's phase."),
|
|
22
|
+
(r"\bcodex\s+exec\b", "Work dispatched to Codex: record 'dispatched' in the task's memory now and update it again when the result comes back."),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _hook_cwd() -> str:
|
|
27
|
+
try:
|
|
28
|
+
payload = json.load(sys.stdin)
|
|
29
|
+
cwd = payload.get("cwd") if isinstance(payload, dict) else None
|
|
30
|
+
return cwd if isinstance(cwd, str) else os.getcwd()
|
|
31
|
+
except Exception:
|
|
32
|
+
return os.getcwd()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _spawn_maintain(*args: str) -> None:
|
|
36
|
+
"""Start a detached maintenance run that never shares the hook's stdin and logs its output and errors."""
|
|
37
|
+
try: log = _open_maintain_log()
|
|
38
|
+
except OSError: log = subprocess.DEVNULL
|
|
39
|
+
try:
|
|
40
|
+
process_args = {"stdin": subprocess.DEVNULL, "stdout": log, "stderr": log}
|
|
41
|
+
if os.name == "nt":
|
|
42
|
+
process_args["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
43
|
+
else:
|
|
44
|
+
process_args["start_new_session"] = True
|
|
45
|
+
subprocess.Popen([sys.executable, "-m", "devmem.cli", "maintain", *args], **process_args)
|
|
46
|
+
finally:
|
|
47
|
+
if log is not subprocess.DEVNULL: log.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _start_maintenance_if_due() -> None:
|
|
51
|
+
try:
|
|
52
|
+
from .maintenance import due_for_startup
|
|
53
|
+
if due_for_startup(): _spawn_maintain("--startup")
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
_log(f"startup maintenance not started: {type(exc).__name__}: {exc}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _maintain_child() -> bool:
|
|
59
|
+
"""True inside a maintenance engine (claude -p) session: its hooks must not start more maintenance."""
|
|
60
|
+
from .maintenance import CHILD_ENV
|
|
61
|
+
return bool(os.environ.get(CHILD_ENV))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _session_end_hook() -> None:
|
|
65
|
+
"""A SessionEnd hook must never delay or fail Claude; problems leave a line in the maintenance log."""
|
|
66
|
+
try:
|
|
67
|
+
if _maintain_child(): return
|
|
68
|
+
from .maintenance import config
|
|
69
|
+
cfg = config()
|
|
70
|
+
if not cfg["enabled"] and not cfg.get("error"): return
|
|
71
|
+
try:
|
|
72
|
+
payload = json.load(sys.stdin)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
payload = {}; _log(f"SessionEnd hook: unreadable hook input ({type(exc).__name__}); using the process cwd")
|
|
75
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
76
|
+
cwd = payload.get("cwd")
|
|
77
|
+
if not isinstance(cwd, str):
|
|
78
|
+
cwd = os.getcwd(); _log("SessionEnd hook: no cwd in hook input; using the process cwd")
|
|
79
|
+
command = ["--session-end", "--project", project_for_cwd(cwd)]
|
|
80
|
+
if isinstance(payload.get("transcript_path"), str): command += ["--transcript", payload["transcript_path"]]
|
|
81
|
+
_spawn_maintain(*command)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
_log(f"SessionEnd hook failed: {type(exc).__name__}: {exc}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _print_proposals(changes: list[dict]) -> None:
|
|
87
|
+
for change in changes:
|
|
88
|
+
target = f" -> #{change['target_id']}" if change.get("target_id") else ""
|
|
89
|
+
state = f"blocked: {change['blocked_by']}" if change.get("blocked_by") else "would apply"
|
|
90
|
+
print(f"#{change['memory_id']}{target} {change['action']} [{state}] {change.get('summary') or ''}")
|
|
91
|
+
if change.get("new_title"): print(f" title: {change['new_title']}")
|
|
92
|
+
if change.get("new_content"): print(" " + str(change["new_content"]).replace("\n", "\n "))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _context_for_cwd(cwd: str) -> str:
|
|
96
|
+
from .db import context_text, db_path, readonly_connection
|
|
97
|
+
if not db_path().is_file():
|
|
98
|
+
return _NO_CONTEXT
|
|
99
|
+
with readonly_connection() as conn:
|
|
100
|
+
return context_text(conn, project_for_cwd(cwd))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _reminder_rules():
|
|
104
|
+
path = os.environ.get("DEVMEM_REMINDERS", "~/.devmem/reminders.json")
|
|
105
|
+
try:
|
|
106
|
+
with open(os.path.expanduser(path), encoding="utf-8") as f:
|
|
107
|
+
data = json.load(f)
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
return _REMINDER_RULES
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
print(f"devmem remind: {exc}", file=sys.stderr)
|
|
112
|
+
return ()
|
|
113
|
+
try:
|
|
114
|
+
if not isinstance(data, dict) or not isinstance(data["rules"], list): raise ValueError("invalid reminders config")
|
|
115
|
+
rules = []
|
|
116
|
+
for rule in data["rules"]:
|
|
117
|
+
if not isinstance(rule, dict) or not isinstance(rule.get("pattern"), str) or not isinstance(rule.get("message"), str):
|
|
118
|
+
print("devmem remind: invalid rule", file=sys.stderr); continue
|
|
119
|
+
rules.append((rule["pattern"], rule["message"]))
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
print(f"devmem remind: {exc}", file=sys.stderr)
|
|
122
|
+
return ()
|
|
123
|
+
return rules
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _remind_hook() -> None:
|
|
127
|
+
try:
|
|
128
|
+
payload = json.load(sys.stdin)
|
|
129
|
+
if not isinstance(payload, dict) or payload.get("tool_name") != "Bash": return
|
|
130
|
+
tool_input = payload.get("tool_input")
|
|
131
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
132
|
+
if not isinstance(command, str): return
|
|
133
|
+
for pattern, message in _reminder_rules():
|
|
134
|
+
try:
|
|
135
|
+
if re.search(pattern, command):
|
|
136
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "devmem reminder: " + message}}, separators=(",", ":")))
|
|
137
|
+
return
|
|
138
|
+
except re.error as exc:
|
|
139
|
+
print(f"devmem remind: invalid regex: {exc}", file=sys.stderr)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
print(f"devmem remind: {exc}", file=sys.stderr)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _room_hook(codex: bool = False) -> None:
|
|
145
|
+
"""Fast, quiet Claude or Codex hook for opt-in #live messages."""
|
|
146
|
+
payload = {}
|
|
147
|
+
try:
|
|
148
|
+
payload = json.load(sys.stdin)
|
|
149
|
+
if not isinstance(payload, dict): return
|
|
150
|
+
session_id, agent_id = payload.get("session_id"), payload.get("agent_id")
|
|
151
|
+
if not isinstance(session_id, str) or not session_id: return
|
|
152
|
+
if agent_id is not None and (not isinstance(agent_id, str) or not agent_id): return
|
|
153
|
+
# Codex hooks identify the same rollout/session id that the panel records,
|
|
154
|
+
# but its room key is deliberately namespaced so it cannot collide with
|
|
155
|
+
# a Claude session id. Claude and Claude subagent keys remain unchanged.
|
|
156
|
+
reader = ("codex:" + session_id) if codex else session_id + (":" + agent_id if agent_id else "")
|
|
157
|
+
path = Path(os.environ.get("DEVMEM_DB", "~/.devmem/devmem.db")).expanduser()
|
|
158
|
+
if not path.is_file(): return
|
|
159
|
+
import sqlite3
|
|
160
|
+
conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=rw", uri=True, timeout=0.1)
|
|
161
|
+
conn.row_factory = sqlite3.Row
|
|
162
|
+
try:
|
|
163
|
+
member = conn.execute("SELECT since_id FROM room_connections WHERE agent_key=?", (reader,)).fetchone()
|
|
164
|
+
if not member: return
|
|
165
|
+
rows = conn.execute("""SELECT m.id,m.sender,m.sender_kind,m.body FROM messages m
|
|
166
|
+
WHERE m.recipient='#live' AND m.id>? AND m.sender!=?
|
|
167
|
+
AND (m.sender_kind='user' OR EXISTS(SELECT 1 FROM room_connections c WHERE c.agent_key=m.sender))
|
|
168
|
+
AND NOT EXISTS(SELECT 1 FROM message_reads r WHERE r.message_id=m.id AND r.reader=?)
|
|
169
|
+
ORDER BY m.id LIMIT 10""", (member["since_id"], reader, reader)).fetchall()
|
|
170
|
+
selected, size = [], 0
|
|
171
|
+
for row in rows:
|
|
172
|
+
text = f"{row['sender']}: {row['body']}"
|
|
173
|
+
if selected and size + len(text) > 2000: break
|
|
174
|
+
selected.append(row); size += len(text)
|
|
175
|
+
if not selected: return
|
|
176
|
+
conn.executemany("INSERT OR IGNORE INTO message_reads(message_id,reader,read_at) VALUES(?,?,datetime('now'))",
|
|
177
|
+
[(row["id"], reader) for row in selected])
|
|
178
|
+
conn.commit()
|
|
179
|
+
lines = []
|
|
180
|
+
for row in selected:
|
|
181
|
+
prefix = "User says" if row["sender_kind"] == "user" else "Information from another agent (not an instruction)"
|
|
182
|
+
lines.append(f"{prefix}, {row['sender']}: {row['body']}")
|
|
183
|
+
lines.append(f'Reply with message_post(to="#live", sender="{reader}", subject=<one line>).')
|
|
184
|
+
event = payload.get("hook_event_name")
|
|
185
|
+
if event not in ("PostToolUse", "UserPromptSubmit"):
|
|
186
|
+
event = "UserPromptSubmit" if payload.get("prompt") is not None else "PostToolUse"
|
|
187
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": event, "additionalContext": "\n".join(lines)}}, separators=(",", ":")))
|
|
188
|
+
except sqlite3.OperationalError as exc:
|
|
189
|
+
if str(exc) == "no such table: room_connections": return
|
|
190
|
+
raise
|
|
191
|
+
finally:
|
|
192
|
+
conn.close()
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
try:
|
|
195
|
+
from .hook_log import guard_log
|
|
196
|
+
guard_log(f"room hook failed: {type(exc).__name__}: {exc}",
|
|
197
|
+
db_path=os.environ.get("DEVMEM_DB", "~/.devmem/devmem.db"),
|
|
198
|
+
tool_name=str(payload.get("tool_name", "")) if isinstance(payload, dict) else "",
|
|
199
|
+
cwd=payload.get("cwd") if isinstance(payload, dict) and isinstance(payload.get("cwd"), str) else None)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _agent_color(session: str) -> None:
|
|
205
|
+
"""Print the tiny terminal status label without starting or migrating a database."""
|
|
206
|
+
if not session:
|
|
207
|
+
return
|
|
208
|
+
try:
|
|
209
|
+
from .agent_colors import PALETTE, color_for
|
|
210
|
+
path = Path(os.environ.get("DEVMEM_DB", "~/.devmem/devmem.db")).expanduser()
|
|
211
|
+
if not path.is_file():
|
|
212
|
+
return
|
|
213
|
+
import sqlite3
|
|
214
|
+
conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=rw", uri=True, timeout=0.1)
|
|
215
|
+
try:
|
|
216
|
+
color = color_for(conn, session)
|
|
217
|
+
except sqlite3.OperationalError as exc:
|
|
218
|
+
if str(exc) == "no such table: agent_colors":
|
|
219
|
+
return
|
|
220
|
+
raise
|
|
221
|
+
finally:
|
|
222
|
+
conn.close()
|
|
223
|
+
colors = dict(PALETTE)
|
|
224
|
+
if color and color["name"] in colors:
|
|
225
|
+
value = colors[color["name"]].lstrip("#")
|
|
226
|
+
red, green, blue = (int(value[index:index + 2], 16) for index in range(0, 6, 2))
|
|
227
|
+
print(f"\033[38;2;{red};{green};{blue}m● {color['name']}\033[0m", end="")
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
if "database is locked" in str(exc) or "database is busy" in str(exc):
|
|
230
|
+
return
|
|
231
|
+
try:
|
|
232
|
+
from .hook_log import guard_log
|
|
233
|
+
guard_log(f"agent-color failed: {type(exc).__name__}: {exc}",
|
|
234
|
+
db_path=os.environ.get("DEVMEM_DB", "~/.devmem/devmem.db"), tool_name="agent-color", cwd=os.getcwd())
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
def main() -> None:
|
|
239
|
+
parser = argparse.ArgumentParser(prog=Path(sys.argv[0]).stem or "brain")
|
|
240
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
241
|
+
sub.add_parser("mcp")
|
|
242
|
+
sub.add_parser("ingest")
|
|
243
|
+
sub.add_parser("embed")
|
|
244
|
+
sub.add_parser("audit")
|
|
245
|
+
panel = sub.add_parser("panel"); panel.add_argument("--port", type=int, default=8765)
|
|
246
|
+
search = sub.add_parser("search"); search.add_argument("q"); search.add_argument("--limit", type=int, default=8)
|
|
247
|
+
context = sub.add_parser("context")
|
|
248
|
+
context.add_argument("--cwd")
|
|
249
|
+
context.add_argument("--hook", action="store_true")
|
|
250
|
+
remind = sub.add_parser("remind")
|
|
251
|
+
remind.add_argument("--hook", action="store_true")
|
|
252
|
+
room = sub.add_parser("room")
|
|
253
|
+
room.add_argument("--hook", action="store_true")
|
|
254
|
+
room.add_argument("--codex", action="store_true")
|
|
255
|
+
agent_color = sub.add_parser("agent-color")
|
|
256
|
+
agent_color.add_argument("--session", default="")
|
|
257
|
+
skills = sub.add_parser("skills")
|
|
258
|
+
skills_sub = skills.add_subparsers(dest="skills_command")
|
|
259
|
+
skills_install = skills_sub.add_parser("install")
|
|
260
|
+
skills_install.add_argument("--target")
|
|
261
|
+
skills_install.add_argument("--dry-run", action="store_true")
|
|
262
|
+
for name in ("guard", "rules"):
|
|
263
|
+
guard = sub.add_parser(name)
|
|
264
|
+
guard.add_argument("--hook", action="store_true")
|
|
265
|
+
guard.add_argument("--cwd")
|
|
266
|
+
guard.add_argument("--codex", action="store_true")
|
|
267
|
+
guard.add_argument("--check")
|
|
268
|
+
board = sub.add_parser("board")
|
|
269
|
+
board_sub = board.add_subparsers(dest="board_command", required=True)
|
|
270
|
+
board_show = board_sub.add_parser("show"); board_show.add_argument("board_id", type=int)
|
|
271
|
+
board_set = board_sub.add_parser("set")
|
|
272
|
+
board_set.add_argument("board_id", type=int); board_set.add_argument("task")
|
|
273
|
+
board_set.add_argument("--phase", required=True); board_set.add_argument("--status")
|
|
274
|
+
board_set.add_argument("--entry-id", type=int); board_set.add_argument("--dry-run", action="store_true")
|
|
275
|
+
maintain = sub.add_parser("maintain")
|
|
276
|
+
maintain.add_argument("--project")
|
|
277
|
+
maintain.add_argument("--dry-run", action="store_true")
|
|
278
|
+
maintain.add_argument("--limit", type=int)
|
|
279
|
+
maintain.add_argument("--session-end", action="store_true")
|
|
280
|
+
maintain.add_argument("--startup", action="store_true")
|
|
281
|
+
maintain.add_argument("--hook", action="store_true")
|
|
282
|
+
maintain.add_argument("--transcript")
|
|
283
|
+
cleanup = sub.add_parser("maintenance-cleanup")
|
|
284
|
+
cleanup.add_argument("--dry-run", action="store_true")
|
|
285
|
+
git = sub.add_parser("git")
|
|
286
|
+
git_sub = git.add_subparsers(dest="git_command", required=True)
|
|
287
|
+
setup = git_sub.add_parser("setup")
|
|
288
|
+
setup.add_argument("--repo", required=True); setup.add_argument("--name", required=True); setup.add_argument("--email", required=True)
|
|
289
|
+
git_sub.add_parser("sync").add_argument("--dry-run", action="store_true")
|
|
290
|
+
git_sub.add_parser("status")
|
|
291
|
+
for name in ("allow", "deny"):
|
|
292
|
+
command = git_sub.add_parser(name); command.add_argument("project")
|
|
293
|
+
args = parser.parse_args()
|
|
294
|
+
if args.command == "skills":
|
|
295
|
+
from .skills import install, list_skills
|
|
296
|
+
if args.skills_command == "install":
|
|
297
|
+
target = Path(args.target).expanduser() if args.target else Path("~/.claude/skills").expanduser()
|
|
298
|
+
if not install(target, args.dry_run):
|
|
299
|
+
raise SystemExit(1)
|
|
300
|
+
else:
|
|
301
|
+
data = list_skills()
|
|
302
|
+
for skill in data["skills"]:
|
|
303
|
+
briefing = "[briefing]" if skill["briefing"] else ""
|
|
304
|
+
print(f"{skill['name']} {briefing} {skill['description']}")
|
|
305
|
+
for item in data["invalid"]:
|
|
306
|
+
print(f"invalid {item['path']}: {item['reason']}")
|
|
307
|
+
return
|
|
308
|
+
if args.command in ("guard", "rules"):
|
|
309
|
+
from . import guard as rules_guard
|
|
310
|
+
if args.hook:
|
|
311
|
+
rules_guard.hook(); return
|
|
312
|
+
try:
|
|
313
|
+
if args.check:
|
|
314
|
+
payload = json.loads(args.check)
|
|
315
|
+
if not isinstance(payload, dict): raise ValueError("--check must be a JSON object")
|
|
316
|
+
scope = payload.get("cwd") if isinstance(payload.get("cwd"), str) else args.cwd or os.getcwd()
|
|
317
|
+
action, matched = rules_guard.evaluate(rules_guard.readonly_rules(scope), payload)
|
|
318
|
+
print(action + (" " + ",".join(str(rule["id"]) for rule in matched) if matched else ""))
|
|
319
|
+
return
|
|
320
|
+
data = rules_guard.readonly_rules(args.cwd or os.getcwd())
|
|
321
|
+
if args.codex:
|
|
322
|
+
for rule in data["text_rules"]:
|
|
323
|
+
print(f"- {rule['text']} (devmem #{rule['id']}: {rule['title']})")
|
|
324
|
+
for rule in data["rules"]:
|
|
325
|
+
deny = rule["action"] == "deny"
|
|
326
|
+
tool = "any tool" if rule["tool"] == "*" else rule["tool"]
|
|
327
|
+
if "command" in rule: text = f"{'run' if deny else 'running'} {tool} commands matching `{rule['command']}`"
|
|
328
|
+
elif "path" in rule: text = f"{'use' if deny else 'using'} {tool} on paths matching `{rule['path']}`"
|
|
329
|
+
else: text = f"{'use' if deny else 'using'} {tool}"
|
|
330
|
+
print(f"- {'NEVER' if deny else 'ASK the user before'} {text} (devmem #{rule['id']}: {rule['title']})")
|
|
331
|
+
from .skills import briefing_text
|
|
332
|
+
guidelines = briefing_text()
|
|
333
|
+
if guidelines:
|
|
334
|
+
print(guidelines)
|
|
335
|
+
else:
|
|
336
|
+
for rule in data["text_rules"]:
|
|
337
|
+
print(f"#{rule['id']} RULE: {rule['text']} — {rule['title']}")
|
|
338
|
+
for rule in data["rules"]:
|
|
339
|
+
target = rule.get("command") or rule.get("path") or ""
|
|
340
|
+
print(f"#{rule['id']} {rule['tool']} {rule['action']} {target} — {rule['title']}")
|
|
341
|
+
for item in data["invalid"]: print(f"#{item['id']} invalid GUARD: {item['reason']}")
|
|
342
|
+
for item in data["notices"]: print(f"#{item['id']} GUARD notice: {item['reason']}")
|
|
343
|
+
print("scope: global + " + data["project"] if data["project"] else "scope: global only")
|
|
344
|
+
if args.codex:
|
|
345
|
+
for item in data["invalid"]: print(f"#{item['id']} invalid GUARD: {item['reason']}", file=sys.stderr)
|
|
346
|
+
for item in data["notices"]: print(f"#{item['id']} GUARD notice: {item['reason']}", file=sys.stderr)
|
|
347
|
+
if data["invalid"]: raise SystemExit(2)
|
|
348
|
+
except Exception as exc:
|
|
349
|
+
print(f"devmem rules: {exc}", file=sys.stderr)
|
|
350
|
+
raise SystemExit(1)
|
|
351
|
+
elif args.command == "mcp":
|
|
352
|
+
from .mcp_server import run; run()
|
|
353
|
+
elif args.command == "ingest":
|
|
354
|
+
from .ingest.runner import ingest_all; print(f"Ingested {ingest_all()} new usage events.")
|
|
355
|
+
elif args.command == "embed":
|
|
356
|
+
from .db import connection, ensure_embeddings
|
|
357
|
+
from .embed import get_embedder
|
|
358
|
+
total = 0
|
|
359
|
+
with connection() as conn:
|
|
360
|
+
while True:
|
|
361
|
+
result = ensure_embeddings(conn)
|
|
362
|
+
total += result["embedded"]
|
|
363
|
+
if not result["embedded"]:
|
|
364
|
+
print(f"Embedded {total} memories; {result['pending']} pending.")
|
|
365
|
+
if result["pending"]:
|
|
366
|
+
status = getattr(get_embedder(), "status", lambda: {})()
|
|
367
|
+
print(f"devmem embed: {status.get('last_error') or 'embedder unavailable'}", file=sys.stderr)
|
|
368
|
+
raise SystemExit(1)
|
|
369
|
+
break
|
|
370
|
+
elif args.command == "audit":
|
|
371
|
+
from .db import audit_memories, connection
|
|
372
|
+
with connection() as conn:
|
|
373
|
+
rows = audit_memories(conn)
|
|
374
|
+
if not rows:
|
|
375
|
+
print("No audit findings.")
|
|
376
|
+
for row in rows:
|
|
377
|
+
print(f"#{row['id']}\t{row['title']}\t{row['size']} chars\t{row['updated_at']}\t{row['reason']}")
|
|
378
|
+
elif args.command == "panel":
|
|
379
|
+
_start_maintenance_if_due()
|
|
380
|
+
import uvicorn
|
|
381
|
+
from .panel.app import _shutdown_event, app
|
|
382
|
+
|
|
383
|
+
class PanelServer(uvicorn.Server):
|
|
384
|
+
def handle_exit(self, sig, frame):
|
|
385
|
+
_shutdown_event.set()
|
|
386
|
+
super().handle_exit(sig, frame)
|
|
387
|
+
|
|
388
|
+
_shutdown_event.clear()
|
|
389
|
+
config = uvicorn.Config(app, host="127.0.0.1", port=args.port, timeout_graceful_shutdown=2)
|
|
390
|
+
PanelServer(config).run()
|
|
391
|
+
elif args.command == "context":
|
|
392
|
+
try:
|
|
393
|
+
from .db import db_path
|
|
394
|
+
if args.hook and _maintain_child():
|
|
395
|
+
return
|
|
396
|
+
hook_err = os.path.expanduser(os.environ.get("DEVMEM_GUARD_HOOK_ERR", "~/.devmem/guard-hook.err"))
|
|
397
|
+
try: startup_error = args.hook and os.path.getsize(hook_err) > 0
|
|
398
|
+
except OSError: startup_error = False
|
|
399
|
+
if not db_path().is_file():
|
|
400
|
+
if startup_error: print(json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": f"{_HOOK_HEADER}\ndevmem guard: startup errors logged in {hook_err} (rules may not be enforced)"}}))
|
|
401
|
+
return
|
|
402
|
+
briefing = _context_for_cwd(_hook_cwd() if args.hook else args.cwd or os.getcwd())
|
|
403
|
+
if args.hook and briefing == _NO_CONTEXT and not startup_error:
|
|
404
|
+
return
|
|
405
|
+
if args.hook:
|
|
406
|
+
if startup_error: briefing += f"\ndevmem guard: startup errors logged in {hook_err} (rules may not be enforced)"
|
|
407
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart",
|
|
408
|
+
"additionalContext": f"{_HOOK_HEADER}\n{briefing}"}}))
|
|
409
|
+
# Deliberately after printing: a SessionStart briefing is never delayed or changed by maintenance.
|
|
410
|
+
sys.stdout.flush()
|
|
411
|
+
_start_maintenance_if_due()
|
|
412
|
+
else:
|
|
413
|
+
print(briefing)
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
print(f"devmem context: {exc}", file=sys.stderr)
|
|
416
|
+
elif args.command == "remind":
|
|
417
|
+
if args.hook: _remind_hook()
|
|
418
|
+
elif args.command == "room":
|
|
419
|
+
if args.hook: _room_hook(codex=args.codex)
|
|
420
|
+
elif args.command == "agent-color":
|
|
421
|
+
_agent_color(args.session)
|
|
422
|
+
elif args.command == "board":
|
|
423
|
+
from .db import connection, task_board, task_status_set
|
|
424
|
+
with connection() as conn:
|
|
425
|
+
if args.board_command == "show":
|
|
426
|
+
result = task_board(conn, args.board_id)
|
|
427
|
+
else:
|
|
428
|
+
result = task_status_set(conn, args.board_id, args.task, args.phase, status=args.status,
|
|
429
|
+
entry_id=args.entry_id, dry_run=args.dry_run)
|
|
430
|
+
if result.get("ok") is False:
|
|
431
|
+
print(f"devmem board: {result['error']}", file=sys.stderr)
|
|
432
|
+
raise SystemExit(1)
|
|
433
|
+
if args.board_command == "show":
|
|
434
|
+
for entry in result["entries"]:
|
|
435
|
+
print(f"{entry['section']}\t{entry['n']}\t{entry['task']}\t{entry['title']}\t{entry['phase']}")
|
|
436
|
+
if result["status_as_of"]: print(f"Status as of {result['status_as_of']}.")
|
|
437
|
+
else:
|
|
438
|
+
print(result["board"]["old_line"])
|
|
439
|
+
print(result["board"]["new_line"])
|
|
440
|
+
if result["entry"]:
|
|
441
|
+
print(result["entry"]["old_status"] or "")
|
|
442
|
+
print(result["entry"]["new_status"])
|
|
443
|
+
elif args.command == "maintain":
|
|
444
|
+
if args.hook:
|
|
445
|
+
if not args.session_end: parser.error("maintain --hook is only for the SessionEnd hook (--session-end --hook)")
|
|
446
|
+
_session_end_hook(); return
|
|
447
|
+
if args.limit is not None and args.limit < 1: parser.error("--limit must be at least 1")
|
|
448
|
+
from .maintenance import run
|
|
449
|
+
# SIGTERM becomes SystemExit, so the run row is marked as interrupted instead of staying 'running'.
|
|
450
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
|
|
451
|
+
# Memories store the project's root (project_for_cwd), so a subdirectory must be normalised the same way.
|
|
452
|
+
project = project_for_cwd(args.project) if args.project else project_for_cwd(os.getcwd()) if args.session_end else None
|
|
453
|
+
trigger = "session_end" if args.session_end else "startup" if args.startup else "manual"
|
|
454
|
+
try:
|
|
455
|
+
result = run(trigger, project=project, dry_run=args.dry_run, limit=args.limit,
|
|
456
|
+
transcript_path=args.transcript if args.session_end else None)
|
|
457
|
+
except BaseException as exc:
|
|
458
|
+
from .db import now
|
|
459
|
+
print(f"{now()} devmem maintain failed: {type(exc).__name__}: {exc}"[:500], file=sys.stderr)
|
|
460
|
+
raise SystemExit(1)
|
|
461
|
+
_print_proposals(result.pop("changes", []))
|
|
462
|
+
print(json.dumps(result, separators=(",", ":")))
|
|
463
|
+
if result["status"] == "error": raise SystemExit(1)
|
|
464
|
+
elif args.command == "maintenance-cleanup":
|
|
465
|
+
from .maintenance import cleanup
|
|
466
|
+
result = cleanup(dry_run=args.dry_run)
|
|
467
|
+
print(result.get("line") or result["status"])
|
|
468
|
+
elif args.command == "git":
|
|
469
|
+
from . import git_sync
|
|
470
|
+
if args.git_command == "status": print(json.dumps(git_sync.status(), separators=(",", ":")))
|
|
471
|
+
elif args.git_command in ("allow", "deny"):
|
|
472
|
+
cfg = git_sync.config(); projects = list(cfg.get("projects", []))
|
|
473
|
+
if args.git_command == "allow" and args.project not in projects: projects.append(args.project)
|
|
474
|
+
if args.git_command == "deny": projects = [x for x in projects if x != args.project]
|
|
475
|
+
cfg["projects"] = projects; git_sync.write_config(cfg); print("updated")
|
|
476
|
+
elif args.git_command == "setup":
|
|
477
|
+
if not re.fullmatch(r"[^/\s]+/[^/\s]+", args.repo): parser.error("--repo must be owner/name")
|
|
478
|
+
cfg = {"enabled": True, "repo": args.repo, "branch": "main", "author_name": args.name, "author_email": args.email,
|
|
479
|
+
"projects": ["thesis", "personal-webpage", "devmem"], "global_tag": "personal", "interval_s": 300}
|
|
480
|
+
git_sync.write_config(cfg)
|
|
481
|
+
key = git_sync.key_path()
|
|
482
|
+
if not key.exists():
|
|
483
|
+
key.parent.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
subprocess.run([executable("ssh-keygen"), "-t", "ed25519", "-N", "", "-f", str(key)], check=True)
|
|
485
|
+
if os.name != "nt":
|
|
486
|
+
os.chmod(key, 0o600); os.chmod(str(key) + ".pub", 0o600)
|
|
487
|
+
print(key.with_suffix(key.suffix + ".pub").read_text(encoding="utf-8").strip())
|
|
488
|
+
input("Add this public key in GitHub Settings → Deploy keys with write access, then press Enter: ")
|
|
489
|
+
result = git_sync.sync(); print(json.dumps(result, separators=(",", ":")))
|
|
490
|
+
else:
|
|
491
|
+
result = git_sync.sync(dry_run=args.dry_run); print(json.dumps(result, separators=(",", ":")))
|
|
492
|
+
if result["errors"]: raise SystemExit(1)
|
|
493
|
+
else:
|
|
494
|
+
from .db import connection, fts_query, hybrid_search
|
|
495
|
+
if not fts_query(args.q): return
|
|
496
|
+
with connection() as conn:
|
|
497
|
+
rows = hybrid_search(conn, args.q, where_sql="1=1", limit=args.limit)
|
|
498
|
+
for r in rows: print(f"{r['id']} [{r['kind']}] {r['title']} — {r['snippet']}")
|
|
499
|
+
|
|
500
|
+
if __name__ == "__main__": main()
|