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