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/state.py ADDED
@@ -0,0 +1,358 @@
1
+ """Live snapshot (/api/state) and pulse (/api/stream) queries.
2
+
3
+ Timestamps in the DB are mixed ISO formats, so time windows always compare with datetime(ts). A plain text
4
+ prefilter on the date (ts >= 'YYYY-MM-DD' a day earlier than needed) lets the ts indexes skip old rows first.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import re
12
+ import time
13
+ from datetime import UTC, datetime, timedelta
14
+ from pathlib import PurePath
15
+ from typing import Any
16
+
17
+ from .. import messages
18
+ from ..db import SCHEMA_VERSION, not_superseded_sql, parse_ts
19
+
20
+ STALE_AFTER_S = 6 * 3600
21
+ RECENT_SESSION_S = 600
22
+ CONNECTED_S = 300
23
+ PULSE_MAX_AGE_S = 600
24
+ MAX_PROJECTS = 12
25
+ MAX_MEMORIES = 250
26
+ FEED_LEN = 30
27
+ TOKEN_COLS = ("input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens")
28
+
29
+
30
+ def _now() -> datetime:
31
+ return datetime.now(UTC)
32
+
33
+
34
+ def _prefilter(days: float) -> str:
35
+ """Date-only lower bound, one day wider than the real window, valid for every ISO variant."""
36
+ return (_now() - timedelta(days=days + 1)).strftime("%Y-%m-%d")
37
+
38
+
39
+ def _rows(conn, sql: str, params=()) -> list[dict[str, Any]]:
40
+ return [dict(r) for r in conn.execute(sql, params).fetchall()]
41
+
42
+
43
+ def _iso(value) -> str | None:
44
+ parsed = parse_ts(value)
45
+ return parsed.isoformat().replace("+00:00", "Z") if parsed else None
46
+
47
+
48
+ def basename(project: str | None) -> str:
49
+ return PurePath(project).name or project if project else "global"
50
+
51
+
52
+ # ---------------------------------------------------------------- limits
53
+
54
+ def limits(conn) -> list[dict[str, Any]]:
55
+ rows = _rows(conn, """SELECT * FROM (
56
+ SELECT r.*, ROW_NUMBER() OVER (PARTITION BY agent,label ORDER BY datetime(ts) DESC, id DESC) rn
57
+ FROM rate_limits r WHERE label IS NOT NULL) WHERE rn=1""")
58
+ now = _now()
59
+ out = []
60
+ for r in rows:
61
+ used = float(r["used_percent"]) if r["used_percent"] is not None else None
62
+ resets = parse_ts(r["resets_at"])
63
+ as_of = max(filter(None, (parse_ts(r["ts"]), parse_ts(r.get("observed_at")))), default=None)
64
+ age = (now - as_of).total_seconds() if as_of else None
65
+ out.append({
66
+ "agent": r["agent"], "label": r["label"], "window_minutes": r["window_minutes"],
67
+ "used_percent": used, "left_percent": None if used is None else max(0.0, round(100 - used, 1)),
68
+ "resets_at": _iso(resets), "resets_in_s": int((resets - now).total_seconds()) if resets else None,
69
+ "reset_passed": bool(resets and resets <= now),
70
+ "as_of": _iso(as_of), "age_s": int(age) if age is not None else None,
71
+ "stale": age is None or age > STALE_AFTER_S,
72
+ })
73
+ order = {"claude": 0, "codex": 1}
74
+ out.sort(key=lambda x: (order.get(x["agent"], 9), x["window_minutes"] or 99999, x["label"]))
75
+ return out
76
+
77
+
78
+ # ---------------------------------------------------------------- tokens
79
+
80
+ def _token_dict(row: dict[str, Any], prefix: str = "") -> dict[str, int]:
81
+ d = {
82
+ "input": int(row.get(prefix + "input") or 0), "output": int(row.get(prefix + "output") or 0),
83
+ "cache_read": int(row.get(prefix + "cache_read") or 0), "cache_write": int(row.get(prefix + "cache_write") or 0),
84
+ }
85
+ d["total"] = sum(d.values())
86
+ return d
87
+
88
+
89
+ def tokens(conn) -> dict[str, Any]:
90
+ today_start = conn.execute("SELECT datetime('now','localtime','start of day','utc')").fetchone()[0]
91
+ pre = _prefilter(7)
92
+ sums = lambda cond, p: ", ".join(f"SUM(CASE WHEN {cond} THEN {c} ELSE 0 END) {p}{n}" for c, n in zip(TOKEN_COLS, ("input", "output", "cache_read", "cache_write")))
93
+ per_agent = _rows(conn, f"""SELECT agent, {sums('datetime(ts) >= :today', 't_')}, {sums("datetime(ts) >= datetime('now','-7 days')", 'w_')}
94
+ FROM usage_events WHERE ts >= :pre GROUP BY agent""", {"today": today_start, "pre": pre})
95
+ models = _rows(conn, f"""SELECT agent, model, {sums('1', '')} FROM usage_events
96
+ WHERE ts >= :pre AND datetime(ts) >= :today GROUP BY agent, model""", {"today": today_start, "pre": _prefilter(1)})
97
+ hourly = _rows(conn, """SELECT agent, strftime('%Y-%m-%dT%H', datetime(ts)) hour,
98
+ SUM(input_tokens+output_tokens+cache_read_tokens+cache_write_tokens) total
99
+ FROM usage_events WHERE ts >= ? AND datetime(ts) >= datetime('now','-24 hours') GROUP BY agent, hour""", (_prefilter(1),))
100
+ now = _now().replace(minute=0, second=0, microsecond=0)
101
+ hours = [(now - timedelta(hours=23 - i)).strftime("%Y-%m-%dT%H") for i in range(24)]
102
+ spark: dict[str, list[int]] = {}
103
+ for h in hourly:
104
+ if h["hour"] in hours:
105
+ spark.setdefault(h["agent"], [0] * 24)[hours.index(h["hour"])] = int(h["total"] or 0)
106
+ today = {r["agent"]: _token_dict(r, "t_") for r in per_agent}
107
+ week = {r["agent"]: _token_dict(r, "w_") for r in per_agent}
108
+ model_rows = [{"agent": m["agent"], "model": m["model"], **_token_dict(m)} for m in models]
109
+ model_rows.sort(key=lambda m: -m["total"])
110
+ return {
111
+ "today_start": _iso(today_start), "today": today, "week": week,
112
+ "today_total": sum(v["total"] for v in today.values()), "week_total": sum(v["total"] for v in week.values()),
113
+ "models_today": model_rows, "hourly": {"hours": [h + ":00:00Z" for h in hours], "series": spark},
114
+ }
115
+
116
+
117
+ # ---------------------------------------------------------------- agents and sessions
118
+
119
+ def _pid_alive(pid) -> bool:
120
+ try:
121
+ os.kill(int(pid), 0)
122
+ return True
123
+ except (OSError, TypeError, ValueError):
124
+ return False
125
+
126
+
127
+ def client_connected(client: dict[str, Any]) -> bool:
128
+ """Same rule as the /agents page: not ended, seen in the last 5 minutes and PID alive."""
129
+ seen = parse_ts(client.get("last_seen_at"))
130
+ return (client.get("ended_at") is None and seen is not None
131
+ and (_now() - seen).total_seconds() < CONNECTED_S and _pid_alive(client.get("pid")))
132
+
133
+
134
+ def agent_family(name: str | None) -> str:
135
+ name = (name or "").lower()
136
+ if "codex" in name:
137
+ return "codex"
138
+ if "claude" in name:
139
+ return "claude"
140
+ return name or "mcp"
141
+
142
+
143
+ def connected_clients(conn) -> list[dict[str, Any]]:
144
+ rows = _rows(conn, """SELECT a.id, a.client_name, a.client_version, a.pid, a.project, a.started_at, a.last_seen_at, a.ended_at,
145
+ (SELECT tool FROM tool_calls t WHERE t.client_id=a.id ORDER BY t.id DESC LIMIT 1) last_tool
146
+ FROM agent_clients a WHERE a.ended_at IS NULL ORDER BY a.id DESC LIMIT 100""")
147
+ out = []
148
+ for r in rows:
149
+ if client_connected(r):
150
+ out.append({"id": r["id"], "client_name": r["client_name"], "family": agent_family(r["client_name"]), "pid": r["pid"],
151
+ "project": r["project"], "project_name": basename(r["project"]), "last_seen": _iso(r["last_seen_at"]),
152
+ "last_tool": r["last_tool"]})
153
+ return out
154
+
155
+
156
+ def recent_sessions(conn) -> list[dict[str, Any]]:
157
+ pre = _prefilter(1)
158
+ window = f"-{RECENT_SESSION_S} seconds"
159
+ acts = _rows(conn, """SELECT agent, session_id, project, ts, name, summary, kind FROM activity_events
160
+ WHERE ts >= ? AND datetime(ts) >= datetime('now', ?) ORDER BY datetime(ts) DESC, id DESC LIMIT 2000""", (pre, window))
161
+ usage = _rows(conn, """SELECT agent, session_id, project, MAX(datetime(ts)) ts,
162
+ SUM(input_tokens+output_tokens+cache_read_tokens+cache_write_tokens) tokens, MAX(model) model
163
+ FROM usage_events WHERE ts >= ? AND datetime(ts) >= datetime('now', ?) GROUP BY agent, session_id, project""", (pre, window))
164
+ sessions: dict[tuple, dict[str, Any]] = {}
165
+ for a in acts: # newest first: the first seen row per session is its last action
166
+ key = (a["agent"], a["session_id"])
167
+ s = sessions.setdefault(key, {"agent": a["agent"], "session_id": a["session_id"], "project": a["project"],
168
+ "last_ts": a["ts"], "last_tool": a["name"] if a["kind"] == "tool" else None,
169
+ "last_summary": a["summary"] if a["kind"] == "tool" else "", "tools_10m": 0, "tokens_10m": 0, "model": None})
170
+ s["tools_10m"] += a["kind"] == "tool"
171
+ if s["last_tool"] is None and a["kind"] == "tool":
172
+ s["last_tool"], s["last_summary"] = a["name"], a["summary"]
173
+ for u in usage:
174
+ # Subagent tokens are logged under "claude"; their activity under "claude-subagent".
175
+ key = (u["agent"], u["session_id"])
176
+ s = sessions.setdefault(key, {"agent": u["agent"], "session_id": u["session_id"], "project": u["project"], "last_ts": u["ts"],
177
+ "last_tool": None, "last_summary": "", "tools_10m": 0, "tokens_10m": 0, "model": None})
178
+ s["tokens_10m"] += int(u["tokens"] or 0)
179
+ s["model"] = u["model"]
180
+ if (parse_ts(u["ts"]) or _now()) > (parse_ts(s["last_ts"]) or _now()):
181
+ s["last_ts"] = u["ts"]
182
+ s["project"] = s["project"] or u["project"]
183
+ for a in _rows(conn, """SELECT s.agent_id,s.project,s.last_activity_at,s.model,
184
+ (SELECT name FROM subagent_tools t WHERE t.agent_id=s.agent_id ORDER BY source_offset DESC LIMIT 1) last_tool,
185
+ (SELECT arg FROM subagent_tools t WHERE t.agent_id=s.agent_id ORDER BY source_offset DESC LIMIT 1) last_summary
186
+ FROM subagents s WHERE s.agent_kind='codex' AND datetime(COALESCE(s.last_activity_at,s.started_at)) >= datetime('now', ?)""", (window,)):
187
+ key = ("codex", a["agent_id"])
188
+ s = sessions.setdefault(key, {"agent": "codex", "session_id": a["agent_id"], "project": a["project"], "last_ts": a["last_activity_at"],
189
+ "last_tool": a["last_tool"], "last_summary": a["last_summary"] or "", "tools_10m": 0, "tokens_10m": 0, "model": a["model"]})
190
+ s["last_tool"], s["last_summary"], s["model"] = a["last_tool"] or s["last_tool"], a["last_summary"] or s["last_summary"], a["model"] or s["model"]
191
+ now = _now()
192
+ out = []
193
+ for s in sessions.values():
194
+ last = parse_ts(s["last_ts"])
195
+ s["last_ts"] = _iso(last)
196
+ s["age_s"] = int((now - last).total_seconds()) if last else None
197
+ s["project_name"] = basename(s["project"])
198
+ out.append(s)
199
+ out.sort(key=lambda s: s["age_s"] if s["age_s"] is not None else 1e9)
200
+ return out
201
+
202
+
203
+ # ---------------------------------------------------------------- activity feed
204
+
205
+ def activity(conn, limit: int = FEED_LEN) -> list[dict[str, Any]]:
206
+ rows = _rows(conn, """SELECT id, agent, session_id, project, ts, kind, name, summary FROM activity_events
207
+ WHERE ts >= ? ORDER BY datetime(ts) DESC, id DESC LIMIT ?""", (_prefilter(7), limit))
208
+ if len(rows) < limit:
209
+ rows = _rows(conn, "SELECT id, agent, session_id, project, ts, kind, name, summary FROM activity_events "
210
+ "ORDER BY datetime(ts) DESC, id DESC LIMIT ?", (limit,))
211
+ for r in rows:
212
+ r["ts"] = _iso(r["ts"])
213
+ r["project_name"] = basename(r["project"])
214
+ return rows
215
+
216
+
217
+ # ---------------------------------------------------------------- brain graph
218
+
219
+ def graph(conn, sessions: list[dict[str, Any]], clients: list[dict[str, Any]]) -> dict[str, Any]:
220
+ pre = _prefilter(7)
221
+ last: dict[str, datetime] = {}
222
+ counts: dict[str, dict[str, int]] = {}
223
+
224
+ def bump(project, ts, field, n):
225
+ if not project:
226
+ return
227
+ t = parse_ts(ts)
228
+ if t and (project not in last or t > last[project]):
229
+ last[project] = t
230
+ c = counts.setdefault(project, {"activity": 0, "memories": 0})
231
+ c[field] += int(n or 0)
232
+
233
+ for r in _rows(conn, "SELECT project, MAX(datetime(ts)) ts, COUNT(*) n FROM activity_events WHERE ts >= ? AND datetime(ts) >= datetime('now','-7 days') GROUP BY project", (pre,)):
234
+ bump(r["project"], r["ts"], "activity", r["n"])
235
+ for r in _rows(conn, "SELECT project, MAX(ts) ts FROM usage_events GROUP BY project"): # served by usage_project_ts
236
+ bump(r["project"], r["ts"], "activity", 0)
237
+ for r in _rows(conn, "SELECT project, COUNT(*) n FROM usage_events WHERE ts >= ? AND datetime(ts) >= datetime('now','-7 days') GROUP BY project", (pre,)):
238
+ bump(r["project"], None, "activity", r["n"])
239
+ for r in _rows(conn, "SELECT project, MAX(updated_at) ts, COUNT(*) n FROM memories WHERE project IS NOT NULL GROUP BY project"):
240
+ bump(r["project"], r["ts"], "memories", r["n"])
241
+ for s in sessions:
242
+ bump(s["project"], s["last_ts"], "activity", 0)
243
+ ranked = sorted(last, key=lambda p: last[p], reverse=True)[:MAX_PROJECTS]
244
+ chosen = set(ranked)
245
+ mems = _rows(conn, "SELECT id, title, kind, project, created_at FROM memories ORDER BY id DESC LIMIT ?", (MAX_MEMORIES,))
246
+ other = False
247
+ for m in mems:
248
+ m["title"] = (m["title"] or "")[:80]
249
+ if m["project"] is None:
250
+ m["cluster"] = "global"
251
+ elif m["project"] in chosen:
252
+ m["cluster"] = m["project"]
253
+ else:
254
+ m["cluster"], other = "other", True
255
+ m["created_at"] = _iso(m["created_at"])
256
+ memory_ids = {m["id"] for m in mems}
257
+ links = [dict(r) for r in conn.execute("SELECT from_id,to_id,type FROM memory_links ORDER BY type,from_id,to_id").fetchall()
258
+ if r["from_id"] in memory_ids and r["to_id"] in memory_ids]
259
+ projects = [{"id": p, "name": basename(p), "path": p, "last_activity": _iso(last[p]), **counts.get(p, {"activity": 0, "memories": 0})}
260
+ for p in ranked]
261
+ if other:
262
+ projects.append({"id": "other", "name": "other", "path": None, "last_activity": None, "activity": 0,
263
+ "memories": sum(1 for m in mems if m["cluster"] == "other")})
264
+ agents = []
265
+ for s in sessions[:10]:
266
+ agents.append({"id": f"{s['agent']}:{s['session_id']}", "agent": s["agent"], "session_id": s["session_id"],
267
+ "project": s["project"] if s["project"] in chosen else None, "project_name": s["project_name"],
268
+ "active": s["age_s"] is not None and s["age_s"] < 120, "connected": False})
269
+ covered = {(agent_family(a["agent"]), a["project"]) for a in agents}
270
+ for c in clients:
271
+ if (c["family"], c["project"]) in covered:
272
+ for a in agents:
273
+ if agent_family(a["agent"]) == c["family"] and a["project"] == c["project"]:
274
+ a["connected"] = True
275
+ continue
276
+ covered.add((c["family"], c["project"]))
277
+ agents.append({"id": f"mcp:{c['id']}", "agent": c["family"], "session_id": None,
278
+ "project": c["project"] if c["project"] in chosen else None, "project_name": c["project_name"],
279
+ "active": False, "connected": True})
280
+ return {"projects": projects, "memories": mems, "agents": agents, "links": links}
281
+
282
+
283
+ def graph_signature(g: dict[str, Any]) -> str:
284
+ """Changes when nodes or edges change (not on per-project counters), so clients re-layout only when needed."""
285
+ key = [[p["id"] for p in g["projects"]], [(m["id"], m["cluster"], m["kind"]) for m in g["memories"]],
286
+ [(a["id"], a["project"], a["active"], a["connected"]) for a in g["agents"]],
287
+ [(l["from_id"], l["to_id"], l["type"]) for l in g.get("links", [])]]
288
+ return hashlib.sha1(json.dumps(key, default=str).encode()).hexdigest()[:16]
289
+
290
+
291
+ # ---------------------------------------------------------------- snapshot and pulses
292
+
293
+ def memory_counts(conn) -> dict[str, Any]:
294
+ by_kind = {r["kind"]: r["n"] for r in _rows(conn, "SELECT kind, COUNT(*) n FROM memories GROUP BY kind")}
295
+ return {"memories_by_kind": by_kind, "memories_total": sum(by_kind.values())}
296
+
297
+
298
+ def reminders(conn) -> list[dict[str, Any]]:
299
+ """Open reminder-tagged memories, newest first, for the Brain panel."""
300
+ rows = _rows(conn, f"""SELECT id,title,content,created_at,tags FROM memories m
301
+ WHERE {not_superseded_sql()} ORDER BY datetime(created_at) DESC, id DESC""")
302
+ return [{"id": row["id"], "title": row["title"] or row["content"][:80], "created_at": _iso(row["created_at"])}
303
+ for row in rows if "remind" in re.split(r"[\s,]+", (row["tags"] or "").lower())]
304
+
305
+
306
+ def cursor(conn) -> dict[str, int]:
307
+ one = lambda t: conn.execute(f"SELECT COALESCE(MAX(id),0) FROM {t}").fetchone()[0]
308
+ return {"activity": one("activity_events"), "usage": one("usage_events"), "memory": one("memories")}
309
+
310
+
311
+ def build_state(conn) -> dict[str, Any]:
312
+ started = time.monotonic()
313
+ clients = connected_clients(conn)
314
+ sessions = recent_sessions(conn)
315
+ g = graph(conn, sessions, clients)
316
+ state = {
317
+ "now": _iso(_now()), "schema_version": SCHEMA_VERSION,
318
+ "limits": limits(conn), "tokens": tokens(conn),
319
+ "agents": {"connected": clients, "sessions": sessions,
320
+ "subagents": messages.session_subagents(conn, [s["session_id"] for s in sessions if s["agent"].startswith("claude")])},
321
+ "activity": activity(conn), "graph": g, "graph_sig": graph_signature(g),
322
+ "counts": memory_counts(conn), "reminders": reminders(conn), "cursor": cursor(conn),
323
+ }
324
+ state["build_ms"] = int((time.monotonic() - started) * 1000)
325
+ return state
326
+
327
+
328
+ def pulses_since(conn, since: dict[str, int]) -> tuple[list[dict[str, Any]], dict[str, int]]:
329
+ """Discrete events newer than the cursor (by id) and no older than 10 minutes, for front-end animation.
330
+
331
+ The age filter keeps a first import of historical logs from replaying as a storm of pulses.
332
+ """
333
+ window = f"-{PULSE_MAX_AGE_S} seconds"
334
+ out: list[dict[str, Any]] = []
335
+ new = dict(since)
336
+ acts = _rows(conn, """SELECT id, agent, session_id, project, ts, kind, name, summary FROM activity_events
337
+ WHERE id > ? ORDER BY id LIMIT 500""", (since.get("activity", 0),))
338
+ if acts:
339
+ new["activity"] = acts[-1]["id"]
340
+ now = _now()
341
+ for a in acts:
342
+ t = parse_ts(a["ts"])
343
+ if t and (now - t).total_seconds() <= PULSE_MAX_AGE_S:
344
+ out.append({"type": a["kind"], "agent": a["agent"], "session_id": a["session_id"], "project": a["project"],
345
+ "project_name": basename(a["project"]), "name": a["name"], "summary": a["summary"], "ts": _iso(t), "id": a["id"]})
346
+ usage = _rows(conn, """SELECT MAX(id) id, agent, session_id, project, MAX(ts) ts, SUM(input_tokens+output_tokens+cache_read_tokens+cache_write_tokens) tokens
347
+ FROM usage_events WHERE id > ? AND datetime(ts) >= datetime('now', ?) GROUP BY agent, session_id, project""", (since.get("usage", 0), window))
348
+ new["usage"] = max(new.get("usage", 0), conn.execute("SELECT COALESCE(MAX(id),0) FROM usage_events").fetchone()[0])
349
+ for u in usage:
350
+ out.append({"type": "usage", "agent": u["agent"], "session_id": u["session_id"], "project": u["project"],
351
+ "project_name": basename(u["project"]), "tokens": int(u["tokens"] or 0), "ts": _iso(u["ts"])})
352
+ mems = _rows(conn, "SELECT id, title, kind, project, created_at, source_agent FROM memories WHERE id > ? ORDER BY id LIMIT 100", (since.get("memory", 0),))
353
+ if mems:
354
+ new["memory"] = mems[-1]["id"]
355
+ for m in mems:
356
+ out.append({"type": "memory", "id": m["id"], "title": (m["title"] or "")[:80], "kind": m["kind"], "project": m["project"],
357
+ "project_name": basename(m["project"]), "agent": m["source_agent"], "ts": _iso(m["created_at"])})
358
+ return out[-80:], new
@@ -0,0 +1,27 @@
1
+ *, *::before, *::after { box-sizing: border-box; }
2
+ html, body { overflow-x: clip; }
3
+ html { scrollbar-color: var(--color-muted) var(--color-paper); scroll-padding-top: var(--space-3xl); }
4
+ body { margin: 0; background: var(--color-paper); color: var(--color-ink); font-family: var(--font-body); font-size: var(--text-md); line-height: 1.55; -webkit-font-smoothing: antialiased; caret-color: var(--color-accent); }
5
+ ::selection { background: color-mix(in oklab, var(--color-accent) 35%, transparent); color: var(--color-ink); }
6
+ :focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
7
+ a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 0.16em; }
8
+ h1, h2, h3 { margin: 0; font-family: var(--font-display); font-weight: var(--display-weight, 700); font-synthesis: none; letter-spacing: var(--tracking-display); line-height: 1.05; overflow-wrap: break-word; min-width: 0; text-wrap: balance; }
9
+ h1 { max-width: 20ch; font-size: var(--text-display); } h2 { max-width: 30ch; font-size: var(--text-section); } h3 { max-width: 40ch; font-size: var(--text-title); line-height: 1.2; }
10
+ .lead { font-size: var(--text-lead); line-height: 1.45; max-width: 52ch; }
11
+ p { margin: 0; text-wrap: pretty; }
12
+ p:not([class]) { max-width: 65ch; }
13
+ img, video { max-width: 100%; height: auto; display: block; }
14
+ svg { display: block; }
15
+ [hidden] { display: none !important; }
16
+ button, input, select, textarea { font: inherit; }
17
+ .container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); box-sizing: border-box; }
18
+ .num, .figure, td.num { font-variant-numeric: tabular-nums; }
19
+ .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
20
+ [data-slot] { position: relative; overflow: hidden; background: linear-gradient(160deg, var(--color-paper-2, var(--color-paper)), var(--color-paper-3, var(--color-paper))); }
21
+ [data-slot] > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1; }
22
+ [data-slot]:has(> img) > :not(img):not([data-keep]) { display: none !important; }
23
+ @media (max-width: 60rem) { :root { --gutter: 24px; --section-gap-s: 80px; --section-gap: 96px; --section-gap-l: 120px; } }
24
+ @media (max-width: 40rem) { :root { --gutter: 16px; --section-gap-s: 56px; --section-gap: 72px; --section-gap-l: 96px; --gap-columns: 16px; } }
25
+ @media (prefers-reduced-motion: reduce) { *:not(.keep-motion), *:not(.keep-motion)::before, *:not(.keep-motion)::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } }
26
+ /* photographs: natural sat=-20 con=10 */
27
+ [data-slot] > img { filter: saturate(0.8) contrast(1.1); }