argus-code 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 (86) hide show
  1. argus/__init__.py +3 -0
  2. argus/adapters/__init__.py +7 -0
  3. argus/adapters/base.py +108 -0
  4. argus/adapters/claude_code/__init__.py +5 -0
  5. argus/adapters/claude_code/adapter.py +63 -0
  6. argus/adapters/claude_code/discover.py +72 -0
  7. argus/adapters/claude_code/extract_tool_calls.py +86 -0
  8. argus/adapters/claude_code/extract_transcript.py +111 -0
  9. argus/adapters/claude_code/extract_turns.py +69 -0
  10. argus/adapters/claude_code/history_jsonl.py +138 -0
  11. argus/adapters/claude_code/ingest_file.py +137 -0
  12. argus/adapters/claude_code/model.py +11 -0
  13. argus/adapters/claude_code/schemas.py +77 -0
  14. argus/adapters/registry.py +30 -0
  15. argus/cli.py +384 -0
  16. argus/collector/__init__.py +0 -0
  17. argus/collector/aggregate.py +102 -0
  18. argus/collector/first_run.py +189 -0
  19. argus/collector/pipeline.py +140 -0
  20. argus/collector/rollup_subagents.py +27 -0
  21. argus/collector/scheduler.py +89 -0
  22. argus/collector/search_backfill.py +109 -0
  23. argus/collector/watcher.py +178 -0
  24. argus/dashboard-dist/_astro/charts.BIevw6Es.js +1 -0
  25. argus/dashboard-dist/_astro/format.DxC1NGYT.js +1 -0
  26. argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.CgwSARdD.js +24 -0
  27. argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.W18SJsr7.js +11 -0
  28. argus/dashboard-dist/_astro/installCanvasRenderer.D_tC6TXz.js +18 -0
  29. argus/dashboard-dist/_astro/models.astro_astro_type_script_index_0_lang.BHTHXYHC.js +13 -0
  30. argus/dashboard-dist/_astro/prompts.astro_astro_type_script_index_0_lang.DfNgiDv9.js +17 -0
  31. argus/dashboard-dist/_astro/session.astro_astro_type_script_index_0_lang.Dj_bfrIa.js +86 -0
  32. argus/dashboard-dist/_astro/settings.astro_astro_type_script_index_0_lang.d_a-uvdi.js +24 -0
  33. argus/dashboard-dist/_astro/tools.astro_astro_type_script_index_0_lang.Dzzau3Yt.js +12 -0
  34. argus/dashboard-dist/_astro/trends.astro_astro_type_script_index_0_lang.BLLeGRNa.js +5 -0
  35. argus/dashboard-dist/index.html +2 -0
  36. argus/dashboard-dist/models/index.html +1 -0
  37. argus/dashboard-dist/prompts/index.html +18 -0
  38. argus/dashboard-dist/session/index.html +2 -0
  39. argus/dashboard-dist/sessions/index.html +1 -0
  40. argus/dashboard-dist/settings/index.html +8 -0
  41. argus/dashboard-dist/styles/global.css +307 -0
  42. argus/dashboard-dist/tools/index.html +1 -0
  43. argus/dashboard-dist/trends/index.html +1 -0
  44. argus/detectors/__init__.py +6 -0
  45. argus/detectors/base.py +34 -0
  46. argus/detectors/registry.py +20 -0
  47. argus/detectors/tool_error_rate_spike.py +138 -0
  48. argus/pricing/2026-05-02.json +24 -0
  49. argus/pricing/__init__.py +0 -0
  50. argus/pricing/compute.py +46 -0
  51. argus/pricing/load.py +45 -0
  52. argus/pricing/refresh.py +91 -0
  53. argus/pricing/types.py +21 -0
  54. argus/scaffold/__init__.py +0 -0
  55. argus/scaffold/scaffolder.py +45 -0
  56. argus/scaffold/snapshot.py +73 -0
  57. argus/scaffold/storage.py +60 -0
  58. argus/schema/__init__.py +0 -0
  59. argus/schema/types.py +157 -0
  60. argus/server/__init__.py +0 -0
  61. argus/server/api.py +661 -0
  62. argus/server/app.py +97 -0
  63. argus/store/__init__.py +0 -0
  64. argus/store/db.py +103 -0
  65. argus/store/migrations/__init__.py +0 -0
  66. argus/store/migrations/inline.py +180 -0
  67. argus/store/repository.py +778 -0
  68. argus/templates/default/.claude/agents/code-reviewer.md +27 -0
  69. argus/templates/default/.claude/agents/security-auditor.md +28 -0
  70. argus/templates/default/.claude/commands/commit.md +38 -0
  71. argus/templates/default/.claude/commands/deploy.md +13 -0
  72. argus/templates/default/.claude/commands/fix-issue.md +15 -0
  73. argus/templates/default/.claude/commands/pr.md +38 -0
  74. argus/templates/default/.claude/commands/review.md +14 -0
  75. argus/templates/default/.claude/rules/api-conventions.md +27 -0
  76. argus/templates/default/.claude/rules/code-style.md +25 -0
  77. argus/templates/default/.claude/rules/testing.md +19 -0
  78. argus/templates/default/.claude/settings.json +28 -0
  79. argus/templates/default/.claude/skills/example/SKILL.md +11 -0
  80. argus/templates/default/CLAUDE.md +57 -0
  81. argus_code-0.2.0.dist-info/METADATA +247 -0
  82. argus_code-0.2.0.dist-info/RECORD +86 -0
  83. argus_code-0.2.0.dist-info/WHEEL +4 -0
  84. argus_code-0.2.0.dist-info/entry_points.txt +2 -0
  85. argus_code-0.2.0.dist-info/licenses/LICENSE +21 -0
  86. argus_code-0.2.0.dist-info/licenses/NOTICE +22 -0
