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.
- argus/__init__.py +3 -0
- argus/adapters/__init__.py +7 -0
- argus/adapters/base.py +108 -0
- argus/adapters/claude_code/__init__.py +5 -0
- argus/adapters/claude_code/adapter.py +63 -0
- argus/adapters/claude_code/discover.py +72 -0
- argus/adapters/claude_code/extract_tool_calls.py +86 -0
- argus/adapters/claude_code/extract_transcript.py +111 -0
- argus/adapters/claude_code/extract_turns.py +69 -0
- argus/adapters/claude_code/history_jsonl.py +138 -0
- argus/adapters/claude_code/ingest_file.py +137 -0
- argus/adapters/claude_code/model.py +11 -0
- argus/adapters/claude_code/schemas.py +77 -0
- argus/adapters/registry.py +30 -0
- argus/cli.py +384 -0
- argus/collector/__init__.py +0 -0
- argus/collector/aggregate.py +102 -0
- argus/collector/first_run.py +189 -0
- argus/collector/pipeline.py +140 -0
- argus/collector/rollup_subagents.py +27 -0
- argus/collector/scheduler.py +89 -0
- argus/collector/search_backfill.py +109 -0
- argus/collector/watcher.py +178 -0
- argus/dashboard-dist/_astro/charts.BIevw6Es.js +1 -0
- argus/dashboard-dist/_astro/format.DxC1NGYT.js +1 -0
- argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.CgwSARdD.js +24 -0
- argus/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.W18SJsr7.js +11 -0
- argus/dashboard-dist/_astro/installCanvasRenderer.D_tC6TXz.js +18 -0
- argus/dashboard-dist/_astro/models.astro_astro_type_script_index_0_lang.BHTHXYHC.js +13 -0
- argus/dashboard-dist/_astro/prompts.astro_astro_type_script_index_0_lang.DfNgiDv9.js +17 -0
- argus/dashboard-dist/_astro/session.astro_astro_type_script_index_0_lang.Dj_bfrIa.js +86 -0
- argus/dashboard-dist/_astro/settings.astro_astro_type_script_index_0_lang.d_a-uvdi.js +24 -0
- argus/dashboard-dist/_astro/tools.astro_astro_type_script_index_0_lang.Dzzau3Yt.js +12 -0
- argus/dashboard-dist/_astro/trends.astro_astro_type_script_index_0_lang.BLLeGRNa.js +5 -0
- argus/dashboard-dist/index.html +2 -0
- argus/dashboard-dist/models/index.html +1 -0
- argus/dashboard-dist/prompts/index.html +18 -0
- argus/dashboard-dist/session/index.html +2 -0
- argus/dashboard-dist/sessions/index.html +1 -0
- argus/dashboard-dist/settings/index.html +8 -0
- argus/dashboard-dist/styles/global.css +307 -0
- argus/dashboard-dist/tools/index.html +1 -0
- argus/dashboard-dist/trends/index.html +1 -0
- argus/detectors/__init__.py +6 -0
- argus/detectors/base.py +34 -0
- argus/detectors/registry.py +20 -0
- argus/detectors/tool_error_rate_spike.py +138 -0
- argus/pricing/2026-05-02.json +24 -0
- argus/pricing/__init__.py +0 -0
- argus/pricing/compute.py +46 -0
- argus/pricing/load.py +45 -0
- argus/pricing/refresh.py +91 -0
- argus/pricing/types.py +21 -0
- argus/scaffold/__init__.py +0 -0
- argus/scaffold/scaffolder.py +45 -0
- argus/scaffold/snapshot.py +73 -0
- argus/scaffold/storage.py +60 -0
- argus/schema/__init__.py +0 -0
- argus/schema/types.py +157 -0
- argus/server/__init__.py +0 -0
- argus/server/api.py +661 -0
- argus/server/app.py +97 -0
- argus/store/__init__.py +0 -0
- argus/store/db.py +103 -0
- argus/store/migrations/__init__.py +0 -0
- argus/store/migrations/inline.py +180 -0
- argus/store/repository.py +778 -0
- argus/templates/default/.claude/agents/code-reviewer.md +27 -0
- argus/templates/default/.claude/agents/security-auditor.md +28 -0
- argus/templates/default/.claude/commands/commit.md +38 -0
- argus/templates/default/.claude/commands/deploy.md +13 -0
- argus/templates/default/.claude/commands/fix-issue.md +15 -0
- argus/templates/default/.claude/commands/pr.md +38 -0
- argus/templates/default/.claude/commands/review.md +14 -0
- argus/templates/default/.claude/rules/api-conventions.md +27 -0
- argus/templates/default/.claude/rules/code-style.md +25 -0
- argus/templates/default/.claude/rules/testing.md +19 -0
- argus/templates/default/.claude/settings.json +28 -0
- argus/templates/default/.claude/skills/example/SKILL.md +11 -0
- argus/templates/default/CLAUDE.md +57 -0
- argus_code-0.2.0.dist-info/METADATA +247 -0
- argus_code-0.2.0.dist-info/RECORD +86 -0
- argus_code-0.2.0.dist-info/WHEEL +4 -0
- argus_code-0.2.0.dist-info/entry_points.txt +2 -0
- argus_code-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|