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
|
@@ -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
|
+
)
|