argus/server/api.py ADDED
@@ -0,0 +1,661 @@
1
+ """All ``/api/*`` route handlers.
2
+
3
+ Direct port of src/server/api.ts. Same response shapes (the dashboard
4
+ consumes JSON keys verbatim).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta, timezone
11
+ from typing import Any, Callable
12
+
13
+ from fastapi import APIRouter, HTTPException, Request, Response
14
+ from fastapi.responses import JSONResponse, PlainTextResponse
15
+
16
+ from ..adapters.base import Adapter
17
+ from ..collector.first_run import IngestStatus
18
+ from ..collector.search_backfill import (
19
+ get_search_backfill_status,
20
+ run_segment_backfill,
21
+ )
22
+ from ..pricing.types import PricingTable
23
+ from ..schema.types import Session
24
+ from ..store.repository import Repository
25
+
26
+ _WINDOWS: dict[str, int | None] = {"today": 1, "7d": 7, "30d": 30, "all": None}
27
+
28
+
29
+ def _is_top_level(sid: str) -> bool:
30
+ return "/" not in sid
31
+
32
+
33
+ def _is_meaningful(s: Session) -> bool:
34
+ return (
35
+ s.turn_count > 0
36
+ or s.total_cost_usd > 0
37
+ or (
38
+ s.total_fresh_input_tokens
39
+ + s.total_output_tokens
40
+ + s.total_cache_read_tokens
41
+ + s.total_cache_write_tokens
42
+ > 0
43
+ )
44
+ )
45
+
46
+
47
+ def _week_of(iso: str) -> str:
48
+ """Match the TS weekOf() byte-for-byte.
49
+
50
+ Two subtleties relative to a naive Python port of the original JS:
51
+
52
+ 1. Day-of-week numbering. Python's ``datetime.weekday()`` numbers days
53
+ as Mon=0..Sun=6, but JS's ``Date.getUTCDay()`` is Sun=0..Sat=6.
54
+ Using ``weekday()`` directly would silently shift the week number
55
+ by one for any year whose Jan 1 isn't aligned in a specific way
56
+ (e.g., 2024-01-07 falls in week 1 instead of week 2). The
57
+ ``(weekday() + 1) % 7`` adjustment converts to JS's numbering.
58
+
59
+ 2. Input shape. Callers pass either a full ISO timestamp ending in
60
+ ``Z`` (when this helper is exercised in tests) or a date-only
61
+ string like ``"2026-05-22"`` (the production path — that's what
62
+ ``substr(timestamp, 1, 10)`` returns from
63
+ ``aggregate_turns_by_day``). The date-only form parses as a
64
+ naive ``datetime``; we promote it to UTC before subtracting from
65
+ the aware ``start`` so the math doesn't raise ``TypeError:
66
+ can't subtract offset-naive and offset-aware datetimes``.
67
+ """
68
+ d = datetime.fromisoformat(iso.replace("Z", "+00:00"))
69
+ if d.tzinfo is None:
70
+ d = d.replace(tzinfo=timezone.utc)
71
+ start = datetime(d.year, 1, 1, tzinfo=timezone.utc)
72
+ diff = (d - start).total_seconds() / 86_400
73
+ js_day_of_week = (start.weekday() + 1) % 7
74
+ return f"{int((diff + js_day_of_week) // 7) + 1:02d}"
75
+
76
+
77
+ def _cutoff_iso_for_window(window: str) -> str:
78
+ days = _WINDOWS.get(window)
79
+ if days is None:
80
+ return ""
81
+ return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat().replace(
82
+ "+00:00", "Z"
83
+ )
84
+
85
+
86
+ def _mcp_server_name(tool_name: str) -> str | None:
87
+ if not tool_name.startswith("mcp__"):
88
+ return None
89
+ rest = tool_name[len("mcp__") :]
90
+ sep = rest.find("__")
91
+ if sep < 0:
92
+ return None
93
+ return rest[:sep]
94
+
95
+
96
+ def is_allowed_origin(origin: str | None) -> bool:
97
+ if not origin:
98
+ return False
99
+ return (
100
+ origin.startswith("http://localhost:")
101
+ or origin.startswith("http://127.0.0.1:")
102
+ or origin.startswith("http://[::1]:")
103
+ )
104
+
105
+
106
+ @dataclass
107
+ class ApiDeps:
108
+ pricing_table_version: str
109
+ ingest_status: Callable[[], IngestStatus]
110
+ adapters: list[Adapter]
111
+ pricing_table: PricingTable
112
+
113
+
114
+ def build_api(repo: Repository, deps: ApiDeps) -> APIRouter:
115
+ """Build the ``/api/*`` router."""
116
+ api = APIRouter()
117
+
118
+ @api.get("/api/sessions")
119
+ def list_sessions(
120
+ limit: int = 100, offset: int = 0,
121
+ agent: str | None = None, includeSub: bool = False,
122
+ ) -> dict[str, Any]:
123
+ sessions = [
124
+ s.model_dump()
125
+ for s in repo.list_sessions(limit=limit, offset=offset, agent=agent)
126
+ if (includeSub or _is_top_level(s.id)) and _is_meaningful(s)
127
+ ]
128
+ return {"sessions": sessions}
129
+
130
+ @api.get("/api/sessions/{session_id}")
131
+ def get_session(session_id: str) -> dict[str, Any]:
132
+ session = repo.get_session(session_id)
133
+ if session is None:
134
+ raise HTTPException(status_code=404, detail="not found")
135
+ turns = [t.model_dump() for t in repo.get_turns_for_session(session_id)]
136
+ return {"session": session.model_dump(), "turns": turns}
137
+
138
+ @api.get("/api/overview")
139
+ def get_overview(window: str = "7d") -> dict[str, Any]:
140
+ cutoff = _cutoff_iso_for_window(window)
141
+ rows = repo.aggregate_turns_by_day(cutoff)
142
+ session_meta: dict[str, Session] = {
143
+ s.id: s for s in repo.list_sessions(limit=100_000)
144
+ }
145
+
146
+ total_cost = 0.0
147
+ total_tokens = 0
148
+ by_day: dict[str, float] = {}
149
+ cost_by_model: dict[str, float] = {}
150
+ split: dict[str, dict[str, Any]] = {}
151
+ sessions_per_agent: dict[str, set[str]] = {}
152
+ active_sessions: set[str] = set()
153
+ session_window: dict[str, dict[str, Any]] = {}
154
+
155
+ for r in rows:
156
+ tokens = r["fresh_input"] + r["output"] + r["cache_read"] + r["cache_write"]
157
+ total_cost += r["cost"]
158
+ total_tokens += tokens
159
+ by_day[r["day"]] = by_day.get(r["day"], 0.0) + r["cost"]
160
+ cost_by_model[r["model"]] = cost_by_model.get(r["model"], 0.0) + r["cost"]
161
+ active_sessions.add(r["session_id"])
162
+ agent = session_meta.get(r["session_id"]).agent if r["session_id"] in session_meta else "unknown"
163
+ split.setdefault(agent, {"cost": 0.0, "sessions": 0, "tokens": 0})
164
+ split[agent]["cost"] += r["cost"]
165
+ split[agent]["tokens"] += tokens
166
+ sessions_per_agent.setdefault(agent, set()).add(r["session_id"])
167
+
168
+ sw = session_window.setdefault(
169
+ r["session_id"], {"cost": 0.0, "tokens": 0, "days": set()}
170
+ )
171
+ sw["cost"] += r["cost"]
172
+ sw["tokens"] += tokens
173
+ sw["days"].add(r["day"])
174
+
175
+ for agent, ids in sessions_per_agent.items():
176
+ split[agent]["sessions"] = len(ids)
177
+
178
+ top_sessions: list[dict[str, Any]] = []
179
+ for sid, w in session_window.items():
180
+ meta = session_meta.get(sid)
181
+ if meta is None:
182
+ continue
183
+ top_sessions.append(
184
+ {
185
+ "id": sid,
186
+ "started_at": meta.started_at,
187
+ "project_path": meta.project_path,
188
+ "primary_model": meta.primary_model,
189
+ "window_cost_usd": w["cost"],
190
+ "window_tokens": w["tokens"],
191
+ "days_active": len(w["days"]),
192
+ }
193
+ )
194
+ top_sessions.sort(key=lambda x: x["window_cost_usd"], reverse=True)
195
+ top_sessions = top_sessions[:20]
196
+
197
+ return {
198
+ "window": window,
199
+ "total_cost_usd": total_cost,
200
+ "total_tokens": total_tokens,
201
+ "session_count": len(active_sessions),
202
+ "agent_split": split,
203
+ "cost_by_day": by_day,
204
+ "cost_by_model": cost_by_model,
205
+ "top_sessions": top_sessions,
206
+ }
207
+
208
+ @api.get("/api/trends")
209
+ def get_trends(
210
+ granularity: str = "day", groupBy: str = "agent" # noqa: N803
211
+ ) -> dict[str, Any]:
212
+ rows = repo.aggregate_turns_by_day("")
213
+ session_agent: dict[str, str] = {
214
+ s.id: s.agent for s in repo.list_sessions(limit=100_000)
215
+ }
216
+
217
+ def rebucket(day: str) -> str:
218
+ if granularity == "day":
219
+ return day
220
+ if granularity == "week":
221
+ return f"{day[:4]}-W{_week_of(day)}"
222
+ return day[:7]
223
+
224
+ points: dict[str, dict[str, dict[str, Any]]] = {}
225
+ for r in rows:
226
+ b = rebucket(r["day"])
227
+ k = (
228
+ session_agent.get(r["session_id"], "unknown")
229
+ if groupBy == "agent"
230
+ else r["model"]
231
+ )
232
+ bucket = points.setdefault(b, {})
233
+ group = bucket.setdefault(
234
+ k, {"cost": 0.0, "tokens": 0, "sessions": 0, "_session_set": set()}
235
+ )
236
+ group["cost"] += r["cost"]
237
+ group["tokens"] += r["fresh_input"] + r["output"]
238
+ group["_session_set"].add(r["session_id"])
239
+
240
+ for groups in points.values():
241
+ for k in list(groups.keys()):
242
+ groups[k]["sessions"] = len(groups[k]["_session_set"])
243
+ del groups[k]["_session_set"]
244
+
245
+ return {
246
+ "granularity": granularity,
247
+ "groupBy": groupBy,
248
+ "points": [
249
+ {"bucket": b, "groups": g} for b, g in sorted(points.items())
250
+ ],
251
+ }
252
+
253
+ @api.get("/api/tools/overview")
254
+ def tools_overview(window: str = "7d") -> dict[str, Any]:
255
+ cutoff = _cutoff_iso_for_window(window)
256
+ totals = repo.tool_calls_total(cutoff)
257
+ leaderboard_raw = repo.tool_leaderboard(cutoff, 20)
258
+ leaderboard = [
259
+ {
260
+ "name": r["name"],
261
+ "calls": r["calls"],
262
+ "errors": r["errors"] or 0,
263
+ "error_rate": (
264
+ round(((r["errors"] or 0) / r["calls"]) * 100) / 100
265
+ if r["calls"] > 0
266
+ else 0
267
+ ),
268
+ }
269
+ for r in leaderboard_raw
270
+ ]
271
+
272
+ mcp_rows = repo.mcp_tool_calls(cutoff)
273
+ mcp_agg: dict[str, dict[str, Any]] = {}
274
+ for row in mcp_rows:
275
+ server = _mcp_server_name(row["tool_name"])
276
+ if server is None:
277
+ continue
278
+ cur = mcp_agg.setdefault(
279
+ server, {"calls": 0, "errors": 0, "tools": set()}
280
+ )
281
+ cur["calls"] += row["calls"]
282
+ cur["errors"] += row["errors"] or 0
283
+ cur["tools"].add(row["tool_name"])
284
+ mcp_servers = [
285
+ {
286
+ "server": server,
287
+ "calls": v["calls"],
288
+ "errors": v["errors"],
289
+ "tools_used": len(v["tools"]),
290
+ }
291
+ for server, v in mcp_agg.items()
292
+ ]
293
+ mcp_servers.sort(key=lambda x: x["calls"], reverse=True)
294
+
295
+ subagents = [
296
+ {"type": r["type"], "calls": r["calls"], "errors": r["errors"] or 0}
297
+ for r in repo.subagent_calls(cutoff)
298
+ ]
299
+
300
+ return {
301
+ "window": window,
302
+ "total_calls": totals["total"],
303
+ "total_errors": totals["errors"],
304
+ "tool_leaderboard": leaderboard,
305
+ "mcp_servers": mcp_servers,
306
+ "subagents": subagents,
307
+ }
308
+
309
+ @api.get("/api/alerts")
310
+ def list_alerts(limit: int = 50) -> dict[str, Any]:
311
+ alerts = repo.list_alerts(limit=min(limit, 200))
312
+ return {"alerts": [a.model_dump() for a in alerts]}
313
+
314
+ @api.get("/api/alerts/unseen")
315
+ def list_unseen_alerts(severity: str | None = None) -> dict[str, Any]:
316
+ if severity is not None and severity not in ("info", "warning", "critical"):
317
+ raise HTTPException(status_code=400, detail="invalid severity")
318
+ alerts = repo.list_unseen_alerts(severity=severity)
319
+ return {"alerts": [a.model_dump() for a in alerts]}
320
+
321
+ @api.post("/api/alerts/{alert_id}/seen")
322
+ def mark_alert_seen(alert_id: int) -> dict[str, Any]:
323
+ if not repo.mark_alert_seen(alert_id):
324
+ raise HTTPException(status_code=404, detail="not found")
325
+ return {"ok": True}
326
+
327
+ @api.get("/api/prompts")
328
+ def prompts(
329
+ q: str = "",
330
+ limit: int = 50,
331
+ project: str | None = None,
332
+ include_slash: str = "0",
333
+ ) -> dict[str, Any]:
334
+ limit = min(limit, 200)
335
+ if not repo.is_search_indexing_enabled():
336
+ return {"total": 0, "prompts": []}
337
+
338
+ try:
339
+ result = repo.search_prompts(
340
+ q=q.strip(),
341
+ limit=limit,
342
+ project=project,
343
+ include_slash=(include_slash == "1"),
344
+ )
345
+ except Exception:
346
+ escaped = q.replace('"', '""')
347
+ try:
348
+ result = repo.search_prompts(
349
+ q=f'"{escaped}"',
350
+ limit=limit,
351
+ project=project,
352
+ include_slash=(include_slash == "1"),
353
+ )
354
+ except Exception:
355
+ result = {"total": 0, "rows": []}
356
+
357
+ prompts_out = []
358
+ for r in result["rows"]:
359
+ prompts_out.append(
360
+ {
361
+ "id": r["id"],
362
+ "timestamp_ms": r["timestamp_ms"],
363
+ "project_path": r["project_path"],
364
+ "display": r["display"],
365
+ "snippet": r.get("snippet") or r["display"],
366
+ "pasted_chars": r["pasted_chars"],
367
+ "session_id": repo.link_prompt_to_session(
368
+ r["project_path"], r["timestamp_ms"]
369
+ ),
370
+ }
371
+ )
372
+ return {"total": result["total"], "prompts": prompts_out}
373
+
374
+ @api.get("/api/prompts/stats")
375
+ def prompts_stats() -> dict[str, Any]:
376
+ if not repo.is_search_indexing_enabled():
377
+ return {"total": 0, "projects": 0, "oldest_ms": None}
378
+ return repo.prompt_stats()
379
+
380
+ @api.get("/api/prompts/projects")
381
+ def prompts_projects() -> dict[str, Any]:
382
+ if not repo.is_search_indexing_enabled():
383
+ return {"projects": []}
384
+ return {"projects": repo.prompt_projects()}
385
+
386
+ @api.get("/api/search")
387
+ def search(
388
+ q: str = "",
389
+ limit: int = 50,
390
+ project: str | None = None,
391
+ include_slash: str = "0",
392
+ roles: str | None = None,
393
+ ) -> dict[str, Any]:
394
+ limit = min(limit, 200)
395
+ wanted_roles = [r for r in (roles.split(",") if roles else []) if r] or None
396
+ include_prompts = wanted_roles is None or "prompt" in wanted_roles
397
+ transcript_roles = (
398
+ [r for r in wanted_roles if r != "prompt"]
399
+ if wanted_roles
400
+ else ["user", "assistant", "thinking", "tool_result"]
401
+ )
402
+ search_enabled = repo.is_search_indexing_enabled()
403
+
404
+ if not search_enabled:
405
+ return {
406
+ "total": 0,
407
+ "prompt_total": 0,
408
+ "transcript_total": 0,
409
+ "search_indexing_enabled": False,
410
+ "results": [],
411
+ }
412
+
413
+ results: list[dict[str, Any]] = []
414
+ prompt_total = 0
415
+ transcript_total = 0
416
+
417
+ if include_prompts:
418
+ try:
419
+ pr = repo.search_prompts(
420
+ q=q.strip(),
421
+ limit=limit,
422
+ project=project,
423
+ include_slash=(include_slash == "1"),
424
+ )
425
+ except Exception:
426
+ escaped = q.replace('"', '""')
427
+ try:
428
+ pr = repo.search_prompts(
429
+ q=f'"{escaped}"',
430
+ limit=limit,
431
+ project=project,
432
+ include_slash=(include_slash == "1"),
433
+ )
434
+ except Exception:
435
+ pr = {"total": 0, "rows": []}
436
+ prompt_total = pr["total"]
437
+ for r in pr["rows"]:
438
+ results.append(
439
+ {
440
+ "kind": "prompt",
441
+ "role": "prompt",
442
+ "timestamp_ms": r["timestamp_ms"],
443
+ "project_path": r["project_path"],
444
+ "text": r["display"],
445
+ "snippet": r.get("snippet") or r["display"],
446
+ "pasted_chars": r["pasted_chars"],
447
+ "session_id": repo.link_prompt_to_session(
448
+ r["project_path"], r["timestamp_ms"]
449
+ ),
450
+ }
451
+ )
452
+
453
+ if q and transcript_roles:
454
+ try:
455
+ tr = repo.search_transcripts(
456
+ q=q.strip(), limit=limit, project=project, roles=transcript_roles
457
+ )
458
+ except Exception:
459
+ escaped = q.replace('"', '""')
460
+ try:
461
+ tr = repo.search_transcripts(
462
+ q=f'"{escaped}"',
463
+ limit=limit,
464
+ project=project,
465
+ roles=transcript_roles,
466
+ )
467
+ except Exception:
468
+ tr = {"total": 0, "rows": []}
469
+ transcript_total = tr["total"]
470
+ for r in tr["rows"]:
471
+ try:
472
+ ts = int(
473
+ datetime.fromisoformat(
474
+ r["timestamp"].replace("Z", "+00:00")
475
+ ).timestamp()
476
+ * 1000
477
+ )
478
+ except (ValueError, TypeError):
479
+ ts = 0
480
+ results.append(
481
+ {
482
+ "kind": "transcript",
483
+ "role": r["role"],
484
+ "timestamp_ms": ts,
485
+ "project_path": r["project_path"],
486
+ "text": r["text"],
487
+ "snippet": r["snippet"],
488
+ "pasted_chars": 0,
489
+ "session_id": r["session_id"],
490
+ }
491
+ )
492
+
493
+ if not q:
494
+ results.sort(key=lambda x: x["timestamp_ms"], reverse=True)
495
+
496
+ return {
497
+ "total": prompt_total + transcript_total,
498
+ "prompt_total": prompt_total,
499
+ "transcript_total": transcript_total,
500
+ "search_indexing_enabled": search_enabled,
501
+ "results": results[:limit],
502
+ }
503
+
504
+ @api.get("/api/sessions/{session_id}/transcript")
505
+ def session_transcript(session_id: str, q: str = "", limit: int = 100) -> dict[str, Any]:
506
+ limit = min(limit, 500)
507
+ q = q.strip()
508
+ if not q:
509
+ return {
510
+ "total": 0,
511
+ "segments": [],
512
+ "search_indexing_enabled": repo.is_search_indexing_enabled(),
513
+ }
514
+ if not repo.is_search_indexing_enabled():
515
+ return {"total": 0, "segments": [], "search_indexing_enabled": False}
516
+ try:
517
+ r = repo.search_transcripts(q=q, limit=limit, session_id=session_id)
518
+ except Exception:
519
+ escaped = q.replace('"', '""')
520
+ try:
521
+ r = repo.search_transcripts(
522
+ q=f'"{escaped}"', limit=limit, session_id=session_id
523
+ )
524
+ except Exception:
525
+ r = {"total": 0, "rows": []}
526
+ segments = [
527
+ {
528
+ "uid": row["uid"],
529
+ "timestamp": row["timestamp"],
530
+ "role": row["role"],
531
+ "text": row["text"],
532
+ "snippet": row["snippet"],
533
+ }
534
+ for row in r["rows"]
535
+ ]
536
+ return {"total": r["total"], "segments": segments}
537
+
538
+ @api.get("/api/search-index/status")
539
+ def search_index_status() -> dict[str, Any]:
540
+ enabled = repo.is_search_indexing_enabled()
541
+ segs = repo.segment_stats()
542
+ bf = get_search_backfill_status()
543
+ return {
544
+ "enabled": enabled,
545
+ "segment_count": segs["total"],
546
+ "indexed_sessions": segs["sessions"],
547
+ "backfill": {
548
+ "in_progress": bf.in_progress,
549
+ "processed": bf.processed,
550
+ "total": bf.total,
551
+ "started_at_ms": bf.started_at_ms,
552
+ "finished_at_ms": bf.finished_at_ms,
553
+ },
554
+ }
555
+
556
+ @api.post("/api/search-index/enable")
557
+ def search_index_enable() -> dict[str, Any]:
558
+ repo.set_search_indexing_enabled(True)
559
+ try:
560
+ run_segment_backfill(deps.adapters, repo, deps.pricing_table)
561
+ except Exception: # noqa: BLE001
562
+ pass
563
+ bf = get_search_backfill_status()
564
+ return {
565
+ "enabled": True,
566
+ "backfill": {
567
+ "in_progress": bf.in_progress,
568
+ "processed": bf.processed,
569
+ "total": bf.total,
570
+ "started_at_ms": bf.started_at_ms,
571
+ "finished_at_ms": bf.finished_at_ms,
572
+ },
573
+ }
574
+
575
+ @api.post("/api/search-index/disable")
576
+ def search_index_disable() -> dict[str, Any]:
577
+ repo.set_search_indexing_enabled(False)
578
+ return {"enabled": False}
579
+
580
+ @api.post("/api/search-index/clear")
581
+ def search_index_clear() -> dict[str, Any]:
582
+ before_bytes = repo.db_size_bytes()
583
+ repo.clear_all_segments()
584
+ repo.set_search_indexing_enabled(False)
585
+ repo.vacuum()
586
+ after_bytes = repo.db_size_bytes()
587
+ return {
588
+ "enabled": False,
589
+ "cleared": True,
590
+ "freed_bytes": max(0, before_bytes - after_bytes),
591
+ "db_size_bytes": after_bytes,
592
+ }
593
+
594
+ @api.get("/api/ingest/status")
595
+ def ingest_status_route() -> dict[str, Any]:
596
+ session_count = sum(
597
+ 1
598
+ for s in repo.list_sessions(limit=100_000)
599
+ if _is_top_level(s.id) and _is_meaningful(s)
600
+ )
601
+ status = deps.ingest_status()
602
+ return {
603
+ "foregroundComplete": status.foreground_complete,
604
+ "pending": status.pending,
605
+ "processed": status.processed,
606
+ "total": status.total,
607
+ "sessionCount": session_count,
608
+ }
609
+
610
+ @api.get("/api/pricing")
611
+ def pricing() -> dict[str, Any]:
612
+ return {"version": deps.pricing_table_version}
613
+
614
+ @api.get("/api/export.json")
615
+ def export_json() -> dict[str, Any]:
616
+ sessions = [
617
+ s.model_dump()
618
+ for s in repo.list_sessions(limit=1_000_000)
619
+ if _is_top_level(s.id) and _is_meaningful(s)
620
+ ]
621
+ return {"sessions": sessions}
622
+
623
+ @api.get("/api/export.csv")
624
+ def export_csv() -> Response:
625
+ rows = [
626
+ s
627
+ for s in repo.list_sessions(limit=1_000_000)
628
+ if _is_top_level(s.id) and _is_meaningful(s)
629
+ ]
630
+ header = (
631
+ "id,agent,started_at,ended_at,project_path,primary_model,"
632
+ "total_cost_usd,total_fresh_input_tokens,total_output_tokens,turn_count\n"
633
+ )
634
+
635
+ def esc(v: Any) -> str:
636
+ return '"' + str(v if v is not None else "").replace('"', '""') + '"'
637
+
638
+ body = "\n".join(
639
+ ",".join(
640
+ [
641
+ esc(s.id),
642
+ esc(s.agent),
643
+ esc(s.started_at),
644
+ esc(s.ended_at or ""),
645
+ esc(s.project_path),
646
+ esc(s.primary_model),
647
+ esc(s.total_cost_usd),
648
+ esc(s.total_fresh_input_tokens),
649
+ esc(s.total_output_tokens),
650
+ esc(s.turn_count),
651
+ ]
652
+ )
653
+ for s in rows
654
+ )
655
+ return PlainTextResponse(header + body, media_type="text/csv")
656
+
657
+ @api.get("/api/parse-errors")
658
+ def parse_errors() -> dict[str, Any]:
659
+ return {"errors": repo.recent_parse_errors(100)}
660
+
661
+ return api