agy-token-stats 0.1.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.
@@ -0,0 +1,96 @@
1
+ """Antigravity (`agy`) token usage statistics and subagent tree analyzer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agy_token_stats.aggregator import (
6
+ _collect_descendants,
7
+ _find_root_session_id,
8
+ _resolve_root_id,
9
+ analyze_sessions,
10
+ compute_stats_report,
11
+ )
12
+ from agy_token_stats.cli import (
13
+ _resolve_brain_sources,
14
+ build_arg_parser,
15
+ main,
16
+ resolve_brain_sources,
17
+ )
18
+ from agy_token_stats.discovery import (
19
+ DEFAULT_CLI_BRAIN_DIR,
20
+ DEFAULT_CLI_HISTORY_PATH,
21
+ DEFAULT_IDE_BRAIN_DIR,
22
+ SKIPPED_BRAIN_DIRS,
23
+ _load_subagent_descriptors,
24
+ _safe_is_dir,
25
+ discover_sessions,
26
+ infer_source_kind,
27
+ load_cli_history_metadata,
28
+ )
29
+ from agy_token_stats.formatters import (
30
+ format_human_report,
31
+ format_json_report,
32
+ )
33
+ from agy_token_stats.models import (
34
+ DailyStats,
35
+ SessionRecord,
36
+ SessionSummary,
37
+ StatsReport,
38
+ StepRecord,
39
+ SubagentLink,
40
+ TimeWindow,
41
+ TokenMetrics,
42
+ )
43
+ from agy_token_stats.parser import (
44
+ _clean_prompt_preview,
45
+ _safe_is_file,
46
+ _step_text_chars,
47
+ parse_transcript_file,
48
+ )
49
+ from agy_token_stats.time_window import (
50
+ get_default_local_tz,
51
+ parse_bound_datetime,
52
+ parse_iso_timestamp,
53
+ resolve_time_window,
54
+ )
55
+
56
+ __version__ = "0.1.0"
57
+
58
+ __all__ = [
59
+ "__version__",
60
+ "DEFAULT_CLI_BRAIN_DIR",
61
+ "DEFAULT_CLI_HISTORY_PATH",
62
+ "DEFAULT_IDE_BRAIN_DIR",
63
+ "SKIPPED_BRAIN_DIRS",
64
+ "DailyStats",
65
+ "SessionRecord",
66
+ "SessionSummary",
67
+ "StatsReport",
68
+ "StepRecord",
69
+ "SubagentLink",
70
+ "TimeWindow",
71
+ "TokenMetrics",
72
+ "_clean_prompt_preview",
73
+ "_collect_descendants",
74
+ "_find_root_session_id",
75
+ "_load_subagent_descriptors",
76
+ "_resolve_brain_sources",
77
+ "_resolve_root_id",
78
+ "_safe_is_dir",
79
+ "_safe_is_file",
80
+ "_step_text_chars",
81
+ "analyze_sessions",
82
+ "build_arg_parser",
83
+ "compute_stats_report",
84
+ "discover_sessions",
85
+ "format_human_report",
86
+ "format_json_report",
87
+ "get_default_local_tz",
88
+ "infer_source_kind",
89
+ "load_cli_history_metadata",
90
+ "main",
91
+ "parse_bound_datetime",
92
+ "parse_iso_timestamp",
93
+ "parse_transcript_file",
94
+ "resolve_brain_sources",
95
+ "resolve_time_window",
96
+ ]
@@ -0,0 +1,8 @@
1
+ """Module entry point for `python3 -m agy_token_stats`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agy_token_stats.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,412 @@
1
+ """Token aggregation, model breakdown/filtering, and subagent tree/flat rollup computation.
2
+
3
+ Invariants:
4
+ - Every matched step is counted once in `overall`, `by_source`, `by_role`, `by_model`,
5
+ and `daily`.
6
+ - Orphan subagents (whose `parent_id` is absent from `sessions`) and subagent-rooted
7
+ queries (`--session <subagent>`) are never miscounted in `active_root_sessions` or
8
+ `daily_roots`.
9
+ - Multi-level subagent trees (`Root -> SubA -> SubB`) accurately count both
10
+ `active_subagent_count` (only descendants with active `self_metrics` in the window)
11
+ and `total_subagent_count` (all descendant subagents in `allowed_ids`).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping, Sequence
17
+ from datetime import datetime
18
+
19
+ from agy_token_stats.models import (
20
+ DailyStats,
21
+ SessionRecord,
22
+ SessionSummary,
23
+ StatsReport,
24
+ StepRecord,
25
+ TimeWindow,
26
+ TokenMetrics,
27
+ )
28
+
29
+
30
+ def _resolve_root_id(cid: str, sessions: Mapping[str, SessionRecord]) -> str:
31
+ """Walk `parent_id` links up to the highest present ancestor in `sessions`."""
32
+ curr = cid
33
+ seen: set[str] = {curr}
34
+ while True:
35
+ rec = sessions.get(curr)
36
+ if rec is None or rec.parent_id is None or rec.parent_id not in sessions:
37
+ return curr
38
+ if rec.parent_id in seen:
39
+ return curr
40
+ seen.add(rec.parent_id)
41
+ curr = rec.parent_id
42
+
43
+
44
+ _find_root_session_id = _resolve_root_id
45
+
46
+
47
+ def _collect_descendants(
48
+ root_id: str, sessions: Mapping[str, SessionRecord]
49
+ ) -> set[str]:
50
+ """Collect `root_id` and all recursive `child_ids` present in `sessions`."""
51
+ collected: set[str] = set()
52
+ queue = [root_id]
53
+ while queue:
54
+ curr = queue.pop(0)
55
+ if curr in collected or curr not in sessions:
56
+ continue
57
+ collected.add(curr)
58
+ for ch_id in sessions[curr].child_ids:
59
+ if ch_id not in collected:
60
+ queue.append(ch_id)
61
+ return collected
62
+
63
+
64
+ def _step_effective_model(st: StepRecord, rec: SessionRecord) -> str:
65
+ """Return the effective model name for `st` in `rec`, or `'Unknown'` if none."""
66
+ return (st.model or rec.model or "").strip() or "Unknown"
67
+
68
+
69
+ def compute_stats_report(
70
+ sessions: Mapping[str, SessionRecord],
71
+ *,
72
+ window: TimeWindow | None = None,
73
+ by_session: bool = False,
74
+ flat: bool = False,
75
+ session_filter: str | None = None,
76
+ model_filter: str | None = None,
77
+ sources_scanned: Sequence[str] | None = None,
78
+ ) -> StatsReport:
79
+ """Compute token statistics across `sessions` under the given time window and hierarchy mode.
80
+
81
+ Filtering behavior:
82
+ - If `session_filter` is set, matches sessions whose `conversation_id` starts with
83
+ `session_filter` (case-insensitive). In tree mode (`flat=False`), matching a session
84
+ also includes all of its descendant subagent sessions.
85
+ - If `by_session=False` (default), filters individual steps by `window.contains(step.created_at_local)`.
86
+ - If `by_session=True`, includes all steps of a session when the session's start time
87
+ (or its root session's start time in tree mode) falls within `window`.
88
+ - If `model_filter` is set, performs a case-insensitive substring match against the
89
+ effective step/session model name.
90
+ """
91
+ active_window = window or TimeWindow(label="all-time")
92
+ scanned = (
93
+ list(sources_scanned)
94
+ if sources_scanned is not None
95
+ else sorted({s.source_kind for s in sessions.values()})
96
+ )
97
+ normalized_model_filter = (
98
+ model_filter.strip() if model_filter and model_filter.strip() else None
99
+ )
100
+ model_needle = (
101
+ normalized_model_filter.lower() if normalized_model_filter else None
102
+ )
103
+
104
+ # Filter candidate session IDs by --session prefix if requested
105
+ if session_filter:
106
+ prefix = session_filter.strip().lower()
107
+ matched_roots = [cid for cid in sessions if cid.startswith(prefix)]
108
+ if flat:
109
+ allowed_ids = set(matched_roots)
110
+ else:
111
+ allowed_ids = set()
112
+ for mid in matched_roots:
113
+ allowed_ids |= _collect_descendants(mid, sessions)
114
+ else:
115
+ allowed_ids = set(sessions.keys())
116
+
117
+ # Compute filtered steps per session
118
+ filtered_steps_by_id: dict[str, list[StepRecord]] = {}
119
+ self_metrics_by_id: dict[str, TokenMetrics] = {}
120
+ first_active_by_id: dict[str, datetime | None] = {}
121
+ last_active_by_id: dict[str, datetime | None] = {}
122
+ active_models_by_id: dict[str, list[str]] = {}
123
+
124
+ for cid in sorted(allowed_ids):
125
+ rec = sessions[cid]
126
+ if by_session:
127
+ anchor_id = cid if flat else _resolve_root_id(cid, sessions)
128
+ anchor_rec = sessions.get(anchor_id, rec)
129
+ anchor_start = anchor_rec.started_at_local
130
+ if anchor_start is not None and active_window.contains(anchor_start):
131
+ matching_steps = list(rec.steps)
132
+ else:
133
+ matching_steps = []
134
+ else:
135
+ matching_steps = [
136
+ st for st in rec.steps if active_window.contains(st.created_at_local)
137
+ ]
138
+
139
+ if model_needle is not None:
140
+ matching_steps = [
141
+ st
142
+ for st in matching_steps
143
+ if model_needle in _step_effective_model(st, rec).lower()
144
+ ]
145
+
146
+ metrics = TokenMetrics()
147
+ first_dt: datetime | None = None
148
+ last_dt: datetime | None = None
149
+ step_models: list[str] = []
150
+ for st in matching_steps:
151
+ metrics.add_step(st)
152
+ if st.step_type in ("PLANNER_RESPONSE", "USER_INPUT"):
153
+ if first_dt is None or st.created_at_local < first_dt:
154
+ first_dt = st.created_at_local
155
+ if last_dt is None or st.created_at_local > last_dt:
156
+ last_dt = st.created_at_local
157
+ eff_m = (st.model or rec.model or "").strip()
158
+ if eff_m and eff_m not in step_models:
159
+ step_models.append(eff_m)
160
+
161
+ if metrics.has_activity and not step_models and rec.model:
162
+ step_models = list(rec.models_used) if rec.models_used else [rec.model]
163
+
164
+ filtered_steps_by_id[cid] = matching_steps
165
+ self_metrics_by_id[cid] = metrics
166
+ first_active_by_id[cid] = first_dt
167
+ last_active_by_id[cid] = last_dt
168
+ active_models_by_id[cid] = step_models
169
+
170
+ # Aggregate overall, by_source, by_role, by_model, and daily without double counting
171
+ overall = TokenMetrics()
172
+ by_source: dict[str, TokenMetrics] = {}
173
+ by_role: dict[str, TokenMetrics] = {}
174
+ by_model: dict[str, TokenMetrics] = {}
175
+ by_source_sessions: dict[str, int] = {}
176
+ by_role_sessions: dict[str, int] = {}
177
+ by_model_sessions: dict[str, int] = {}
178
+
179
+ daily_metrics: dict[str, TokenMetrics] = {}
180
+ daily_roots: dict[str, set[str]] = {}
181
+ daily_subagents: dict[str, set[str]] = {}
182
+
183
+ active_subagent_ids: set[str] = set()
184
+
185
+ for cid in sorted(allowed_ids):
186
+ rec = sessions[cid]
187
+ s_metrics = self_metrics_by_id[cid]
188
+ if not s_metrics.has_activity:
189
+ continue
190
+
191
+ is_subagent = rec.parent_id is not None or rec.agent_type != "root"
192
+ if is_subagent:
193
+ active_subagent_ids.add(cid)
194
+
195
+ overall = overall.merge(s_metrics)
196
+
197
+ src = rec.source_kind
198
+ by_source[src] = by_source.get(src, TokenMetrics()).merge(s_metrics)
199
+ by_source_sessions[src] = by_source_sessions.get(src, 0) + 1
200
+
201
+ role_key = rec.agent_type or ("subagent" if is_subagent else "root")
202
+ by_role[role_key] = by_role.get(role_key, TokenMetrics()).merge(s_metrics)
203
+ by_role_sessions[role_key] = by_role_sessions.get(role_key, 0) + 1
204
+
205
+ session_model_accs: dict[str, TokenMetrics] = {}
206
+ root_for_day = cid if flat else _resolve_root_id(cid, sessions)
207
+ for st in filtered_steps_by_id[cid]:
208
+ if st.step_type not in ("PLANNER_RESPONSE", "USER_INPUT"):
209
+ continue
210
+
211
+ m_key = _step_effective_model(st, rec)
212
+ session_model_accs.setdefault(m_key, TokenMetrics()).add_step(st)
213
+
214
+ day_key = st.created_at_local.strftime("%Y-%m-%d")
215
+ d_acc = daily_metrics.setdefault(day_key, TokenMetrics())
216
+ d_acc.add_step(st)
217
+ if is_subagent and not flat:
218
+ daily_subagents.setdefault(day_key, set()).add(cid)
219
+ if (
220
+ root_for_day != cid
221
+ and root_for_day in allowed_ids
222
+ and sessions[root_for_day].parent_id is None
223
+ and sessions[root_for_day].agent_type == "root"
224
+ ):
225
+ daily_roots.setdefault(day_key, set()).add(root_for_day)
226
+ elif is_subagent and flat:
227
+ daily_subagents.setdefault(day_key, set()).add(cid)
228
+ else:
229
+ daily_roots.setdefault(day_key, set()).add(cid)
230
+
231
+ for m_key, m_acc in session_model_accs.items():
232
+ if m_acc.has_activity:
233
+ by_model[m_key] = by_model.get(m_key, TokenMetrics()).merge(m_acc)
234
+ by_model_sessions[m_key] = by_model_sessions.get(m_key, 0) + 1
235
+
236
+ # Build SessionSummary objects (flat vs tree)
237
+ if flat:
238
+ summaries: list[SessionSummary] = []
239
+ active_root_count = 0
240
+ for cid in sorted(allowed_ids):
241
+ rec = sessions[cid]
242
+ s_metrics = self_metrics_by_id[cid]
243
+ if not s_metrics.has_activity:
244
+ continue
245
+ is_subagent = rec.parent_id is not None or rec.agent_type != "root"
246
+ if not is_subagent:
247
+ active_root_count += 1
248
+ s_models = active_models_by_id[cid]
249
+ s_model_str = ", ".join(s_models) if s_models else rec.model
250
+ summaries.append(
251
+ SessionSummary(
252
+ conversation_id=cid,
253
+ source_kind=rec.source_kind,
254
+ parent_id=rec.parent_id,
255
+ agent_type=rec.agent_type,
256
+ agent_role=rec.agent_role,
257
+ title=rec.title,
258
+ workspace=rec.workspace,
259
+ model=s_model_str,
260
+ first_active_local=first_active_by_id[cid],
261
+ last_active_local=last_active_by_id[cid],
262
+ self_metrics=s_metrics,
263
+ tree_metrics=s_metrics,
264
+ children=[],
265
+ active_subagent_count=0,
266
+ total_subagent_count=len(rec.child_ids),
267
+ models=list(s_models),
268
+ self_model=s_model_str,
269
+ )
270
+ )
271
+ else:
272
+ # Top-level roots in allowed_ids are sessions whose parent_id is not in allowed_ids
273
+ top_level_ids = [
274
+ cid
275
+ for cid in sorted(allowed_ids)
276
+ if sessions[cid].parent_id is None
277
+ or sessions[cid].parent_id not in allowed_ids
278
+ ]
279
+
280
+ visited_tree: set[str] = set()
281
+
282
+ def _build_tree_summary(cid: str) -> SessionSummary | None:
283
+ if cid in visited_tree or cid not in allowed_ids:
284
+ return None
285
+ visited_tree.add(cid)
286
+ rec = sessions[cid]
287
+ self_m = self_metrics_by_id[cid]
288
+ tree_m = self_m
289
+ child_summaries: list[SessionSummary] = []
290
+ first_dt = first_active_by_id[cid]
291
+ last_dt = last_active_by_id[cid]
292
+ active_subs = 0
293
+ total_subs = len(_collect_descendants(cid, sessions) & allowed_ids) - 1
294
+ self_models = list(active_models_by_id[cid]) if self_m.has_activity else []
295
+ self_model_str = ", ".join(self_models) if self_models else rec.model
296
+ combined_models: list[str] = list(self_models)
297
+
298
+ for ch_id in rec.child_ids:
299
+ if ch_id not in allowed_ids:
300
+ continue
301
+ ch_sum = _build_tree_summary(ch_id)
302
+ if ch_sum is None:
303
+ continue
304
+ if ch_sum.tree_metrics.has_activity:
305
+ child_summaries.append(ch_sum)
306
+ tree_m = tree_m.merge(ch_sum.tree_metrics)
307
+ active_subs += (
308
+ 1 if ch_sum.self_metrics.has_activity else 0
309
+ ) + ch_sum.active_subagent_count
310
+ for cm in ch_sum.models:
311
+ if cm and cm not in combined_models:
312
+ combined_models.append(cm)
313
+ if ch_sum.first_active_local is not None and (
314
+ first_dt is None or ch_sum.first_active_local < first_dt
315
+ ):
316
+ first_dt = ch_sum.first_active_local
317
+ if ch_sum.last_active_local is not None and (
318
+ last_dt is None or ch_sum.last_active_local > last_dt
319
+ ):
320
+ last_dt = ch_sum.last_active_local
321
+
322
+ if not tree_m.has_activity:
323
+ return None
324
+
325
+ if not combined_models:
326
+ combined_models = list(active_models_by_id[cid])
327
+ model_str = ", ".join(combined_models) if combined_models else rec.model
328
+
329
+ return SessionSummary(
330
+ conversation_id=cid,
331
+ source_kind=rec.source_kind,
332
+ parent_id=rec.parent_id,
333
+ agent_type=rec.agent_type,
334
+ agent_role=rec.agent_role,
335
+ title=rec.title,
336
+ workspace=rec.workspace,
337
+ model=model_str,
338
+ first_active_local=first_dt,
339
+ last_active_local=last_dt,
340
+ self_metrics=self_m,
341
+ tree_metrics=tree_m,
342
+ children=child_summaries,
343
+ active_subagent_count=active_subs,
344
+ total_subagent_count=total_subs,
345
+ models=combined_models,
346
+ self_model=self_model_str,
347
+ )
348
+
349
+ summaries = []
350
+ for top_id in top_level_ids:
351
+ s_sum = _build_tree_summary(top_id)
352
+ if s_sum is not None:
353
+ summaries.append(s_sum)
354
+ active_root_count = sum(
355
+ 1
356
+ for s in summaries
357
+ if s.parent_id is None and s.agent_type == "root"
358
+ )
359
+
360
+ summaries.sort(
361
+ key=lambda s: (
362
+ s.last_active_local.timestamp() if s.last_active_local is not None else 0.0,
363
+ s.tree_metrics.total_tokens,
364
+ s.conversation_id,
365
+ ),
366
+ reverse=True,
367
+ )
368
+
369
+ daily_list = [
370
+ DailyStats(
371
+ date_str=day_str,
372
+ active_root_sessions=len(daily_roots.get(day_str, set())),
373
+ active_subagent_sessions=len(daily_subagents.get(day_str, set())),
374
+ metrics=daily_metrics[day_str],
375
+ )
376
+ for day_str in sorted(daily_metrics.keys())
377
+ ]
378
+
379
+ sorted_by_source = dict(
380
+ sorted(by_source.items(), key=lambda kv: (-kv[1].total_tokens, kv[0]))
381
+ )
382
+ sorted_by_role = dict(
383
+ sorted(by_role.items(), key=lambda kv: (-kv[1].total_tokens, kv[0]))
384
+ )
385
+ sorted_by_model = dict(
386
+ sorted(
387
+ by_model.items(),
388
+ key=lambda kv: (-kv[1].total_tokens, -kv[1].llm_calls, kv[0]),
389
+ )
390
+ )
391
+
392
+ return StatsReport(
393
+ window=active_window,
394
+ by_session_mode=by_session,
395
+ flat_mode=flat,
396
+ sources_scanned=scanned,
397
+ active_root_sessions=active_root_count,
398
+ active_subagent_sessions=len(active_subagent_ids),
399
+ overall=overall,
400
+ by_source=sorted_by_source,
401
+ by_role=sorted_by_role,
402
+ by_source_sessions=by_source_sessions,
403
+ by_role_sessions=by_role_sessions,
404
+ daily=daily_list,
405
+ sessions=summaries,
406
+ by_model=sorted_by_model,
407
+ by_model_sessions=by_model_sessions,
408
+ model_filter=normalized_model_filter,
409
+ )
410
+
411
+
412
+ analyze_sessions = compute_stats_report