codeframe-ai 0.9.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 (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,598 @@
1
+ """Repository for Token Repository operations.
2
+
3
+ Extracted from monolithic Database class for better maintainability.
4
+ """
5
+
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import List, Optional, Dict, Any, TYPE_CHECKING
8
+ import logging
9
+
10
+
11
+ from codeframe.core.models import (
12
+ CallType,
13
+ )
14
+ from codeframe.platform_store.repositories.base import BaseRepository
15
+
16
+ if TYPE_CHECKING:
17
+ from codeframe.core.models import TokenUsage
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class TokenRepository(BaseRepository):
23
+ """Repository for token repository operations."""
24
+
25
+
26
+ def save_token_usage(self, token_usage: "TokenUsage") -> int:
27
+ """Save a token usage record to the database.
28
+
29
+ Args:
30
+ token_usage: TokenUsage model instance
31
+
32
+ Returns:
33
+ Database ID of the created record
34
+
35
+ Example:
36
+ >>> from codeframe.core.models import TokenUsage, CallType
37
+ >>> usage = TokenUsage(
38
+ ... task_id=27,
39
+ ... agent_id="backend-001",
40
+ ... project_id=1,
41
+ ... model_name="claude-sonnet-4-5",
42
+ ... input_tokens=1000,
43
+ ... output_tokens=500,
44
+ ... estimated_cost_usd=0.0105,
45
+ ... call_type=CallType.TASK_EXECUTION
46
+ ... )
47
+ >>> usage_id = db.save_token_usage(usage)
48
+ """
49
+ if self._sync_lock is not None:
50
+ with self._sync_lock:
51
+ cursor = self.conn.cursor()
52
+ cursor.execute(
53
+ """
54
+ INSERT INTO token_usage (
55
+ task_id, agent_id, project_id, model_name,
56
+ input_tokens, output_tokens, estimated_cost_usd,
57
+ actual_cost_usd, call_type, timestamp
58
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
59
+ """,
60
+ (
61
+ token_usage.task_id,
62
+ token_usage.agent_id,
63
+ token_usage.project_id,
64
+ token_usage.model_name,
65
+ token_usage.input_tokens,
66
+ token_usage.output_tokens,
67
+ token_usage.estimated_cost_usd,
68
+ token_usage.actual_cost_usd,
69
+ (
70
+ token_usage.call_type.value
71
+ if isinstance(token_usage.call_type, CallType)
72
+ else token_usage.call_type
73
+ ),
74
+ token_usage.timestamp.isoformat(),
75
+ ),
76
+ )
77
+ self.conn.commit()
78
+ return cursor.lastrowid
79
+ else:
80
+ cursor = self.conn.cursor()
81
+ cursor.execute(
82
+ """
83
+ INSERT INTO token_usage (
84
+ task_id, agent_id, project_id, model_name,
85
+ input_tokens, output_tokens, estimated_cost_usd,
86
+ actual_cost_usd, call_type, timestamp
87
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
88
+ """,
89
+ (
90
+ token_usage.task_id,
91
+ token_usage.agent_id,
92
+ token_usage.project_id,
93
+ token_usage.model_name,
94
+ token_usage.input_tokens,
95
+ token_usage.output_tokens,
96
+ token_usage.estimated_cost_usd,
97
+ token_usage.actual_cost_usd,
98
+ (
99
+ token_usage.call_type.value
100
+ if isinstance(token_usage.call_type, CallType)
101
+ else token_usage.call_type
102
+ ),
103
+ token_usage.timestamp.isoformat(),
104
+ ),
105
+ )
106
+ self.conn.commit()
107
+ return cursor.lastrowid
108
+
109
+
110
+
111
+ def get_token_usage(
112
+ self,
113
+ project_id: Optional[int] = None,
114
+ agent_id: Optional[str] = None,
115
+ start_date: Optional[datetime] = None,
116
+ end_date: Optional[datetime] = None,
117
+ ) -> List[Dict[str, Any]]:
118
+ """Get token usage records with optional filtering.
119
+
120
+ Args:
121
+ project_id: Filter by project ID (optional)
122
+ agent_id: Filter by agent ID (optional)
123
+ start_date: Filter by start date (inclusive, optional)
124
+ end_date: Filter by end date (inclusive, optional)
125
+
126
+ Returns:
127
+ List of token usage records as dictionaries
128
+
129
+ Example:
130
+ >>> # Get all usage for a project
131
+ >>> usage = db.get_token_usage(project_id=1)
132
+ >>>
133
+ >>> # Get usage for an agent in a date range
134
+ >>> from datetime import datetime, timedelta
135
+ >>> start = datetime.now() - timedelta(days=7)
136
+ >>> usage = db.get_token_usage(agent_id="backend-001", start_date=start)
137
+ """
138
+ cursor = self.conn.cursor()
139
+
140
+ # Build query with filters
141
+ query = "SELECT * FROM token_usage WHERE 1=1"
142
+ params = []
143
+
144
+ if project_id is not None:
145
+ query += " AND project_id = ?"
146
+ params.append(project_id)
147
+
148
+ if agent_id is not None:
149
+ query += " AND agent_id = ?"
150
+ params.append(agent_id)
151
+
152
+ if start_date is not None:
153
+ query += " AND timestamp >= ?"
154
+ params.append(start_date.isoformat())
155
+
156
+ if end_date is not None:
157
+ query += " AND timestamp <= ?"
158
+ params.append(end_date.isoformat())
159
+
160
+ query += " ORDER BY timestamp DESC"
161
+
162
+ cursor.execute(query, params)
163
+ return [dict(row) for row in cursor.fetchall()]
164
+
165
+
166
+
167
+ def get_task_token_summary(self, task_id: int) -> Dict[str, Any]:
168
+ """Get aggregated token usage summary for a single task.
169
+
170
+ Args:
171
+ task_id: Task ID to summarize
172
+
173
+ Returns:
174
+ Dictionary with aggregated token data:
175
+ {
176
+ "task_id": int,
177
+ "total_input_tokens": int,
178
+ "total_output_tokens": int,
179
+ "total_tokens": int,
180
+ "total_cost_usd": float,
181
+ "call_count": int,
182
+ }
183
+ """
184
+ cursor = self.conn.cursor()
185
+ cursor.execute(
186
+ """
187
+ SELECT
188
+ COALESCE(SUM(input_tokens), 0) as total_input_tokens,
189
+ COALESCE(SUM(output_tokens), 0) as total_output_tokens,
190
+ COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
191
+ COALESCE(SUM(estimated_cost_usd), 0.0) as total_cost_usd,
192
+ COUNT(*) as call_count
193
+ FROM token_usage
194
+ WHERE task_id = ?
195
+ """,
196
+ (task_id,),
197
+ )
198
+ row = cursor.fetchone()
199
+
200
+ return {
201
+ "task_id": task_id,
202
+ "total_input_tokens": row["total_input_tokens"],
203
+ "total_output_tokens": row["total_output_tokens"],
204
+ "total_tokens": row["total_tokens"],
205
+ "total_cost_usd": row["total_cost_usd"],
206
+ "call_count": row["call_count"],
207
+ }
208
+
209
+ def get_batch_token_usage(
210
+ self,
211
+ task_ids: List[int],
212
+ start_date: Optional[datetime] = None,
213
+ end_date: Optional[datetime] = None,
214
+ ) -> List[Dict[str, Any]]:
215
+ """Get token usage records filtered by a list of task IDs.
216
+
217
+ Args:
218
+ task_ids: List of task IDs to filter by
219
+ start_date: Optional start of date range (inclusive)
220
+ end_date: Optional end of date range (inclusive)
221
+
222
+ Returns:
223
+ List of token usage records as dictionaries
224
+ """
225
+ if not task_ids:
226
+ return []
227
+
228
+ cursor = self.conn.cursor()
229
+ placeholders = ",".join("?" for _ in task_ids)
230
+ query = f"SELECT * FROM token_usage WHERE task_id IN ({placeholders})"
231
+ params: list = list(task_ids)
232
+
233
+ if start_date is not None:
234
+ query += " AND timestamp >= ?"
235
+ params.append(start_date.isoformat())
236
+
237
+ if end_date is not None:
238
+ query += " AND timestamp <= ?"
239
+ params.append(end_date.isoformat())
240
+
241
+ query += " ORDER BY timestamp DESC"
242
+
243
+ cursor.execute(query, params)
244
+ return [dict(row) for row in cursor.fetchall()]
245
+
246
+ def get_workspace_token_usage(
247
+ self,
248
+ start_date: Optional[datetime] = None,
249
+ end_date: Optional[datetime] = None,
250
+ ) -> List[Dict[str, Any]]:
251
+ """Get all token usage records across the workspace.
252
+
253
+ Args:
254
+ start_date: Optional start of date range (inclusive)
255
+ end_date: Optional end of date range (inclusive)
256
+
257
+ Returns:
258
+ List of token usage records as dictionaries
259
+ """
260
+ cursor = self.conn.cursor()
261
+ query = "SELECT * FROM token_usage WHERE 1=1"
262
+ params: list = []
263
+
264
+ if start_date is not None:
265
+ query += " AND timestamp >= ?"
266
+ params.append(start_date.isoformat())
267
+
268
+ if end_date is not None:
269
+ query += " AND timestamp <= ?"
270
+ params.append(end_date.isoformat())
271
+
272
+ query += " ORDER BY timestamp DESC"
273
+
274
+ cursor.execute(query, params)
275
+ return [dict(row) for row in cursor.fetchall()]
276
+
277
+ def get_costs_summary(self, days: int) -> Dict[str, Any]:
278
+ """Aggregate token_usage costs into daily buckets for analytics.
279
+
280
+ Args:
281
+ days: Number of trailing days to include in the summary.
282
+
283
+ Returns:
284
+ Dictionary with keys:
285
+ total_spend_usd: float — sum of estimated_cost_usd in window
286
+ total_tasks: int — distinct task_id count (excludes NULL)
287
+ avg_cost_per_task: float — total_spend_usd / total_tasks (0 if no tasks)
288
+ daily: list of {"date": "YYYY-MM-DD", "cost_usd": float}
289
+ — one entry per day in the window, oldest first,
290
+ zero-filled for days with no spend.
291
+ """
292
+ if days <= 0:
293
+ raise ValueError("days must be a positive integer")
294
+
295
+ now_utc = datetime.now(timezone.utc)
296
+ # Inclusive window starting at midnight UTC, `days` calendar days back.
297
+ # Use a space-separated, offset-free format so lexicographic comparison
298
+ # works against both `CURRENT_TIMESTAMP` defaults ("YYYY-MM-DD HH:MM:SS")
299
+ # and Python `.isoformat()` outputs ("YYYY-MM-DDTHH:MM:SS+00:00").
300
+ end_date = now_utc.date()
301
+ start_date = end_date - timedelta(days=days - 1)
302
+ start_iso = start_date.strftime("%Y-%m-%d %H:%M:%S")
303
+ # Exclusive upper bound = midnight after today, so the daily chart and
304
+ # the KPI cards always cover the same set of rows even if some records
305
+ # are future-dated (clock skew, bad seed data).
306
+ end_iso = (end_date + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
307
+
308
+ cursor = self.conn.cursor()
309
+
310
+ # Totals over the window. total_spend includes NULL-task records so it
311
+ # matches the chart; total_tasks only counts records linked to a task.
312
+ cursor.execute(
313
+ """
314
+ SELECT
315
+ COALESCE(SUM(estimated_cost_usd), 0.0) AS total_spend,
316
+ COUNT(DISTINCT CASE WHEN task_id IS NOT NULL THEN task_id END) AS task_count
317
+ FROM token_usage
318
+ WHERE timestamp >= ? AND timestamp < ?
319
+ """,
320
+ (start_iso, end_iso),
321
+ )
322
+ totals = cursor.fetchone()
323
+ total_spend = float(totals["total_spend"] or 0.0)
324
+ total_tasks = int(totals["task_count"] or 0)
325
+ avg_cost = (total_spend / total_tasks) if total_tasks > 0 else 0.0
326
+
327
+ # Daily aggregation — group by calendar date in UTC
328
+ cursor.execute(
329
+ """
330
+ SELECT
331
+ DATE(timestamp) AS day,
332
+ COALESCE(SUM(estimated_cost_usd), 0.0) AS cost
333
+ FROM token_usage
334
+ WHERE timestamp >= ? AND timestamp < ?
335
+ GROUP BY DATE(timestamp)
336
+ """,
337
+ (start_iso, end_iso),
338
+ )
339
+ by_day: Dict[str, float] = {row["day"]: float(row["cost"] or 0.0) for row in cursor.fetchall()}
340
+
341
+ daily: List[Dict[str, Any]] = []
342
+ for offset in range(days):
343
+ d = start_date + timedelta(days=offset)
344
+ iso = d.isoformat()
345
+ daily.append({"date": iso, "cost_usd": by_day.get(iso, 0.0)})
346
+
347
+ return {
348
+ "total_spend_usd": total_spend,
349
+ "total_tasks": total_tasks,
350
+ "avg_cost_per_task": avg_cost,
351
+ "daily": daily,
352
+ }
353
+
354
+ def _window_iso_bounds(self, days: int) -> tuple[str, str]:
355
+ """Return inclusive start / exclusive end ISO strings for a `days` window.
356
+
357
+ Mirrors get_costs_summary's bounds so the per-task and per-agent
358
+ aggregations cover the same rows. Space-separated, offset-free format
359
+ works against both ``CURRENT_TIMESTAMP`` defaults and ``.isoformat()``.
360
+ """
361
+ if days <= 0:
362
+ raise ValueError("days must be a positive integer")
363
+ end_date = datetime.now(timezone.utc).date()
364
+ start_date = end_date - timedelta(days=days - 1)
365
+ start_iso = start_date.strftime("%Y-%m-%d %H:%M:%S")
366
+ end_iso = (end_date + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
367
+ return start_iso, end_iso
368
+
369
+ def get_top_tasks_by_cost(
370
+ self,
371
+ days: int,
372
+ limit: int = 10,
373
+ ) -> List[Dict[str, Any]]:
374
+ """Aggregate spend per task and return the top N by cost.
375
+
376
+ Args:
377
+ days: Trailing window in days.
378
+ limit: Maximum number of tasks to return.
379
+
380
+ Returns:
381
+ List of dicts, sorted by total_cost_usd DESC:
382
+ {
383
+ "task_id": <native value from token_usage.task_id>,
384
+ "agent_id": str,
385
+ "input_tokens": int,
386
+ "output_tokens": int,
387
+ "total_cost_usd": float,
388
+ }
389
+ Excludes rows where task_id IS NULL. The reported ``agent_id`` is
390
+ the agent that made the most calls for that task (ties broken
391
+ arbitrarily). ``task_id`` is returned as stored — SQLite preserves
392
+ the inserted type, so v2 UUID strings come back as strings and v1
393
+ integers come back as integers.
394
+ """
395
+ if limit <= 0:
396
+ raise ValueError("limit must be a positive integer")
397
+ start_iso, end_iso = self._window_iso_bounds(days)
398
+
399
+ cursor = self.conn.cursor()
400
+ cursor.execute(
401
+ """
402
+ SELECT
403
+ task_id,
404
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
405
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
406
+ COALESCE(SUM(estimated_cost_usd), 0.0) AS total_cost_usd
407
+ FROM token_usage
408
+ WHERE task_id IS NOT NULL
409
+ AND timestamp >= ?
410
+ AND timestamp < ?
411
+ GROUP BY task_id
412
+ ORDER BY total_cost_usd DESC
413
+ LIMIT ?
414
+ """,
415
+ (start_iso, end_iso, limit),
416
+ )
417
+ rows = cursor.fetchall()
418
+
419
+ # TODO(perf): the dominant-agent lookup is N+1 against the limit.
420
+ # Acceptable at limit=10 (analytics view) and even limit=1000 (badge
421
+ # map for a board). Fold into a single CTE if the cap grows further.
422
+ result: List[Dict[str, Any]] = []
423
+ for row in rows:
424
+ task_id = row["task_id"]
425
+ # Find the most-used agent for this task in the same window.
426
+ cursor.execute(
427
+ """
428
+ SELECT agent_id, COUNT(*) AS calls
429
+ FROM token_usage
430
+ WHERE task_id = ?
431
+ AND timestamp >= ?
432
+ AND timestamp < ?
433
+ GROUP BY agent_id
434
+ ORDER BY calls DESC
435
+ LIMIT 1
436
+ """,
437
+ (task_id, start_iso, end_iso),
438
+ )
439
+ agent_row = cursor.fetchone()
440
+ agent_id = agent_row["agent_id"] if agent_row else ""
441
+
442
+ result.append({
443
+ "task_id": task_id,
444
+ "agent_id": agent_id,
445
+ "input_tokens": int(row["input_tokens"] or 0),
446
+ "output_tokens": int(row["output_tokens"] or 0),
447
+ "total_cost_usd": float(row["total_cost_usd"] or 0.0),
448
+ })
449
+
450
+ return result
451
+
452
+ def get_costs_by_agent(self, days: int) -> Dict[str, Any]:
453
+ """Aggregate spend per agent over a trailing `days` window.
454
+
455
+ Args:
456
+ days: Trailing window in days.
457
+
458
+ Returns:
459
+ {
460
+ "by_agent": [
461
+ {
462
+ "agent_id": str,
463
+ "input_tokens": int,
464
+ "output_tokens": int,
465
+ "total_cost_usd": float,
466
+ "call_count": int,
467
+ },
468
+ ...
469
+ ],
470
+ "total_input_tokens": int,
471
+ "total_output_tokens": int,
472
+ }
473
+
474
+ Includes records with NULL ``task_id`` — calls without a task still
475
+ attribute to an agent. Sorted by total_cost_usd DESC.
476
+ """
477
+ start_iso, end_iso = self._window_iso_bounds(days)
478
+
479
+ cursor = self.conn.cursor()
480
+ cursor.execute(
481
+ """
482
+ SELECT
483
+ agent_id,
484
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
485
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
486
+ COALESCE(SUM(estimated_cost_usd), 0.0) AS total_cost_usd,
487
+ COUNT(*) AS call_count
488
+ FROM token_usage
489
+ WHERE timestamp >= ? AND timestamp < ?
490
+ GROUP BY agent_id
491
+ ORDER BY total_cost_usd DESC
492
+ """,
493
+ (start_iso, end_iso),
494
+ )
495
+ rows = cursor.fetchall()
496
+
497
+ by_agent: List[Dict[str, Any]] = []
498
+ total_input = 0
499
+ total_output = 0
500
+ for row in rows:
501
+ inp = int(row["input_tokens"] or 0)
502
+ out = int(row["output_tokens"] or 0)
503
+ by_agent.append({
504
+ "agent_id": row["agent_id"],
505
+ "input_tokens": inp,
506
+ "output_tokens": out,
507
+ "total_cost_usd": float(row["total_cost_usd"] or 0.0),
508
+ "call_count": int(row["call_count"] or 0),
509
+ })
510
+ total_input += inp
511
+ total_output += out
512
+
513
+ return {
514
+ "by_agent": by_agent,
515
+ "total_input_tokens": total_input,
516
+ "total_output_tokens": total_output,
517
+ }
518
+
519
+ def get_project_costs_aggregate(self, project_id: int) -> Dict[str, Any]:
520
+ """Get aggregated cost statistics for a project.
521
+
522
+ This is a convenience method that aggregates costs by agent and model
523
+ in a single database query for better performance.
524
+
525
+ Args:
526
+ project_id: Project ID
527
+
528
+ Returns:
529
+ Dictionary with aggregated costs:
530
+ {
531
+ "total_cost": float,
532
+ "total_tokens": int,
533
+ "by_agent": {...},
534
+ "by_model": {...}
535
+ }
536
+
537
+ Example:
538
+ >>> stats = db.get_project_costs_aggregate(project_id=1)
539
+ >>> print(f"Total: ${stats['total_cost']:.2f}")
540
+ """
541
+ cursor = self.conn.cursor()
542
+
543
+ # Get overall totals
544
+ cursor.execute(
545
+ """
546
+ SELECT
547
+ COALESCE(SUM(estimated_cost_usd), 0) as total_cost,
548
+ COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
549
+ COUNT(*) as total_calls
550
+ FROM token_usage
551
+ WHERE project_id = ?
552
+ """,
553
+ (project_id,),
554
+ )
555
+ totals = cursor.fetchone()
556
+
557
+ # Get breakdown by agent
558
+ cursor.execute(
559
+ """
560
+ SELECT
561
+ agent_id,
562
+ SUM(estimated_cost_usd) as cost,
563
+ SUM(input_tokens + output_tokens) as tokens,
564
+ COUNT(*) as calls
565
+ FROM token_usage
566
+ WHERE project_id = ?
567
+ GROUP BY agent_id
568
+ ORDER BY cost DESC
569
+ """,
570
+ (project_id,),
571
+ )
572
+ by_agent = [dict(row) for row in cursor.fetchall()]
573
+
574
+ # Get breakdown by model
575
+ cursor.execute(
576
+ """
577
+ SELECT
578
+ model_name,
579
+ SUM(estimated_cost_usd) as cost,
580
+ SUM(input_tokens + output_tokens) as tokens,
581
+ COUNT(*) as calls
582
+ FROM token_usage
583
+ WHERE project_id = ?
584
+ GROUP BY model_name
585
+ ORDER BY cost DESC
586
+ """,
587
+ (project_id,),
588
+ )
589
+ by_model = [dict(row) for row in cursor.fetchall()]
590
+
591
+ return {
592
+ "total_cost": totals["total_cost"],
593
+ "total_tokens": totals["total_tokens"],
594
+ "total_calls": totals["total_calls"],
595
+ "by_agent": by_agent,
596
+ "by_model": by_model,
597
+ }
598
+