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
@@ -0,0 +1,778 @@
1
+ """Repository — all SQL lives here.
2
+
3
+ Direct port of src/store/repository.ts. Methods keep the same names so
4
+ test files port over verbatim. Named placeholders use SQLite's ``:name``
5
+ syntax instead of better-sqlite3's ``@name``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sqlite3
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ..schema.types import (
18
+ Alert,
19
+ Prompt,
20
+ Session,
21
+ ToolCall,
22
+ TranscriptSegment,
23
+ Turn,
24
+ )
25
+
26
+
27
+ def normalize_project_path(p: str) -> str:
28
+ """Same project_path normalization used by session ingest and history ingest.
29
+
30
+ Replaces backslashes with forward slashes; on Windows additionally
31
+ lowercases everything. Trailing slash stripped. Empty input preserved.
32
+ The history.jsonl and session-ingest sides MUST agree byte-for-byte
33
+ so the prompt → session linkage join hits.
34
+ """
35
+ if not p:
36
+ return ""
37
+ s = p.replace("\\", "/").rstrip("/")
38
+ if sys.platform == "win32":
39
+ s = s.lower()
40
+ return s
41
+
42
+
43
+ def _iso_to_ms(iso: str | None) -> int | None:
44
+ """Convert an ISO-8601 timestamp string to ms since epoch, or None."""
45
+ if not iso:
46
+ return None
47
+ try:
48
+ # fromisoformat handles trailing 'Z' from Python 3.11+
49
+ s = iso.replace("Z", "+00:00") if iso.endswith("Z") else iso
50
+ dt = datetime.fromisoformat(s)
51
+ if dt.tzinfo is None:
52
+ dt = dt.replace(tzinfo=timezone.utc)
53
+ return int(dt.timestamp() * 1000)
54
+ except (ValueError, TypeError):
55
+ return None
56
+
57
+
58
+ def _row_to_session(row: sqlite3.Row) -> Session:
59
+ """SQLite row → Session, dropping the denormalized *_at_ms columns."""
60
+ d = dict(row)
61
+ d.pop("started_at_ms", None)
62
+ d.pop("ended_at_ms", None)
63
+ d["metadata"] = json.loads(d["metadata"])
64
+ return Session.model_validate(d)
65
+
66
+
67
+ def _row_to_turn(row: sqlite3.Row) -> Turn:
68
+ d = dict(row)
69
+ d["metadata"] = json.loads(d["metadata"])
70
+ return Turn.model_validate(d)
71
+
72
+
73
+ def _row_to_alert(row: sqlite3.Row) -> Alert:
74
+ d = dict(row)
75
+ d["metadata"] = json.loads(d["metadata"])
76
+ return Alert.model_validate(d)
77
+
78
+
79
+ class Repository:
80
+ """All SQL is encapsulated here. Methods are mostly direct ports."""
81
+
82
+ def __init__(self, db: sqlite3.Connection) -> None:
83
+ self.db = db
84
+
85
+ # ─── Sessions ──────────────────────────────────────────────────────
86
+
87
+ def upsert_session(self, s: Session) -> None:
88
+ started_ms = _iso_to_ms(s.started_at)
89
+ ended_ms = _iso_to_ms(s.ended_at)
90
+ params = {
91
+ "id": s.id,
92
+ "agent": s.agent,
93
+ "agent_version": s.agent_version,
94
+ "project_path": normalize_project_path(s.project_path),
95
+ "started_at": s.started_at,
96
+ "ended_at": s.ended_at,
97
+ "duration_sec": s.duration_sec,
98
+ "total_fresh_input_tokens": s.total_fresh_input_tokens,
99
+ "total_output_tokens": s.total_output_tokens,
100
+ "total_cache_read_tokens": s.total_cache_read_tokens,
101
+ "total_cache_write_tokens": s.total_cache_write_tokens,
102
+ "total_cost_usd": s.total_cost_usd,
103
+ "primary_model": s.primary_model,
104
+ "turn_count": s.turn_count,
105
+ "pricing_table_version": s.pricing_table_version,
106
+ "computed_at": s.computed_at,
107
+ "agent_reported_cost_usd": s.agent_reported_cost_usd,
108
+ "metadata": json.dumps(s.metadata),
109
+ "started_at_ms": started_ms,
110
+ "ended_at_ms": ended_ms,
111
+ }
112
+ self.db.execute(
113
+ """
114
+ INSERT INTO sessions (id, agent, agent_version, project_path, started_at, ended_at, duration_sec,
115
+ total_fresh_input_tokens, total_output_tokens, total_cache_read_tokens, total_cache_write_tokens,
116
+ total_cost_usd, primary_model, turn_count, pricing_table_version, computed_at,
117
+ agent_reported_cost_usd, metadata, started_at_ms, ended_at_ms)
118
+ VALUES (:id, :agent, :agent_version, :project_path, :started_at, :ended_at, :duration_sec,
119
+ :total_fresh_input_tokens, :total_output_tokens, :total_cache_read_tokens, :total_cache_write_tokens,
120
+ :total_cost_usd, :primary_model, :turn_count, :pricing_table_version, :computed_at,
121
+ :agent_reported_cost_usd, :metadata, :started_at_ms, :ended_at_ms)
122
+ ON CONFLICT(id) DO UPDATE SET
123
+ agent_version=excluded.agent_version, project_path=excluded.project_path,
124
+ ended_at=excluded.ended_at, duration_sec=excluded.duration_sec,
125
+ total_fresh_input_tokens=excluded.total_fresh_input_tokens,
126
+ total_output_tokens=excluded.total_output_tokens,
127
+ total_cache_read_tokens=excluded.total_cache_read_tokens,
128
+ total_cache_write_tokens=excluded.total_cache_write_tokens,
129
+ total_cost_usd=excluded.total_cost_usd, primary_model=excluded.primary_model,
130
+ turn_count=excluded.turn_count, pricing_table_version=excluded.pricing_table_version,
131
+ computed_at=excluded.computed_at, agent_reported_cost_usd=excluded.agent_reported_cost_usd,
132
+ metadata=excluded.metadata,
133
+ started_at_ms=excluded.started_at_ms, ended_at_ms=excluded.ended_at_ms
134
+ """,
135
+ params,
136
+ )
137
+
138
+ def get_session(self, session_id: str) -> Session | None:
139
+ row = self.db.execute(
140
+ "SELECT * FROM sessions WHERE id = ?", (session_id,)
141
+ ).fetchone()
142
+ return _row_to_session(row) if row else None
143
+
144
+ def list_sessions(
145
+ self, *, limit: int, offset: int = 0, agent: str | None = None
146
+ ) -> list[Session]:
147
+ if agent:
148
+ rows = self.db.execute(
149
+ "SELECT * FROM sessions WHERE agent = ? ORDER BY started_at DESC LIMIT ? OFFSET ?",
150
+ (agent, limit, offset),
151
+ ).fetchall()
152
+ else:
153
+ rows = self.db.execute(
154
+ "SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?",
155
+ (limit, offset),
156
+ ).fetchall()
157
+ return [_row_to_session(r) for r in rows]
158
+
159
+ # ─── Turns ─────────────────────────────────────────────────────────
160
+
161
+ def upsert_turn(self, t: Turn) -> None:
162
+ params = {
163
+ "id": t.id,
164
+ "session_id": t.session_id,
165
+ "sequence": t.sequence,
166
+ "timestamp": t.timestamp,
167
+ "model": t.model,
168
+ "model_raw": t.model_raw,
169
+ "fresh_input_tokens": t.fresh_input_tokens,
170
+ "output_tokens": t.output_tokens,
171
+ "cache_read_tokens": t.cache_read_tokens,
172
+ "cache_write_tokens": t.cache_write_tokens,
173
+ "cache_write_5m_tokens": t.cache_write_5m_tokens,
174
+ "cache_write_1h_tokens": t.cache_write_1h_tokens,
175
+ "tool_calls_count": t.tool_calls_count,
176
+ "cost_usd": t.cost_usd,
177
+ "metadata": json.dumps(t.metadata),
178
+ }
179
+ self.db.execute(
180
+ """
181
+ INSERT INTO turns (id, session_id, sequence, timestamp, model, model_raw,
182
+ fresh_input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
183
+ cache_write_5m_tokens, cache_write_1h_tokens, tool_calls_count, cost_usd, metadata)
184
+ VALUES (:id, :session_id, :sequence, :timestamp, :model, :model_raw,
185
+ :fresh_input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens,
186
+ :cache_write_5m_tokens, :cache_write_1h_tokens, :tool_calls_count, :cost_usd, :metadata)
187
+ ON CONFLICT(id) DO UPDATE SET
188
+ sequence=excluded.sequence, timestamp=excluded.timestamp, model=excluded.model,
189
+ model_raw=excluded.model_raw, fresh_input_tokens=excluded.fresh_input_tokens,
190
+ output_tokens=excluded.output_tokens, cache_read_tokens=excluded.cache_read_tokens,
191
+ cache_write_tokens=excluded.cache_write_tokens,
192
+ cache_write_5m_tokens=excluded.cache_write_5m_tokens,
193
+ cache_write_1h_tokens=excluded.cache_write_1h_tokens,
194
+ tool_calls_count=excluded.tool_calls_count, cost_usd=excluded.cost_usd,
195
+ metadata=excluded.metadata
196
+ """,
197
+ params,
198
+ )
199
+
200
+ def get_turns_for_session(self, session_id: str) -> list[Turn]:
201
+ rows = self.db.execute(
202
+ "SELECT * FROM turns WHERE session_id = ? ORDER BY sequence",
203
+ (session_id,),
204
+ ).fetchall()
205
+ return [_row_to_turn(r) for r in rows]
206
+
207
+ # ─── File offsets / parse errors ───────────────────────────────────
208
+
209
+ def set_file_offset(self, path: str, offset: int) -> None:
210
+ self.db.execute(
211
+ """
212
+ INSERT INTO file_offsets (path, byte_offset, last_seen) VALUES (?, ?, ?)
213
+ ON CONFLICT(path) DO UPDATE SET byte_offset=excluded.byte_offset, last_seen=excluded.last_seen
214
+ """,
215
+ (path, offset, datetime.now(timezone.utc).isoformat()),
216
+ )
217
+
218
+ def get_file_offset(self, path: str) -> int:
219
+ row = self.db.execute(
220
+ "SELECT byte_offset FROM file_offsets WHERE path = ?", (path,)
221
+ ).fetchone()
222
+ return row["byte_offset"] if row else 0
223
+
224
+ def record_parse_error(self, e: dict[str, Any]) -> None:
225
+ self.db.execute(
226
+ """
227
+ INSERT INTO parse_errors (file, byte_offset, reason, raw_line_truncated, occurred_at)
228
+ VALUES (?, ?, ?, ?, ?)
229
+ """,
230
+ (
231
+ e["file"],
232
+ e["byte_offset"],
233
+ e["reason"],
234
+ e["raw_line_truncated"],
235
+ datetime.now(timezone.utc).isoformat(),
236
+ ),
237
+ )
238
+
239
+ def recent_parse_errors(self, limit: int) -> list[dict[str, Any]]:
240
+ rows = self.db.execute(
241
+ "SELECT file, byte_offset, reason, raw_line_truncated FROM parse_errors ORDER BY id DESC LIMIT ?",
242
+ (limit,),
243
+ ).fetchall()
244
+ return [dict(r) for r in rows]
245
+
246
+ # ─── Tool calls ────────────────────────────────────────────────────
247
+
248
+ def upsert_tool_calls(self, calls: list[ToolCall]) -> None:
249
+ if not calls:
250
+ return
251
+ rows = [
252
+ {
253
+ "id": c.id,
254
+ "session_id": c.session_id,
255
+ "turn_index": c.turn_index,
256
+ "tool_name": c.tool_name,
257
+ "is_error": c.is_error,
258
+ "input_size": c.input_size,
259
+ "subagent_type": c.subagent_type,
260
+ "timestamp": c.timestamp,
261
+ }
262
+ for c in calls
263
+ ]
264
+ with self.db:
265
+ self.db.executemany(
266
+ """
267
+ INSERT INTO tool_calls (id, session_id, turn_index, tool_name, is_error, input_size, subagent_type, timestamp)
268
+ VALUES (:id, :session_id, :turn_index, :tool_name, :is_error, :input_size, :subagent_type, :timestamp)
269
+ ON CONFLICT(id) DO UPDATE SET
270
+ turn_index=excluded.turn_index, tool_name=excluded.tool_name,
271
+ is_error=excluded.is_error, input_size=excluded.input_size,
272
+ subagent_type=excluded.subagent_type, timestamp=excluded.timestamp
273
+ """,
274
+ rows,
275
+ )
276
+
277
+ def count_tool_calls_for_session(self, session_id: str) -> int:
278
+ row = self.db.execute(
279
+ "SELECT COUNT(*) AS n FROM tool_calls WHERE session_id = ?",
280
+ (session_id,),
281
+ ).fetchone()
282
+ return row["n"] if row else 0
283
+
284
+ def tool_leaderboard(
285
+ self, cutoff_iso: str, limit: int = 20
286
+ ) -> list[dict[str, Any]]:
287
+ rows = self.db.execute(
288
+ """
289
+ SELECT tool_name AS name, COUNT(*) AS calls, SUM(is_error) AS errors
290
+ FROM tool_calls
291
+ WHERE timestamp >= ?
292
+ GROUP BY tool_name
293
+ ORDER BY calls DESC
294
+ LIMIT ?
295
+ """,
296
+ (cutoff_iso, limit),
297
+ ).fetchall()
298
+ return [
299
+ {"name": r["name"], "calls": r["calls"], "errors": r["errors"] or 0}
300
+ for r in rows
301
+ ]
302
+
303
+ def tool_call_stats_in_range(
304
+ self, *, start_iso: str, end_iso: str
305
+ ) -> list[dict[str, Any]]:
306
+ """Per-tool ``(calls, errors)`` over a half-open ``[start, end)`` window."""
307
+ rows = self.db.execute(
308
+ """
309
+ SELECT tool_name, COUNT(*) AS calls,
310
+ COALESCE(SUM(is_error), 0) AS errors
311
+ FROM tool_calls
312
+ WHERE timestamp >= ? AND timestamp < ?
313
+ GROUP BY tool_name
314
+ """,
315
+ (start_iso, end_iso),
316
+ ).fetchall()
317
+ return [dict(r) for r in rows]
318
+
319
+ def tool_calls_total(self, cutoff_iso: str) -> dict[str, int]:
320
+ row = self.db.execute(
321
+ """
322
+ SELECT COUNT(*) AS total, COALESCE(SUM(is_error), 0) AS errors
323
+ FROM tool_calls WHERE timestamp >= ?
324
+ """,
325
+ (cutoff_iso,),
326
+ ).fetchone()
327
+ return {
328
+ "total": row["total"] if row else 0,
329
+ "errors": row["errors"] if row else 0,
330
+ }
331
+
332
+ def aggregate_turns_by_day(self, cutoff_iso: str) -> list[dict[str, Any]]:
333
+ """Per-turn aggregation for windowed views.
334
+
335
+ Returns one row per (day, model, session_id) inside the cutoff.
336
+ Sub-agent rollup ids contain ``/`` and are excluded (their turns
337
+ roll into the parent session via the pipeline).
338
+ """
339
+ rows = self.db.execute(
340
+ """
341
+ SELECT
342
+ substr(timestamp, 1, 10) AS day,
343
+ model,
344
+ session_id,
345
+ COALESCE(SUM(cost_usd), 0) AS cost,
346
+ COALESCE(SUM(fresh_input_tokens), 0) AS fresh_input,
347
+ COALESCE(SUM(output_tokens), 0) AS output,
348
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read,
349
+ COALESCE(SUM(cache_write_tokens), 0) AS cache_write
350
+ FROM turns
351
+ WHERE timestamp >= ?
352
+ AND session_id NOT LIKE '%/%'
353
+ GROUP BY day, model, session_id
354
+ ORDER BY day ASC
355
+ """,
356
+ (cutoff_iso,),
357
+ ).fetchall()
358
+ return [dict(r) for r in rows]
359
+
360
+ def mcp_tool_calls(self, cutoff_iso: str) -> list[dict[str, Any]]:
361
+ rows = self.db.execute(
362
+ r"""
363
+ SELECT tool_name, COUNT(*) AS calls, SUM(is_error) AS errors
364
+ FROM tool_calls
365
+ WHERE timestamp >= ? AND tool_name LIKE 'mcp\_\_%' ESCAPE '\'
366
+ GROUP BY tool_name
367
+ """,
368
+ (cutoff_iso,),
369
+ ).fetchall()
370
+ return [dict(r) for r in rows]
371
+
372
+ def subagent_calls(self, cutoff_iso: str) -> list[dict[str, Any]]:
373
+ rows = self.db.execute(
374
+ """
375
+ SELECT subagent_type AS type, COUNT(*) AS calls, COALESCE(SUM(is_error), 0) AS errors
376
+ FROM tool_calls
377
+ WHERE timestamp >= ? AND subagent_type IS NOT NULL AND subagent_type <> ''
378
+ GROUP BY subagent_type
379
+ ORDER BY calls DESC
380
+ """,
381
+ (cutoff_iso,),
382
+ ).fetchall()
383
+ return [dict(r) for r in rows]
384
+
385
+ def sessions_missing_tool_calls(self, limit: int) -> list[dict[str, Any]]:
386
+ rows = self.db.execute(
387
+ """
388
+ SELECT s.id FROM sessions s
389
+ LEFT JOIN (SELECT DISTINCT session_id FROM tool_calls) t ON t.session_id = s.id
390
+ WHERE t.session_id IS NULL
391
+ ORDER BY s.started_at DESC
392
+ LIMIT ?
393
+ """,
394
+ (limit,),
395
+ ).fetchall()
396
+ return [{"id": r["id"]} for r in rows]
397
+
398
+ # ─── Prompts ───────────────────────────────────────────────────────
399
+
400
+ def insert_prompts(self, rows: list[Prompt]) -> None:
401
+ if not rows:
402
+ return
403
+ params = [
404
+ {
405
+ "timestamp_ms": r.timestamp_ms,
406
+ "project_path": r.project_path,
407
+ "display": r.display,
408
+ "pasted_chars": r.pasted_chars,
409
+ "is_slash": r.is_slash,
410
+ }
411
+ for r in rows
412
+ ]
413
+ with self.db:
414
+ self.db.executemany(
415
+ """
416
+ INSERT INTO prompts (timestamp_ms, project_path, display, pasted_chars, is_slash)
417
+ VALUES (:timestamp_ms, :project_path, :display, :pasted_chars, :is_slash)
418
+ """,
419
+ params,
420
+ )
421
+
422
+ def search_prompts(
423
+ self,
424
+ *,
425
+ q: str | None = None,
426
+ limit: int,
427
+ project: str | None = None,
428
+ include_slash: bool = False,
429
+ ) -> dict[str, Any]:
430
+ slash_clause = "1=1" if include_slash else "is_slash = 0"
431
+ project_clause = "AND p.project_path = ?" if project else ""
432
+ project_params: tuple = (project,) if project else ()
433
+
434
+ if q and q.strip():
435
+ fts = q.strip()
436
+ sql = f"""
437
+ SELECT p.*, snippet(prompts_fts, 0, '<mark>', '</mark>', '…', 16) AS snippet
438
+ FROM prompts_fts
439
+ JOIN prompts p ON p.id = prompts_fts.rowid
440
+ WHERE prompts_fts MATCH ? AND {slash_clause} {project_clause}
441
+ ORDER BY bm25(prompts_fts)
442
+ LIMIT ?
443
+ """
444
+ count_sql = f"""
445
+ SELECT COUNT(*) AS n FROM prompts_fts
446
+ JOIN prompts p ON p.id = prompts_fts.rowid
447
+ WHERE prompts_fts MATCH ? AND {slash_clause} {project_clause}
448
+ """
449
+ params = (fts, *project_params)
450
+ rows = [dict(r) for r in self.db.execute(sql, (*params, limit)).fetchall()]
451
+ total_row = self.db.execute(count_sql, params).fetchone()
452
+ return {"total": total_row["n"] if total_row else 0, "rows": rows}
453
+
454
+ sql = f"""
455
+ SELECT p.*, p.display AS snippet
456
+ FROM prompts p
457
+ WHERE {slash_clause} {project_clause}
458
+ ORDER BY p.timestamp_ms DESC
459
+ LIMIT ?
460
+ """
461
+ count_sql = f"""
462
+ SELECT COUNT(*) AS n FROM prompts p
463
+ WHERE {slash_clause} {project_clause}
464
+ """
465
+ rows = [dict(r) for r in self.db.execute(sql, (*project_params, limit)).fetchall()]
466
+ total_row = self.db.execute(count_sql, project_params).fetchone()
467
+ return {"total": total_row["n"] if total_row else 0, "rows": rows}
468
+
469
+ def prompt_stats(self) -> dict[str, Any]:
470
+ row = self.db.execute(
471
+ """
472
+ SELECT COUNT(*) AS total,
473
+ COUNT(DISTINCT project_path) AS projects,
474
+ MIN(timestamp_ms) AS oldest_ms
475
+ FROM prompts
476
+ """
477
+ ).fetchone()
478
+ return {
479
+ "total": row["total"] if row else 0,
480
+ "projects": row["projects"] if row else 0,
481
+ "oldest_ms": row["oldest_ms"] if row else None,
482
+ }
483
+
484
+ def prompt_projects(self) -> list[str]:
485
+ rows = self.db.execute(
486
+ "SELECT DISTINCT project_path FROM prompts ORDER BY project_path"
487
+ ).fetchall()
488
+ return [r["project_path"] for r in rows]
489
+
490
+ def link_prompt_to_session(self, project_path: str, timestamp_ms: int) -> str | None:
491
+ row = self.db.execute(
492
+ """
493
+ SELECT id FROM sessions
494
+ WHERE project_path = ?
495
+ AND started_at_ms IS NOT NULL
496
+ AND started_at_ms <= ?
497
+ AND (ended_at_ms IS NULL OR ended_at_ms >= ?)
498
+ ORDER BY ABS(started_at_ms - ?)
499
+ LIMIT 1
500
+ """,
501
+ (project_path, timestamp_ms, timestamp_ms, timestamp_ms),
502
+ ).fetchone()
503
+ return row["id"] if row else None
504
+
505
+ # ─── Transcript segments ───────────────────────────────────────────
506
+
507
+ def upsert_transcript_segments(self, rows: list[TranscriptSegment]) -> None:
508
+ if not rows:
509
+ return
510
+ params = [
511
+ {
512
+ "uid": r.uid,
513
+ "session_id": r.session_id,
514
+ "timestamp": r.timestamp,
515
+ "role": r.role,
516
+ "text": r.text,
517
+ }
518
+ for r in rows
519
+ ]
520
+ with self.db:
521
+ self.db.executemany(
522
+ """
523
+ INSERT INTO transcript_segments (uid, session_id, timestamp, role, text)
524
+ VALUES (:uid, :session_id, :timestamp, :role, :text)
525
+ ON CONFLICT(uid) DO UPDATE SET
526
+ session_id=excluded.session_id, timestamp=excluded.timestamp,
527
+ role=excluded.role, text=excluded.text
528
+ """,
529
+ params,
530
+ )
531
+
532
+ def count_segments_for_session(self, session_id: str) -> int:
533
+ row = self.db.execute(
534
+ "SELECT COUNT(*) AS n FROM transcript_segments WHERE session_id = ?",
535
+ (session_id,),
536
+ ).fetchone()
537
+ return row["n"] if row else 0
538
+
539
+ def sessions_missing_segments(self, limit: int) -> list[dict[str, Any]]:
540
+ rows = self.db.execute(
541
+ """
542
+ SELECT s.id FROM sessions s
543
+ LEFT JOIN (SELECT DISTINCT session_id FROM transcript_segments) t ON t.session_id = s.id
544
+ WHERE t.session_id IS NULL
545
+ ORDER BY s.started_at DESC
546
+ LIMIT ?
547
+ """,
548
+ (limit,),
549
+ ).fetchall()
550
+ return [{"id": r["id"]} for r in rows]
551
+
552
+ def search_transcripts(
553
+ self,
554
+ *,
555
+ q: str,
556
+ limit: int,
557
+ project: str | None = None,
558
+ session_id: str | None = None,
559
+ roles: list[str] | None = None,
560
+ ) -> dict[str, Any]:
561
+ conds: list[str] = ["transcript_fts MATCH ?"]
562
+ params: list[Any] = [q]
563
+ if project:
564
+ conds.append("s.project_path = ?")
565
+ params.append(project)
566
+ if session_id:
567
+ conds.append("seg.session_id = ?")
568
+ params.append(session_id)
569
+ if roles:
570
+ conds.append(f"seg.role IN ({','.join('?' for _ in roles)})")
571
+ params.extend(roles)
572
+ where = "WHERE " + " AND ".join(conds)
573
+
574
+ sql = f"""
575
+ SELECT seg.uid, seg.session_id, seg.timestamp, seg.role, seg.text,
576
+ snippet(transcript_fts, 0, '<mark>', '</mark>', '…', 16) AS snippet,
577
+ s.project_path AS project_path
578
+ FROM transcript_fts
579
+ JOIN transcript_segments seg ON seg.rowid = transcript_fts.rowid
580
+ LEFT JOIN sessions s ON s.id = seg.session_id
581
+ {where}
582
+ ORDER BY bm25(transcript_fts)
583
+ LIMIT ?
584
+ """
585
+ count_sql = f"""
586
+ SELECT COUNT(*) AS n FROM transcript_fts
587
+ JOIN transcript_segments seg ON seg.rowid = transcript_fts.rowid
588
+ LEFT JOIN sessions s ON s.id = seg.session_id
589
+ {where}
590
+ """
591
+ rows = [dict(r) for r in self.db.execute(sql, (*params, limit)).fetchall()]
592
+ total_row = self.db.execute(count_sql, params).fetchone()
593
+ return {"total": total_row["n"] if total_row else 0, "rows": rows}
594
+
595
+ def segment_projects(self) -> list[str]:
596
+ rows = self.db.execute(
597
+ """
598
+ SELECT DISTINCT s.project_path
599
+ FROM transcript_segments seg
600
+ JOIN sessions s ON s.id = seg.session_id
601
+ WHERE s.project_path IS NOT NULL AND s.project_path <> ''
602
+ ORDER BY s.project_path
603
+ """
604
+ ).fetchall()
605
+ return [r["project_path"] for r in rows]
606
+
607
+ def segment_stats(self) -> dict[str, int]:
608
+ row = self.db.execute(
609
+ """
610
+ SELECT COUNT(*) AS total, COUNT(DISTINCT session_id) AS sessions
611
+ FROM transcript_segments
612
+ """
613
+ ).fetchone()
614
+ return {
615
+ "total": row["total"] if row else 0,
616
+ "sessions": row["sessions"] if row else 0,
617
+ }
618
+
619
+ def clear_all_segments(self) -> None:
620
+ self.db.execute("DELETE FROM transcript_segments")
621
+
622
+ def db_size_bytes(self) -> int:
623
+ """Sum of the main db + -wal + -shm files (or page_count * page_size for :memory:)."""
624
+ # sqlite3.Connection doesn't expose .name like better-sqlite3; we
625
+ # fetch the main file from PRAGMA database_list.
626
+ cur = self.db.execute("PRAGMA database_list")
627
+ path = None
628
+ for row in cur.fetchall():
629
+ if row["name"] == "main":
630
+ path = row["file"]
631
+ break
632
+ if not path:
633
+ row = self.db.execute(
634
+ """
635
+ SELECT (SELECT page_count FROM pragma_page_count()) *
636
+ (SELECT page_size FROM pragma_page_size()) AS bytes
637
+ """
638
+ ).fetchone()
639
+ return row["bytes"] if row else 0
640
+ total = 0
641
+ for suffix in ("", "-wal", "-shm"):
642
+ try:
643
+ total += os.stat(path + suffix).st_size
644
+ except OSError:
645
+ pass
646
+ return total
647
+
648
+ def vacuum(self) -> None:
649
+ self.db.execute("VACUUM")
650
+ self.db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
651
+
652
+ # ─── Alerts ────────────────────────────────────────────────────────
653
+
654
+ def upsert_alert(self, a: Alert) -> int:
655
+ """Insert or update an alert keyed on (detector, dedup_key).
656
+
657
+ Same-severity upserts keep ``first_seen_at`` and ``seen_at`` as-is.
658
+ Severity changes reset ``seen_at`` to NULL so the dashboard re-pings
659
+ a state that has escalated (e.g., warning → critical).
660
+
661
+ Single statement with ``RETURNING id`` so there's no transaction
662
+ wrapper, no implicit cursor reuse, and no second round trip — the
663
+ whole thing is one prepared statement we can run from any thread.
664
+ """
665
+ params = {
666
+ "detector": a.detector,
667
+ "dedup_key": a.dedup_key,
668
+ "severity": a.severity,
669
+ "title": a.title,
670
+ "message": a.message,
671
+ "metadata": json.dumps(a.metadata),
672
+ "first_seen_at": a.first_seen_at,
673
+ "last_seen_at": a.last_seen_at,
674
+ }
675
+ cur = self.db.execute(
676
+ """
677
+ INSERT INTO alerts (detector, dedup_key, severity, title, message, metadata,
678
+ first_seen_at, last_seen_at)
679
+ VALUES (:detector, :dedup_key, :severity, :title, :message, :metadata,
680
+ :first_seen_at, :last_seen_at)
681
+ ON CONFLICT(detector, dedup_key) DO UPDATE SET
682
+ title = excluded.title,
683
+ message = excluded.message,
684
+ metadata = excluded.metadata,
685
+ last_seen_at = excluded.last_seen_at,
686
+ seen_at = CASE
687
+ WHEN resolved_at IS NOT NULL THEN NULL
688
+ WHEN severity = excluded.severity THEN seen_at
689
+ ELSE NULL
690
+ END,
691
+ resolved_at = NULL,
692
+ severity = excluded.severity
693
+ RETURNING id
694
+ """,
695
+ params,
696
+ )
697
+ row = cur.fetchone()
698
+ return int(row["id"])
699
+
700
+ def list_alerts(self, *, limit: int = 50) -> list[Alert]:
701
+ rows = self.db.execute(
702
+ "SELECT * FROM alerts WHERE resolved_at IS NULL "
703
+ "ORDER BY last_seen_at DESC LIMIT ?",
704
+ (limit,),
705
+ ).fetchall()
706
+ return [_row_to_alert(r) for r in rows]
707
+
708
+ def list_unseen_alerts(self, *, severity: str | None = None) -> list[Alert]:
709
+ if severity:
710
+ rows = self.db.execute(
711
+ "SELECT * FROM alerts WHERE seen_at IS NULL AND resolved_at IS NULL "
712
+ "AND severity = ? ORDER BY last_seen_at DESC",
713
+ (severity,),
714
+ ).fetchall()
715
+ else:
716
+ rows = self.db.execute(
717
+ "SELECT * FROM alerts WHERE seen_at IS NULL AND resolved_at IS NULL "
718
+ "ORDER BY last_seen_at DESC"
719
+ ).fetchall()
720
+ return [_row_to_alert(r) for r in rows]
721
+
722
+ def resolve_stale_alerts(
723
+ self, *, detector: str, active_dedup_keys: list[str]
724
+ ) -> int:
725
+ """Mark resolved_at = now() for unresolved alerts under ``detector``
726
+ whose dedup_key is NOT in ``active_dedup_keys``.
727
+
728
+ Empty ``active_dedup_keys`` resolves every unresolved row under that
729
+ detector — correct when a detector emitted no findings this tick.
730
+ """
731
+ now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
732
+ if not active_dedup_keys:
733
+ cur = self.db.execute(
734
+ "UPDATE alerts SET resolved_at = ? "
735
+ "WHERE detector = ? AND resolved_at IS NULL",
736
+ (now, detector),
737
+ )
738
+ else:
739
+ placeholders = ",".join("?" * len(active_dedup_keys))
740
+ cur = self.db.execute(
741
+ f"UPDATE alerts SET resolved_at = ? "
742
+ f"WHERE detector = ? AND resolved_at IS NULL "
743
+ f"AND dedup_key NOT IN ({placeholders})",
744
+ (now, detector, *active_dedup_keys),
745
+ )
746
+ return cur.rowcount
747
+
748
+ def mark_alert_seen(self, alert_id: int) -> bool:
749
+ cur = self.db.execute(
750
+ "UPDATE alerts SET seen_at = ? WHERE id = ? AND seen_at IS NULL",
751
+ (datetime.now(timezone.utc).isoformat(), alert_id),
752
+ )
753
+ if cur.rowcount > 0:
754
+ return True
755
+ # Already-seen rows hit rowcount=0 but the row exists — treat as success.
756
+ row = self.db.execute(
757
+ "SELECT 1 FROM alerts WHERE id = ?", (alert_id,)
758
+ ).fetchone()
759
+ return row is not None
760
+
761
+ # ─── App-meta-backed settings ──────────────────────────────────────
762
+
763
+ def is_search_indexing_enabled(self) -> bool:
764
+ row = self.db.execute(
765
+ "SELECT value FROM app_meta WHERE key = 'enable_transcript_search'"
766
+ ).fetchone()
767
+ if row:
768
+ return row["value"] == "1"
769
+ # Migration default: ON if segments already exist, OFF otherwise.
770
+ has_data = self.segment_stats()["total"] > 0
771
+ self.set_search_indexing_enabled(has_data)
772
+ return has_data
773
+
774
+ def set_search_indexing_enabled(self, enabled: bool) -> None:
775
+ self.db.execute(
776
+ "INSERT OR REPLACE INTO app_meta (key, value) VALUES ('enable_transcript_search', ?)",
777
+ ("1" if enabled else "0",),
778
+ )