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/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()