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/panel/app.py ADDED
@@ -0,0 +1,971 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import json
6
+ import logging
7
+ import re
8
+ import sqlite3
9
+ import secrets
10
+ import threading
11
+ import time
12
+ from contextlib import asynccontextmanager
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from urllib.parse import quote
16
+
17
+ from fastapi import FastAPI, Form, HTTPException, Request
18
+ from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse, StreamingResponse
19
+ from fastapi.staticfiles import StaticFiles
20
+ from fastapi.templating import Jinja2Templates
21
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
22
+
23
+ from .. import messages as msgs
24
+ from ..agent_colors import color_for
25
+ from ..db import audit_memories, connection, ensure_embeddings, fts_query, hybrid_search, is_stale, not_superseded_any_sql, not_superseded_sql, now, parse_task_board, update_memory, validate_content, validate_review_after
26
+ from ..guard import rules_data
27
+ from ..skills import list_skills
28
+ from ..embed import get_embedder
29
+ from ..ingest.runner import ingest_all
30
+ from ..ado import AdoClient, AdoError, empty_snapshot
31
+ from . import state as live
32
+
33
+ HERE = Path(__file__).parent
34
+ templates = Jinja2Templates(directory=str(HERE / "templates"))
35
+ PANEL_STARTED_AT = now()
36
+
37
+
38
+ def asset(path: str) -> str:
39
+ """Static URL tagged with the file's mtime: an edited script is fetched again instead of served from the browser cache."""
40
+ try: version = int((HERE / "static" / path).stat().st_mtime)
41
+ except OSError: version = 0
42
+ return f"/static/{path}?v={version}"
43
+
44
+
45
+ templates.env.globals["asset"] = asset
46
+
47
+
48
+ def when(value, seconds: bool = False) -> str:
49
+ """An ISO timestamp as local time with its zone ("30 Oct 07:36 CET"); anything else is shown as given."""
50
+ if not value or not isinstance(value, str): return value or ""
51
+ text = re.sub(r"(\.\d{6})\d+", r"\1", value.strip()).replace("Z", "+00:00")
52
+ try: parsed = datetime.fromisoformat(text)
53
+ except ValueError: return value
54
+ if "T" not in text and " " not in text: return f"{parsed.day} {parsed:%b %Y}"
55
+ if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=UTC)
56
+ local = parsed.astimezone()
57
+ return f"{local.day} {local:%b %H:%M{':%S' if seconds else ''} %Z}"
58
+
59
+
60
+ def live_time(value) -> str:
61
+ if not value or not isinstance(value, str): return ""
62
+ try: return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone().strftime("%H:%M")
63
+ except ValueError: return value
64
+
65
+
66
+ def window_label(limit) -> str:
67
+ """A usage-limit window named the way the Brain gauges name it: 5h, weekly, weekly opus, 2d."""
68
+ label, minutes = limit.get("label") or "", limit.get("window_minutes")
69
+ named = {"five_hour": "5h", "seven_day": "weekly"}
70
+ if label in named: return named[label]
71
+ if label.startswith("seven_day_"): return "weekly " + label.removeprefix("seven_day_").replace("_", " ")
72
+ base = "5h" if minutes == 300 else "weekly" if minutes == 10080 else (f"{minutes // 1440}d" if minutes % 1440 == 0 else f"{round(minutes / 60)}h") if minutes else label
73
+ prefix = label.split(":")[0] + " " if ":" in label[1:] else ""
74
+ return prefix + base
75
+
76
+
77
+ templates.env.filters["when"] = when
78
+ templates.env.filters["live_time"] = live_time
79
+ templates.env.filters["window_label"] = window_label
80
+ log = logging.getLogger("devmem.panel")
81
+
82
+ INGEST_EVERY_S = 5
83
+ PRUNE_EVERY_S = 3600
84
+ STREAM_EVERY_S = 2.5
85
+ LIVE_CHAT_HOURS = 2
86
+ STATE_TTL_S = 2.0
87
+ ADO_REFRESH_S = 5 * 60
88
+
89
+ # Set by the CLI-owned Uvicorn server before it begins graceful shutdown. SSE
90
+ # clients use this to leave their generators promptly instead of holding it up.
91
+ _shutdown_event = threading.Event()
92
+
93
+
94
+ def _rows(sql, params=()):
95
+ with connection() as conn:
96
+ return [dict(x) for x in conn.execute(sql, params).fetchall()]
97
+
98
+
99
+ def _ingest_loop(stop: threading.Event):
100
+ last_ingest_error = last_embedding_error = None
101
+ pruned_at = 0.0
102
+ while True:
103
+ if time.monotonic() - pruned_at > PRUNE_EVERY_S: # message retention works even when maintenance is disabled
104
+ try:
105
+ from ..maintenance import prune_messages
106
+ prune_messages()
107
+ except Exception:
108
+ log.exception("message pruning failed")
109
+ finally:
110
+ pruned_at = time.monotonic()
111
+ try:
112
+ ingest_all()
113
+ except Exception as exc: # dashboard remains usable if one log is malformed/unreadable
114
+ current = (type(exc), str(exc))
115
+ if current != last_ingest_error:
116
+ log.exception("ingest pass failed")
117
+ last_ingest_error = current
118
+ try:
119
+ # Backfill writes only memory_embeddings; it may lazily load the local model.
120
+ with connection() as conn:
121
+ ensure_embeddings(conn)
122
+ except Exception as exc:
123
+ current = (type(exc), str(exc))
124
+ if current != last_embedding_error:
125
+ log.exception("embedding backfill failed")
126
+ last_embedding_error = current
127
+ if stop.wait(INGEST_EVERY_S):
128
+ return
129
+
130
+
131
+ class AdoPulseQueue:
132
+ """Small in-process cursor for ADO refresh notifications on the shared SSE stream."""
133
+
134
+ def __init__(self):
135
+ self.lock = threading.Lock()
136
+ self.cursor = 0
137
+ self.events: list[tuple[int, dict]] = []
138
+
139
+ def emit(self, event: dict):
140
+ with self.lock:
141
+ self.cursor += 1
142
+ self.events.append((self.cursor, event))
143
+ self.events = self.events[-100:]
144
+
145
+ def since(self, cursor: int) -> tuple[list[dict], int]:
146
+ with self.lock:
147
+ return [event for number, event in self.events if number > cursor], self.cursor
148
+
149
+
150
+ class AdoCache:
151
+ """ADO snapshot cache; unconfigured states reset the change baseline."""
152
+
153
+ def __init__(self, client: AdoClient | None = None, on_waiting_added=None):
154
+ self.client = client or AdoClient()
155
+ self.on_waiting_added = on_waiting_added
156
+ self.lock = threading.Lock()
157
+ self.value: dict | None = None
158
+ self.had_successful_refresh = False
159
+ self.changes: list[dict] = []
160
+ self.change_seq = 0
161
+ self.last_config_error: str | None = None
162
+ self.last_token_error: str | None = None
163
+ self.configured_failure_types: set[type[BaseException]] = set()
164
+ # The journal lives in memory; a new id per panel run tells browsers their seen seq is from an older run.
165
+ self.journal_id = secrets.token_hex(4)
166
+
167
+ @staticmethod
168
+ def _waiting_ids(snapshot: dict | None) -> set[int | str]:
169
+ if not snapshot:
170
+ return set()
171
+ names = ("to_review", "my_approved_not_completed", "my_changes_requested")
172
+ return {pr["id"] for name in names for pr in snapshot.get(name, []) if pr.get("id") is not None}
173
+
174
+ @staticmethod
175
+ def _by_id(snapshot: dict, name: str) -> dict[int | str, dict]:
176
+ return {item["id"]: item for item in snapshot.get(name, []) if item.get("id") is not None}
177
+
178
+ @classmethod
179
+ def _changes(cls, old: dict, fresh: dict) -> list[str]:
180
+ changes = []
181
+ old_tasks, new_tasks = cls._by_id(old, "open_tasks"), cls._by_id(fresh, "open_tasks")
182
+ for task_id, task in new_tasks.items():
183
+ if task_id not in old_tasks:
184
+ changes.append(f"Task #{task_id} {task.get('title') or 'Untitled work item'}: new, {task.get('state') or ''}")
185
+ elif task.get("state") != old_tasks[task_id].get("state"):
186
+ changes.append(f"Task #{task_id} {task.get('title') or 'Untitled work item'}: {old_tasks[task_id].get('state') or ''} → {task.get('state') or ''}")
187
+ for task_id, task in old_tasks.items():
188
+ if task_id not in new_tasks:
189
+ changes.append(f"Task #{task_id} {task.get('title') or 'Untitled work item'}: no longer open")
190
+
191
+ labels = {"to_review": "to review", "my_approved_not_completed": "approved, not completed",
192
+ "my_changes_requested": "changes requested", "my_waiting_on_others": "waiting on others"}
193
+ old_prs = {pr_id: (name, pr) for name in labels for pr_id, pr in cls._by_id(old, name).items()}
194
+ new_prs = {pr_id: (name, pr) for name in labels for pr_id, pr in cls._by_id(fresh, name).items()}
195
+ for pr_id, (name, pr) in new_prs.items():
196
+ old_item = old_prs.get(pr_id)
197
+ if old_item is None or old_item[0] != name:
198
+ changes.append(f"PR !{pr_id} {pr.get('title') or 'Untitled pull request'}: now in {labels[name]}")
199
+ if name == "my_waiting_on_others" and old_item and old_item[0] == name and set(pr.get("pending_reviewers", [])) != set(old_item[1].get("pending_reviewers", [])):
200
+ reviewers = pr.get("pending_reviewers", [])
201
+ changes.append(f"PR !{pr_id} {pr.get('title') or 'Untitled pull request'}: pending reviewers now {', '.join(reviewers) if reviewers else 'none'}")
202
+ for pr_id, (_, pr) in old_prs.items():
203
+ if pr_id not in new_prs:
204
+ changes.append(f"PR !{pr_id} {pr.get('title') or 'Untitled pull request'}: no longer listed")
205
+ return changes
206
+
207
+ def get_changes(self, since: int = 0) -> tuple[int, list[dict]]:
208
+ with self.lock:
209
+ return self.change_seq, [copy.deepcopy(change) for change in reversed(self.changes) if change["seq"] > since]
210
+
211
+ def _configured(self) -> tuple[bool, str | None]:
212
+ try:
213
+ return bool(self.client.configured()), None
214
+ except Exception as exc:
215
+ if type(exc) not in self.configured_failure_types:
216
+ log.exception("Azure DevOps configuration check failed (%s)", type(exc).__name__)
217
+ self.configured_failure_types.add(type(exc))
218
+ return False, f"configuration check failed ({type(exc).__name__})"
219
+
220
+ def get(self) -> dict:
221
+ with self.lock:
222
+ if self.value is not None:
223
+ return copy.deepcopy(self.value)
224
+ # Do not retain or display credentials; a token and valid names control this state.
225
+ configured, config_error = self._configured()
226
+ snapshot = empty_snapshot(configured=configured, config_error=config_error)
227
+ snapshot["pending"] = True
228
+ return snapshot
229
+
230
+ def refresh(self) -> dict:
231
+ try:
232
+ return self._refresh()
233
+ except Exception as exc:
234
+ log.error("Unexpected Azure DevOps refresh error (%s)", type(exc).__name__, exc_info=True)
235
+ self.unexpected_loop_error(exc)
236
+ with self.lock:
237
+ return copy.deepcopy(self.value)
238
+
239
+ def _refresh(self) -> dict:
240
+ try:
241
+ fresh = self.client.snapshot()
242
+ except AdoError as exc:
243
+ log.warning("Azure DevOps refresh failed: %s", exc)
244
+ with self.lock:
245
+ return self._failed(str(exc))
246
+ except Exception as exc:
247
+ log.error("Unexpected Azure DevOps refresh error (%s)", type(exc).__name__, exc_info=True)
248
+ with self.lock:
249
+ return self._failed(f"unexpected error ({type(exc).__name__})")
250
+
251
+ reason = fresh.get("config_error")
252
+ if reason and reason != self.last_config_error:
253
+ log.warning("Azure DevOps config problem: %s", reason)
254
+ self.last_config_error = reason
255
+ token_error = fresh.get("token_error")
256
+ if token_error and token_error != self.last_token_error:
257
+ log.warning("Azure DevOps token problem: %s", token_error)
258
+ self.last_token_error = token_error
259
+
260
+ with self.lock:
261
+ if reason or not fresh.get("configured"):
262
+ self.value = fresh
263
+ self.had_successful_refresh = False
264
+ return copy.deepcopy(fresh)
265
+ # A different organization/project is new data, not a change: start a fresh baseline.
266
+ had_successful_refresh = self.had_successful_refresh and (self.value or {}).get("scope") == fresh.get("scope")
267
+ previous = self.value
268
+ old_ids = self._waiting_ids(self.value)
269
+ self.value = fresh
270
+ self.had_successful_refresh = True
271
+ if had_successful_refresh and previous is not None:
272
+ at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
273
+ for text in self._changes(previous, fresh):
274
+ self.change_seq += 1
275
+ self.changes.append({"seq": self.change_seq, "at": at, "text": text})
276
+ self.changes = self.changes[-100:]
277
+ added = self._waiting_ids(fresh) - old_ids if had_successful_refresh else set()
278
+ result = copy.deepcopy(fresh)
279
+ # Initial data after startup or an unconfigured interval creates no alert.
280
+ if added and self.on_waiting_added:
281
+ for pr_id in added:
282
+ self.on_waiting_added({"type": "ado", "id": pr_id})
283
+ return result
284
+
285
+ def _failed(self, error: str) -> dict:
286
+ """Keep the last configured data marked stale; never re-show an old 'not configured' state."""
287
+ if self.value is not None and self.value.get("configured"):
288
+ snapshot = copy.deepcopy(self.value)
289
+ else:
290
+ # snapshot() only fails once a token and config exist, so the error is the news here.
291
+ configured, config_error = self._configured()
292
+ snapshot = empty_snapshot(configured=configured, config_error=config_error)
293
+ snapshot["stale"] = True
294
+ snapshot["pending"] = False
295
+ snapshot["error"] = error
296
+ self.value = snapshot
297
+ return copy.deepcopy(snapshot)
298
+
299
+ def unexpected_loop_error(self, exc: BaseException) -> None:
300
+ with self.lock:
301
+ self._failed(f"unexpected refresh loop error ({type(exc).__name__})")
302
+
303
+
304
+ _ado_pulses = AdoPulseQueue()
305
+ _ado_cache = AdoCache(on_waiting_added=_ado_pulses.emit)
306
+
307
+
308
+ def _ado_loop(stop: threading.Event):
309
+ while True:
310
+ try:
311
+ _ado_cache.refresh()
312
+ except Exception as exc:
313
+ log.exception("Azure DevOps refresh loop error")
314
+ if hasattr(_ado_cache, "unexpected_loop_error"):
315
+ _ado_cache.unexpected_loop_error(exc)
316
+ if stop.wait(ADO_REFRESH_S):
317
+ return
318
+
319
+
320
+ def _git_sync_loop(stop: threading.Event):
321
+ while not stop.is_set():
322
+ try:
323
+ from .. import git_sync
324
+ cfg = git_sync.config()
325
+ interval = max(1, int(cfg.get("interval_s", 300)))
326
+ if cfg.get("enabled"): git_sync.sync()
327
+ except Exception:
328
+ log.exception("GitHub sync loop error")
329
+ interval = 300
330
+ if stop.wait(interval): return
331
+
332
+
333
+ class StateCache:
334
+ """One snapshot shared by every request/stream client, rebuilt at most every STATE_TTL_S."""
335
+
336
+ def __init__(self):
337
+ self.lock = threading.Lock()
338
+ self.value: dict | None = None
339
+ self.at = 0.0
340
+
341
+ def get(self) -> dict:
342
+ with self.lock:
343
+ if self.value is None or time.monotonic() - self.at > STATE_TTL_S:
344
+ with connection() as conn:
345
+ self.value = live.build_state(conn)
346
+ self.at = time.monotonic()
347
+ return self.value
348
+
349
+ def invalidate(self) -> None:
350
+ with self.lock:
351
+ self.value = None
352
+
353
+
354
+ _cache = StateCache()
355
+
356
+
357
+ @asynccontextmanager
358
+ async def lifespan(app: FastAPI):
359
+ from ..dispatch import loop as dispatch_loop
360
+ stop = threading.Event()
361
+ thread = threading.Thread(target=_ingest_loop, args=(stop,), daemon=True)
362
+ ado_thread = threading.Thread(target=_ado_loop, args=(stop,), daemon=True)
363
+ git_thread = threading.Thread(target=_git_sync_loop, args=(stop,), daemon=True)
364
+ dispatch_thread = threading.Thread(target=dispatch_loop, args=(stop,), daemon=True)
365
+ thread.start()
366
+ ado_thread.start()
367
+ git_thread.start()
368
+ dispatch_thread.start()
369
+ try:
370
+ yield
371
+ finally:
372
+ stop.set()
373
+ thread.join(timeout=1)
374
+ ado_thread.join(timeout=1)
375
+ git_thread.join(timeout=1)
376
+ dispatch_thread.join(timeout=5)
377
+
378
+
379
+ app = FastAPI(title="devmem", lifespan=lifespan)
380
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=["127.0.0.1", "localhost"])
381
+
382
+
383
+ @app.middleware("http")
384
+ async def same_origin_writes(request: Request, call_next):
385
+ """fix(2026-10-28): refuse cross-site writes. Any page in the browser could otherwise auto-submit a forged user reply that a waiting
386
+ agent acts on, or revert maintenance. Browsers send Origin / Sec-Fetch-Site on every POST; scripts without them are not browsers."""
387
+ if request.method not in ("GET", "HEAD", "OPTIONS"):
388
+ site, origin = request.headers.get("sec-fetch-site"), request.headers.get("origin")
389
+ own = f"{request.url.scheme}://{request.headers.get('host', '')}"
390
+ if (site is not None and site not in ("same-origin", "none")) or (origin is not None and origin != own):
391
+ return PlainTextResponse("cross-site request refused", 403)
392
+ return await call_next(request)
393
+ app.mount("/static", StaticFiles(directory=str(HERE / "static")), name="static")
394
+
395
+
396
+ def render(request: Request, name: str, **context):
397
+ context.setdefault("active", name.removesuffix(".html"))
398
+ from ..maintenance import attention
399
+ with connection() as conn:
400
+ state, messages_badge = attention(conn), msgs.badge(conn, PANEL_STARTED_AT)
401
+ context.setdefault("maintenance_count", state["unseen"] + (1 if state["failing_since"] else 0))
402
+ context.setdefault("messages_badge", messages_badge)
403
+ return templates.TemplateResponse(request, name, context)
404
+
405
+
406
+ # ---------------------------------------------------------------- live API
407
+
408
+ @app.get("/api/state")
409
+ def api_state():
410
+ return _cache.get()
411
+
412
+
413
+ def _sse(event: str, data) -> str:
414
+ return f"event: {event}\ndata: {json.dumps(data, separators=(',', ':'), default=str)}\n\n"
415
+
416
+
417
+ def _messages() -> dict:
418
+ with connection() as conn:
419
+ return msgs.snapshot(conn, since=PANEL_STARTED_AT)
420
+
421
+
422
+ def _pulses(since: dict) -> tuple[list, dict]:
423
+ with connection() as conn:
424
+ return live.pulses_since(conn, since)
425
+
426
+
427
+ @app.get("/api/stream")
428
+ async def api_stream(request: Request, only: str = ""):
429
+ messages_only = only == "messages" # the chat page needs no state, graph or pulses
430
+ async def wait_for_next_update() -> bool:
431
+ """Wait in short slices so server shutdown does not wait for an SSE client."""
432
+ remaining = STREAM_EVERY_S
433
+ while remaining > 0:
434
+ if _shutdown_event.is_set() or await request.is_disconnected():
435
+ return False
436
+ pause = min(0.5, remaining)
437
+ started = time.monotonic()
438
+ await asyncio.sleep(pause)
439
+ remaining -= time.monotonic() - started
440
+ return not _shutdown_event.is_set() and not await request.is_disconnected()
441
+
442
+ async def feed_or_none():
443
+ try:
444
+ return await asyncio.to_thread(_messages)
445
+ except Exception: # fix(2026-10-28): a messages failure must not take state and pulses (or the stream) down with it
446
+ log.exception("messages update failed")
447
+ return None
448
+
449
+ async def events():
450
+ sent_sig = since = ado_cursor = None
451
+ if not messages_only:
452
+ snapshot = await asyncio.to_thread(_cache.get)
453
+ since = dict(snapshot["cursor"]) # no replay of history: pulses start from now
454
+ ado_cursor = _ado_pulses.since(0)[1]
455
+ sent_sig = snapshot["graph_sig"]
456
+ sent_messages = await feed_or_none()
457
+ yield "retry: 3000\n\n" + ("" if messages_only else _sse("state", snapshot)) + (_sse("messages", sent_messages) if sent_messages else "")
458
+ while not _shutdown_event.is_set() and not await request.is_disconnected():
459
+ if not await wait_for_next_update():
460
+ return
461
+ if not messages_only:
462
+ try:
463
+ pulses, since = await asyncio.to_thread(_pulses, since)
464
+ snapshot = await asyncio.to_thread(_cache.get)
465
+ ado_pulses, ado_cursor = _ado_pulses.since(ado_cursor)
466
+ except Exception:
467
+ log.exception("stream update failed")
468
+ yield ": error\n\n"
469
+ else:
470
+ if pulses or ado_pulses:
471
+ yield _sse("pulse", pulses + ado_pulses)
472
+ # Diff-lite: the graph (the bulk of the payload) is only re-sent when its nodes change.
473
+ body = dict(snapshot)
474
+ if snapshot["graph_sig"] == sent_sig:
475
+ body.pop("graph")
476
+ sent_sig = snapshot["graph_sig"]
477
+ yield _sse("state", body)
478
+ feed = await feed_or_none()
479
+ if feed is not None and feed != sent_messages:
480
+ sent_messages = feed
481
+ yield _sse("messages", feed)
482
+ elif messages_only:
483
+ yield ": keepalive\n\n"
484
+
485
+ return StreamingResponse(events(), media_type="text/event-stream",
486
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
487
+
488
+
489
+ # ---------------------------------------------------------------- pages
490
+
491
+ @app.get("/")
492
+ def brain(request: Request):
493
+ try:
494
+ with connection() as conn: rules = rules_data(conn, None, all_scopes=True)
495
+ except sqlite3.Error as exc:
496
+ log.exception("rules loading failed")
497
+ rules = {"rules": [], "text_rules": [], "invalid": [], "notices": [], "error": str(exc)}
498
+ skills = list_skills()
499
+ return render(request, "brain.html", active="brain", rules=rules, skills=skills)
500
+
501
+
502
+ @app.get("/ado")
503
+ def ado(request: Request):
504
+ return render(request, "ado.html", snapshot=_ado_cache.get())
505
+
506
+
507
+ @app.post("/ado/refresh")
508
+ def ado_refresh():
509
+ _ado_cache.refresh()
510
+ return RedirectResponse("/ado", 303)
511
+
512
+
513
+ @app.get("/api/ado")
514
+ def api_ado():
515
+ return JSONResponse(_ado_cache.get())
516
+
517
+
518
+ @app.get("/api/ado/changes")
519
+ def api_ado_changes(since: int = 0):
520
+ latest_seq, changes = _ado_cache.get_changes(max(0, since))
521
+ return {"journal_id": _ado_cache.journal_id, "latest_seq": latest_seq, "changes": changes}
522
+
523
+
524
+ @app.get("/overview")
525
+ def overview(request: Request):
526
+ totals = _rows("""SELECT agent,
527
+ SUM(CASE WHEN datetime(ts) >= datetime('now','localtime','start of day','utc') THEN input_tokens+output_tokens+cache_read_tokens+cache_write_tokens ELSE 0 END) today,
528
+ SUM(CASE WHEN datetime(ts) >= datetime('now','-7 days') THEN input_tokens+output_tokens+cache_read_tokens+cache_write_tokens ELSE 0 END) week,
529
+ SUM(CASE WHEN datetime(ts) >= datetime('now','-30 days') THEN input_tokens+output_tokens+cache_read_tokens+cache_write_tokens ELSE 0 END) month
530
+ FROM usage_events GROUP BY agent""")
531
+ daily = _rows("""SELECT date(ts,'localtime') day, agent,
532
+ SUM(input_tokens+output_tokens+cache_read_tokens+cache_write_tokens) tokens
533
+ FROM usage_events WHERE datetime(ts) >= datetime('now','-30 days') GROUP BY day, agent ORDER BY day""")
534
+ projects = _rows("""SELECT COALESCE(project,'Global') project,
535
+ SUM(input_tokens+output_tokens+cache_read_tokens+cache_write_tokens) tokens
536
+ FROM usage_events GROUP BY project ORDER BY tokens DESC LIMIT 10""")
537
+ with connection() as conn:
538
+ limits, tokens = live.limits(conn), live.tokens(conn)
539
+ boards = []
540
+ for row in conn.execute(f"SELECT id,title,content,tags FROM memories m WHERE {not_superseded_any_sql()}"):
541
+ if "board" not in re.split(r"[\s,]+", (row["tags"] or "").lower()): continue
542
+ entries, status_as_of = parse_task_board(row["content"])
543
+ boards.append({"id": row["id"], "title": row["title"], "entries": entries, "status_as_of": status_as_of})
544
+ return render(request, "overview.html", totals=totals, daily=daily, projects=projects, limits=limits, tokens=tokens, boards=boards)
545
+
546
+
547
+ @app.get("/projects")
548
+ def projects(request: Request):
549
+ rows = _rows("""WITH u AS (SELECT project,SUM(input_tokens+output_tokens+cache_read_tokens+cache_write_tokens) tokens,
550
+ COUNT(DISTINCT session_id) sessions,MAX(ts) activity FROM usage_events GROUP BY project),
551
+ m AS (SELECT project,COUNT(*) memories,MAX(updated_at) activity FROM memories GROUP BY project),
552
+ keys AS (SELECT project FROM u UNION SELECT project FROM m)
553
+ SELECT COALESCE(keys.project,'Global') project,COALESCE(u.tokens,0) tokens,COALESCE(u.sessions,0) sessions,
554
+ COALESCE(m.memories,0) memories,COALESCE(u.activity,m.activity) activity
555
+ FROM keys LEFT JOIN u ON u.project IS keys.project LEFT JOIN m ON m.project IS keys.project
556
+ ORDER BY datetime(COALESCE(u.activity,m.activity)) DESC""")
557
+ return render(request, "projects.html", rows=rows)
558
+
559
+
560
+ @app.get("/projects/detail")
561
+ def project_detail(request: Request, path: str):
562
+ return render(request, "project.html", active="projects", project=path,
563
+ events=_rows("SELECT * FROM usage_events WHERE project=? ORDER BY datetime(ts) DESC LIMIT 200", (path,)),
564
+ activity=_rows("SELECT * FROM activity_events WHERE project=? ORDER BY datetime(ts) DESC LIMIT 100", (path,)),
565
+ memories=_rows("SELECT * FROM memories WHERE project=? ORDER BY updated_at DESC", (path,)))
566
+
567
+
568
+ @app.get("/sessions")
569
+ def sessions(request: Request):
570
+ rows = _rows("""SELECT session_id,agent,project,model,MIN(ts) start,MAX(ts) end,COUNT(*) turns,SUM(input_tokens) input,
571
+ SUM(output_tokens) output,SUM(cache_read_tokens) cache FROM usage_events
572
+ GROUP BY session_id,agent,project,model ORDER BY datetime(end) DESC LIMIT 500""")
573
+ return render(request, "sessions.html", rows=rows)
574
+
575
+
576
+ @app.get("/sessions/{session_id}")
577
+ def session_detail(request: Request, session_id: str):
578
+ return render(request, "session.html", active="sessions", session_id=session_id,
579
+ events=_rows("SELECT * FROM usage_events WHERE session_id=? ORDER BY datetime(ts)", (session_id,)),
580
+ activity=_rows("SELECT * FROM activity_events WHERE session_id=? ORDER BY datetime(ts) DESC LIMIT 200", (session_id,)))
581
+
582
+
583
+ @app.get("/agents")
584
+ def agents(request: Request):
585
+ clients = _rows("""SELECT a.*,COUNT(t.id) tool_count FROM agent_clients a LEFT JOIN tool_calls t ON t.client_id=a.id
586
+ GROUP BY a.id ORDER BY a.last_seen_at DESC""")
587
+ for client in clients:
588
+ client["connected"] = live.client_connected(client)
589
+ calls = _rows("SELECT t.*,a.client_name FROM tool_calls t JOIN agent_clients a ON a.id=t.client_id ORDER BY t.at DESC LIMIT 100")
590
+ return render(request, "agents.html", clients=clients, calls=calls)
591
+
592
+
593
+ @app.get("/memory")
594
+ def memory(request: Request, q: str = "", kind: str = ""):
595
+ embedder_status = None
596
+ if q and fts_query(q):
597
+ with connection() as conn:
598
+ matches = hybrid_search(conn, q, where_sql="1=1", kind=kind or None, limit=200)
599
+ ids = [match["id"] for match in matches]
600
+ full_rows = conn.execute(
601
+ f"SELECT * FROM memories WHERE id IN ({','.join('?' for _ in ids)})", ids
602
+ ).fetchall() if ids else []
603
+ by_id = {row["id"]: dict(row) for row in full_rows}
604
+ # Hydrate fields used by the edit form without changing hybrid search's
605
+ # compact public/MCP projection, and retain its rank and annotations.
606
+ rows = [{**by_id[match["id"]], "match": match["match"], "snippet": match.get("snippet")}
607
+ for match in matches if match["id"] in by_id]
608
+ status = getattr(get_embedder(), "status", lambda: {})()
609
+ if status and (not status.get("enabled") or status.get("last_error")):
610
+ embedder_status = status
611
+ else:
612
+ sql, params = "SELECT * FROM memories", []
613
+ if kind:
614
+ sql += " WHERE kind=?"
615
+ params.append(kind)
616
+ rows = _rows(sql + " ORDER BY updated_at DESC LIMIT 200", params)
617
+ for row in rows:
618
+ row["stale"] = is_stale(row.get("review_after"))
619
+ from ..git_sync import in_scope
620
+ row["synced"] = in_scope(row)
621
+ return render(request, "memory.html", rows=rows, q=q, kind=kind, embedder_status=embedder_status,
622
+ counts=_rows("SELECT kind,COUNT(*) count FROM memories GROUP BY kind"))
623
+
624
+
625
+ @app.get("/memory/{id}")
626
+ def memory_detail(request: Request, id: int):
627
+ row = _rows("SELECT * FROM memories WHERE id=?", (id,))
628
+ if not row: raise HTTPException(404, "memory not found")
629
+ row[0]["stale"] = is_stale(row[0].get("review_after"))
630
+ from ..git_sync import in_scope
631
+ row[0]["synced"] = in_scope(row[0])
632
+ return render(request, "memory.html", rows=row, q="", kind="", embedder_status=None,
633
+ counts=_rows("SELECT kind,COUNT(*) count FROM memories GROUP BY kind"))
634
+
635
+
636
+ @app.post("/memory")
637
+ def create_memory(content: str = Form(...), title: str = Form(""), kind: str = Form("note"), tags: str = Form(""), project: str = Form(""), review_after: str = Form("")):
638
+ try:
639
+ validate_content(content); review_after = validate_review_after(review_after)
640
+ except ValueError as exc:
641
+ raise HTTPException(400, str(exc)) from exc
642
+ with connection() as conn:
643
+ conn.execute("INSERT INTO memories(project,kind,title,content,tags,review_after,created_at,updated_at,source_agent) VALUES(?,?,?,?,?,?,?,?,?)",
644
+ (project or None, kind, title or content[:80], content, tags, review_after, now(), now(), "panel"))
645
+ return RedirectResponse("/memory", 303)
646
+
647
+
648
+ @app.post("/memory/{id}")
649
+ def edit_memory(id: int, content: str = Form(...), title: str = Form(...), kind: str = Form(...), tags: str = Form(""), review_after: str = Form("")):
650
+ try:
651
+ validate_content(content); review_after = validate_review_after(review_after)
652
+ except ValueError as exc:
653
+ raise HTTPException(400, str(exc)) from exc
654
+ with connection() as conn:
655
+ conn.execute("UPDATE memories SET content=?,title=?,kind=?,tags=?,review_after=?,updated_at=? WHERE id=?", (content, title, kind, tags, review_after, now(), id))
656
+ return RedirectResponse("/memory", 303)
657
+
658
+
659
+ @app.post("/memory/{id}/delete")
660
+ def delete_memory(id: int):
661
+ with connection() as conn:
662
+ conn.execute("DELETE FROM memories WHERE id=?", (id,))
663
+ return RedirectResponse("/memory", 303)
664
+
665
+
666
+ @app.post("/memory/{id}/reminder-done")
667
+ def reminder_done(id: int):
668
+ with connection() as conn:
669
+ row = conn.execute("SELECT tags FROM memories WHERE id=?", (id,)).fetchone()
670
+ if not row:
671
+ raise HTTPException(404, "memory not found")
672
+ tags = re.split(r"[\s,]+", row["tags"] or "")
673
+ existing_tags = " ".join(tag for tag in tags if tag)
674
+ updated_tags = " ".join(tag for tag in tags if tag and tag.lower() != "remind")
675
+ if updated_tags != existing_tags:
676
+ update_memory(conn, id, tags=updated_tags)
677
+ _cache.invalidate()
678
+ return {"ok": True, "id": id}
679
+
680
+
681
+ @app.get("/audit")
682
+ def audit(request: Request):
683
+ with connection() as conn:
684
+ rows = audit_memories(conn)
685
+ return render(request, "audit.html", rows=rows)
686
+
687
+
688
+ @app.get("/maintenance")
689
+ def maintenance(request: Request):
690
+ from ..maintenance import OPEN_FLAG_SQL, attention
691
+ with connection() as conn:
692
+ failing = attention(conn)["failing_since"]
693
+ runs = [dict(row) for row in conn.execute("SELECT * FROM maintenance_runs ORDER BY id DESC LIMIT 50")]
694
+ changes = [dict(row) for row in conn.execute(f"""SELECT c.*, r.status run_status, ({OPEN_FLAG_SQL}) open_flag
695
+ FROM maintenance_changes c LEFT JOIN maintenance_runs r ON r.id=c.run_id WHERE c.blocked_by IS NOT 'no_change' ORDER BY c.id DESC LIMIT 300""")]
696
+ # Rendering the page is "seeing" it: the nav badge only counts what arrived since.
697
+ conn.execute("UPDATE maintenance_changes SET seen_at=? WHERE seen_at IS NULL", (now(),))
698
+ for change in changes:
699
+ try: change["evidence_items"] = json.loads(change["evidence"])
700
+ except (TypeError, ValueError): change["evidence_items"] = None
701
+ if not isinstance(change["evidence_items"], list): # fix(2026-10-28): say so instead of showing "no evidence"
702
+ change["evidence_items"] = [{"ref": "corrupt evidence record", "result": "the stored evidence could not be read"}]
703
+ from .. import git_sync
704
+ try: git_status = git_sync.status()
705
+ except ValueError as exc: git_status = {"enabled": False, "error": str(exc), "out_of_sync": []}
706
+ return render(request, "maintenance.html", runs=runs, changes=changes, failing=failing, git_status=git_status, notice=request.query_params.get("notice"))
707
+
708
+
709
+ @app.post("/git/sync")
710
+ def git_sync_now():
711
+ from .. import git_sync
712
+ result = git_sync.sync()
713
+ notice = result.get("errors") and "; ".join(result["errors"]) or "GitHub sync finished"
714
+ return RedirectResponse("/maintenance?notice=" + quote(notice), 303)
715
+
716
+
717
+ @app.post("/maintenance/cleanup")
718
+ def maintenance_cleanup():
719
+ from ..maintenance import cleanup
720
+ result = cleanup()
721
+ notice = result.get("line") or ("cleanup is already running" if result["status"] == "locked" else "cleanup finished")
722
+ return RedirectResponse("/maintenance?notice=" + quote(notice), 303)
723
+
724
+
725
+ @app.post("/maintenance/{change_id}/revert")
726
+ def maintenance_revert(change_id: int):
727
+ from ..maintenance import revert
728
+ try:
729
+ if not revert(change_id): raise HTTPException(404, "change cannot be reverted")
730
+ except ValueError as exc:
731
+ raise HTTPException(409, str(exc)) from exc
732
+ return RedirectResponse("/maintenance", 303)
733
+
734
+
735
+ @app.post("/maintenance/{change_id}/dismiss")
736
+ def maintenance_dismiss(change_id: int):
737
+ from ..maintenance import dismiss
738
+ if not dismiss(change_id): raise HTTPException(404, "only an open flag can be dismissed")
739
+ return RedirectResponse("/maintenance", 303)
740
+
741
+
742
+ # ---------------------------------------------------------------- messages
743
+
744
+ @app.get("/api/messages")
745
+ def api_messages():
746
+ return _messages()
747
+
748
+
749
+ @app.get("/api/dispatch")
750
+ def api_dispatch():
751
+ from ..dispatch import config
752
+ cfg = config()
753
+ with connection() as conn:
754
+ jobs = [dict(row) for row in conn.execute("SELECT * FROM message_jobs ORDER BY id DESC LIMIT 20")]
755
+ return {"enabled": cfg["enabled"], "error": cfg.get("error"), "jobs": jobs}
756
+
757
+
758
+ def _chat_filters(project: str, agent: str, kind: str, status: str, question: str) -> dict:
759
+ return {"project": project, "agent": agent, "kind": kind if kind in msgs.KINDS else "",
760
+ "status": status if status in ("open", "answered", "closed") else "", "question": bool(question)}
761
+
762
+
763
+ def _live_agent_name(kind, project, key, color=None, subagent=None):
764
+ """Human label for a live key; the raw identity remains in the row title."""
765
+ name = color["name"] if color else kind
766
+ if kind == "Codex":
767
+ parts = [name, "Codex"]
768
+ if project: parts.append(live.basename(project))
769
+ return " · ".join(parts)
770
+ if subagent:
771
+ return " · ".join((name, subagent))
772
+ if project:
773
+ return " · ".join((name, live.basename(project)))
774
+ return name
775
+
776
+
777
+ def _live_agents(conn):
778
+ """Active Brain agents with the exact key their live-room hook uses."""
779
+ rows = []
780
+ sessions = live.recent_sessions(conn)
781
+ subagents = msgs.session_subagents(conn, [s["session_id"] for s in sessions if s["agent"].startswith("claude")])
782
+ for session in sessions:
783
+ if session["agent"].startswith("claude") and session["session_id"]:
784
+ key = session["session_id"]
785
+ color = color_for(conn, key)
786
+ rows.append({"key": key, "name": _live_agent_name("Claude", session.get("project"), key, color), "color": color, "connectable": True, "status": session.get("last_tool") or "active"})
787
+ for agent in subagents.get(session["session_id"], []):
788
+ key = session["session_id"] + ":" + agent["agent_id"]
789
+ rows.append({"key": key, "agent_id": agent["agent_id"], "name": _live_agent_name("Claude", session.get("project"), key, color, agent.get("name") or msgs.friendly_name(agent.get("agent_type"))), "color": color,
790
+ "connectable": True, "status": agent.get("status") or "active"})
791
+ elif session["agent"] == "codex":
792
+ key = "codex:" + str(session.get("session_id") or "")
793
+ color = color_for(conn, key)
794
+ rows.append({"key": key, "name": _live_agent_name("Codex", session.get("project"), key, color), "color": color,
795
+ "connectable": True, "status": session.get("last_tool") or "active"})
796
+ connected = {r[0] for r in conn.execute("SELECT agent_key FROM room_connections")}
797
+ active = {row["key"] for row in rows}
798
+ for key in sorted(connected - active):
799
+ if key.startswith("codex:"):
800
+ color = color_for(conn, key)
801
+ rows.append({"key": key, "name": _live_agent_name("Codex", None, key, color), "color": color, "connectable": True, "status": "idle", "connected": True})
802
+ elif ":" in key:
803
+ parent, agent_id = key.split(":", 1)
804
+ color = color_for(conn, parent)
805
+ rows.append({"key": key, "agent_id": agent_id, "name": _live_agent_name("Claude", None, key, color, agent_id), "color": color, "connectable": True, "status": "idle", "connected": True})
806
+ else:
807
+ color = color_for(conn, key)
808
+ rows.append({"key": key, "name": _live_agent_name("Claude", None, key, color), "color": color, "connectable": True, "status": "idle", "connected": True})
809
+ for row in rows: row["connected"] = row["key"] in connected
810
+ return rows
811
+
812
+
813
+ def _live_data(conn):
814
+ rows = msgs.live_rows(conn, LIVE_CHAT_HOURS)
815
+ for row in rows:
816
+ row["headline"], row["icon"] = msgs.headline_for(row)
817
+ agents = _live_agents(conn)
818
+ by_key = {agent["key"]: agent for agent in agents}
819
+ by_agent_id = {agent["agent_id"]: agent for agent in agents if agent.get("agent_id")}
820
+ for row in rows:
821
+ agent = by_key.get(row["sender"]) or by_agent_id.get(row.get("agent_id"))
822
+ row["display_name"] = "You" if row["sender_kind"] == "user" else (agent["name"] if agent else msgs.friendly_name(row["sender"]))
823
+ row["color"] = None if row["sender_kind"] == "user" or not agent else agent["color"]
824
+ delivery = []
825
+ for item in row["delivery"]:
826
+ delivery_agent = by_key.get(item["key"])
827
+ if delivery_agent is None:
828
+ key = item["key"]
829
+ color_key = key.removeprefix("codex:").split(":", 1)[0] if not key.startswith("codex:") else key
830
+ color = color_for(conn, color_key)
831
+ delivery_agent = {"name": _live_agent_name("Codex" if key.startswith("codex:") else "Claude", None, key, color, key.split(":", 1)[1] if ":" in key and not key.startswith("codex:") else None), "color": color}
832
+ delivery.append({**item, "name": delivery_agent["name"], "color": delivery_agent["color"]})
833
+ row["delivery"] = delivery
834
+ return {"messages": rows, "agents": agents, "connected": sum(a["connected"] for a in agents if a["connectable"])}
835
+
836
+
837
+ @app.get("/messages")
838
+ def messages_page(request: Request):
839
+ """One opt-in, live room; old query parameters intentionally resolve here too."""
840
+ with connection() as conn:
841
+ data = _live_data(conn)
842
+ from ..dispatch import config
843
+ return render(request, "messages.html", active="messages", dispatch_enabled=config()["enabled"], dispatch_project=str(Path.cwd()), **data)
844
+
845
+
846
+ @app.get("/api/live")
847
+ def api_live(read: int = 0):
848
+ with connection() as conn:
849
+ data = _live_data(conn)
850
+ if read: msgs.mark_read(conn, [row["id"] for row in data["messages"] if row["kind"] != "event"], msgs.USER)
851
+ return data
852
+
853
+
854
+ @app.post("/messages/live/connect")
855
+ def live_connect(agent_key: str = Form("")):
856
+ with connection() as conn:
857
+ if not isinstance(agent_key, str) or agent_key not in {a["key"] for a in _live_agents(conn) if a["connectable"]}:
858
+ raise HTTPException(400, "unknown agent")
859
+ since_id = conn.execute("SELECT COALESCE(MAX(id),0) FROM messages").fetchone()[0]
860
+ conn.execute("INSERT OR REPLACE INTO room_connections(agent_key,since_id,connected_at) VALUES(?,?,?)", (agent_key, since_id, now()))
861
+ return {"ok": True}
862
+
863
+
864
+ @app.post("/messages/live/disconnect")
865
+ def live_disconnect(agent_key: str = Form("")):
866
+ if not isinstance(agent_key, str) or not agent_key:
867
+ raise HTTPException(400, "agent key is required")
868
+ with connection() as conn:
869
+ if not conn.execute("DELETE FROM room_connections WHERE agent_key=?", (agent_key,)).rowcount:
870
+ raise HTTPException(400, "unknown connection")
871
+ return {"ok": True}
872
+
873
+
874
+ @app.post("/messages/live/send")
875
+ def live_send(body: str = Form(""), project: str = Form("")):
876
+ from ..dispatch import config, explicit_agents, enqueue
877
+ with connection() as conn:
878
+ try:
879
+ selected = sorted(explicit_agents(body)) if config()["enabled"] else []
880
+ if selected and (not project or not Path(project).is_absolute() or not Path(project).is_dir()):
881
+ raise ValueError("Choose an existing absolute project directory for automatic replies.")
882
+ ids = []
883
+ for agent in selected or [None]:
884
+ message_id = msgs.post(conn, body, sender=msgs.USER, sender_kind="user", to=agent or msgs.LIVE_TO,
885
+ project=str(Path(project).resolve()) if agent else None)
886
+ ids.append(message_id)
887
+ if agent:
888
+ enqueue(conn, message_id, f"live:{message_id}", selected=agent)
889
+ except ValueError as exc:
890
+ raise HTTPException(400, str(exc)) from exc
891
+ return {"id": ids[0], "ids": ids}
892
+
893
+
894
+ @app.get("/api/chats")
895
+ def api_chats(project: str = "", agent: str = "", kind: str = "", status: str = "", question: str = ""):
896
+ with connection() as conn:
897
+ return msgs.chats(conn, **_chat_filters(project, agent, kind, status, question))
898
+
899
+
900
+ @app.get("/api/chat")
901
+ def api_chat(key: str, read: int = 0):
902
+ with connection() as conn:
903
+ current = msgs.chat(conn, key)
904
+ if not current: raise HTTPException(404, "chat not found")
905
+ if read: msgs.mark_chat_read(conn, current) # the open chat page reads what arrives live
906
+ return current
907
+
908
+
909
+ @app.post("/messages/chat/send")
910
+ def chat_send(request: Request, key: str = Form(...), body: str = Form(...)):
911
+ with connection() as conn:
912
+ try:
913
+ message_id = msgs.chat_post(conn, key, body)
914
+ except LookupError as exc:
915
+ raise HTTPException(404, str(exc)) from exc
916
+ except ValueError as exc:
917
+ raise HTTPException(400, str(exc)) from exc
918
+ if "application/json" in request.headers.get("accept", ""):
919
+ return {"id": message_id}
920
+ return RedirectResponse("/messages?chat=" + quote(key, safe=""), 303)
921
+
922
+
923
+ @app.get("/messages/thread/{thread_id}")
924
+ def message_thread_page(request: Request, thread_id: int):
925
+ with connection() as conn:
926
+ items = msgs.thread(conn, thread_id)
927
+ if not items: raise HTTPException(404, "thread not found")
928
+ agents = {aid: msgs.subagent(conn, aid) for aid in dict.fromkeys(m["agent_id"] for m in items if m["agent_id"])}
929
+ # Opening a thread is reading it: unread and badge counts only cover what the user has not seen.
930
+ msgs.mark_read(conn, [m["id"] for m in items], msgs.USER)
931
+ chat_key = msgs.chat_keys(conn, msgs._roots(conn, [thread_id])).get(thread_id)
932
+ return render(request, "message_thread.html", active="messages", items=items, root=items[0], agents=agents, chat_key=chat_key)
933
+
934
+
935
+ @app.post("/messages/thread/{thread_id}/reply")
936
+ def message_reply(thread_id: int, body: str = Form(...)):
937
+ with connection() as conn:
938
+ items = msgs.thread(conn, thread_id)
939
+ if not items: raise HTTPException(404, "thread not found")
940
+ # Answer the newest open question from an agent, otherwise continue after the newest message.
941
+ target = next((m for m in reversed(items) if m["kind"] == "question" and m["status"] == "open" and m["sender_kind"] != "user"), items[-1])
942
+ try:
943
+ message_id = msgs.post(conn, body, sender=msgs.USER, sender_kind="user", to=target["sender"] if target["sender_kind"] != "user" else "*",
944
+ reply_to=target["id"])
945
+ except ValueError as exc:
946
+ raise HTTPException(400, str(exc)) from exc
947
+ msgs.mark_read(conn, [message_id], msgs.USER)
948
+ from ..dispatch import enqueue
949
+ chat_key = msgs.chat_keys(conn, msgs._roots(conn, [items[0]["thread_id"]])).get(items[0]["thread_id"])
950
+ enqueue(conn, message_id, chat_key or f"t:{items[0]['thread_id']}")
951
+ return RedirectResponse(f"/messages/thread/{thread_id}", 303)
952
+
953
+
954
+ @app.post("/messages/thread/{thread_id}/close")
955
+ def message_close(thread_id: int):
956
+ with connection() as conn: msgs.close(conn, thread_id)
957
+ return RedirectResponse(f"/messages/thread/{thread_id}", 303)
958
+
959
+
960
+ @app.post("/messages/thread/{thread_id}/reopen")
961
+ def message_reopen(thread_id: int):
962
+ with connection() as conn: msgs.reopen(conn, thread_id)
963
+ return RedirectResponse(f"/messages/thread/{thread_id}", 303)
964
+
965
+
966
+ @app.get("/messages/agent/{agent_id}")
967
+ def subagent_page(request: Request, agent_id: str):
968
+ with connection() as conn:
969
+ agent = msgs.subagent(conn, agent_id)
970
+ if not agent: raise HTTPException(404, "subagent not found")
971
+ return render(request, "subagent.html", active="messages", agent=agent)