usage-cli 0.29.32__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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
analyzer/insights.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from math import sqrt
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
INSIGHT_CHANGE_HEADLINE = "change_headline"
|
|
13
|
+
INSIGHT_SPIKE = "spike"
|
|
14
|
+
INSIGHT_SHIFT = "shift"
|
|
15
|
+
INSIGHT_ACTION = "action"
|
|
16
|
+
|
|
17
|
+
# Insights are anomaly-only: every component below stays silent unless its
|
|
18
|
+
# threshold trips, and an empty list is a valid (common) result — the report
|
|
19
|
+
# renders a quiet "nothing unusual" line instead of padding with filler.
|
|
20
|
+
_SPIKE_MULTIPLIER_THRESHOLD = 3.0
|
|
21
|
+
_CHANGE_SURGE_PCT = 50
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_insights(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
25
|
+
components: list[dict[str, Any]] = []
|
|
26
|
+
|
|
27
|
+
change = _build_change_headline(data)
|
|
28
|
+
if change is not None:
|
|
29
|
+
components.append(change)
|
|
30
|
+
|
|
31
|
+
spike = _find_spike(data.get("daily_trend"))
|
|
32
|
+
if spike is not None:
|
|
33
|
+
components.append(
|
|
34
|
+
{
|
|
35
|
+
"type": INSIGHT_SPIKE,
|
|
36
|
+
"key": "insights_spike_v2",
|
|
37
|
+
"date": spike["date"],
|
|
38
|
+
"tokens": spike["tokens"],
|
|
39
|
+
"mean_multiplier": spike["mean_multiplier"],
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
shift = _build_shift(data)
|
|
44
|
+
if shift is not None:
|
|
45
|
+
components.append(shift)
|
|
46
|
+
|
|
47
|
+
action = _build_action(change, spike, data)
|
|
48
|
+
if action is not None:
|
|
49
|
+
components.append(action)
|
|
50
|
+
|
|
51
|
+
return components[:5]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_change_headline(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
55
|
+
comparison = _mapping_value(data.get("comparison"))
|
|
56
|
+
if comparison is None or not bool(comparison.get("has_prev")):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
prev_tokens = _int_value(comparison.get("prev_tokens"))
|
|
60
|
+
if prev_tokens <= 0:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
summary = _mapping_value(data.get("summary"))
|
|
64
|
+
if summary is None:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
cur_tokens = _int_value(summary.get("total_tokens"))
|
|
68
|
+
delta_pct = round((cur_tokens - prev_tokens) / prev_tokens * 100)
|
|
69
|
+
if delta_pct < _CHANGE_SURGE_PCT:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"type": INSIGHT_CHANGE_HEADLINE,
|
|
74
|
+
"key": "insights_change_up",
|
|
75
|
+
"tokens": cur_tokens,
|
|
76
|
+
"cost_usd": _round_cost(_float_value(summary.get("cost_usd"))),
|
|
77
|
+
"pct": abs(delta_pct),
|
|
78
|
+
"direction": "up",
|
|
79
|
+
"delta_pct": delta_pct,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_shift(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
84
|
+
return _build_new_project_shift(data) or _build_model_shift(data)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_new_project_shift(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
88
|
+
comparison = _mapping_value(data.get("comparison"))
|
|
89
|
+
if comparison is None or not bool(comparison.get("has_prev")):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
prev_projects = {
|
|
93
|
+
_str_value(project, "")
|
|
94
|
+
for project in _list_value(comparison.get("prev_projects"))
|
|
95
|
+
}
|
|
96
|
+
for project in _list_value(data.get("by_project")):
|
|
97
|
+
item = _mapping_value(project)
|
|
98
|
+
if item is None:
|
|
99
|
+
continue
|
|
100
|
+
name = _str_value(item.get("project"), "unknown")
|
|
101
|
+
pct = _float_value(item.get("pct"))
|
|
102
|
+
if pct >= 15.0 and name not in prev_projects:
|
|
103
|
+
return {
|
|
104
|
+
"type": INSIGHT_SHIFT,
|
|
105
|
+
"key": "insights_shift_new_project",
|
|
106
|
+
"project": name,
|
|
107
|
+
"pct": _round_pct(pct),
|
|
108
|
+
}
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _build_model_shift(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
113
|
+
comparison = _mapping_value(data.get("comparison"))
|
|
114
|
+
if comparison is None or not bool(comparison.get("has_prev")):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
top_model = _first_mapping(data.get("by_model"))
|
|
118
|
+
prev_model_share = _mapping_value(comparison.get("prev_model_share"))
|
|
119
|
+
if top_model is None or prev_model_share is None:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
model = _str_value(top_model.get("model"), "unknown")
|
|
123
|
+
pct = _float_value(top_model.get("pct"))
|
|
124
|
+
prev_pct = _float_value(prev_model_share.get(model))
|
|
125
|
+
if pct - prev_pct < 10.0:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
top_project = _str_value(top_model.get("top_project"), "")
|
|
129
|
+
if top_project and top_project != "unknown":
|
|
130
|
+
return {
|
|
131
|
+
"type": INSIGHT_SHIFT,
|
|
132
|
+
"key": "insights_shift_model_up",
|
|
133
|
+
"model": model,
|
|
134
|
+
"prev_pct": _round_pct(prev_pct),
|
|
135
|
+
"pct": _round_pct(pct),
|
|
136
|
+
"project": top_project,
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
"type": INSIGHT_SHIFT,
|
|
140
|
+
"key": "insights_shift_model_up_plain",
|
|
141
|
+
"model": model,
|
|
142
|
+
"prev_pct": _round_pct(prev_pct),
|
|
143
|
+
"pct": _round_pct(pct),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _build_action(
|
|
148
|
+
change: dict[str, Any] | None,
|
|
149
|
+
spike: dict[str, Any] | None,
|
|
150
|
+
data: dict[str, Any],
|
|
151
|
+
) -> dict[str, Any] | None:
|
|
152
|
+
if (
|
|
153
|
+
change is not None
|
|
154
|
+
and change.get("direction") == "up"
|
|
155
|
+
and _int_value(change.get("delta_pct")) >= 50
|
|
156
|
+
):
|
|
157
|
+
return {"type": INSIGHT_ACTION, "key": "insights_action_watch_quota"}
|
|
158
|
+
|
|
159
|
+
if spike is not None:
|
|
160
|
+
summary = _mapping_value(data.get("summary"))
|
|
161
|
+
total_tokens = _int_value(summary.get("total_tokens")) if summary else 0
|
|
162
|
+
spike_tokens = _int_value(spike.get("tokens"))
|
|
163
|
+
if total_tokens <= 0:
|
|
164
|
+
return None
|
|
165
|
+
return {
|
|
166
|
+
"type": INSIGHT_ACTION,
|
|
167
|
+
"key": "insights_action_spike_share",
|
|
168
|
+
"date": spike["date"],
|
|
169
|
+
"share": round(spike_tokens / total_tokens * 100),
|
|
170
|
+
}
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _find_spike(raw_daily: object) -> dict[str, Any] | None:
|
|
175
|
+
daily = _daily_points(raw_daily)
|
|
176
|
+
if len(daily) < 2:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
token_values = [point["tokens"] for point in daily]
|
|
180
|
+
mean = sum(token_values) / len(token_values)
|
|
181
|
+
if mean <= 0.0:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
variance = sum((tokens - mean) ** 2 for tokens in token_values) / len(token_values)
|
|
185
|
+
stdev = sqrt(variance)
|
|
186
|
+
threshold = mean + stdev
|
|
187
|
+
|
|
188
|
+
candidates = [
|
|
189
|
+
point
|
|
190
|
+
for point in daily
|
|
191
|
+
if point["tokens"] > threshold
|
|
192
|
+
and point["tokens"] >= mean * _SPIKE_MULTIPLIER_THRESHOLD
|
|
193
|
+
]
|
|
194
|
+
if not candidates:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
spike = sorted(candidates, key=lambda point: (-point["tokens"], point["date"]))[0]
|
|
198
|
+
return {
|
|
199
|
+
"date": spike["date"],
|
|
200
|
+
"tokens": spike["tokens"],
|
|
201
|
+
"cost_usd": _round_cost(spike["cost"]),
|
|
202
|
+
"mean_tokens": round(mean, 1),
|
|
203
|
+
"stdev_tokens": round(stdev, 1),
|
|
204
|
+
"mean_multiplier": round(spike["tokens"] / mean, 2),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _daily_points(raw_daily: object) -> list[dict[str, Any]]:
|
|
209
|
+
daily = _list_value(raw_daily)
|
|
210
|
+
points: list[dict[str, Any]] = []
|
|
211
|
+
for raw_point in daily:
|
|
212
|
+
point = _mapping_value(raw_point)
|
|
213
|
+
if point is None:
|
|
214
|
+
continue
|
|
215
|
+
date = _str_value(point.get("date"), "")
|
|
216
|
+
tokens = _int_value(point.get("tokens"))
|
|
217
|
+
if not date or tokens < 0:
|
|
218
|
+
continue
|
|
219
|
+
points.append(
|
|
220
|
+
{
|
|
221
|
+
"date": date,
|
|
222
|
+
"tokens": tokens,
|
|
223
|
+
"cost": _float_value(point.get("cost")),
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
return points
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _first_mapping(value: object) -> dict[str, Any] | None:
|
|
230
|
+
items = _list_value(value)
|
|
231
|
+
if not items:
|
|
232
|
+
return None
|
|
233
|
+
return _mapping_value(items[0])
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _mapping_value(value: object) -> dict[str, Any] | None:
|
|
237
|
+
if isinstance(value, dict):
|
|
238
|
+
return cast(dict[str, Any], value)
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _list_value(value: object) -> list[object]:
|
|
243
|
+
if isinstance(value, list):
|
|
244
|
+
return value
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _str_value(value: object, default: str) -> str:
|
|
249
|
+
if isinstance(value, str):
|
|
250
|
+
return value
|
|
251
|
+
return default
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _int_value(value: object) -> int:
|
|
255
|
+
if isinstance(value, bool):
|
|
256
|
+
return 0
|
|
257
|
+
if isinstance(value, int):
|
|
258
|
+
return value
|
|
259
|
+
if isinstance(value, float):
|
|
260
|
+
return int(value)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _float_value(value: object) -> float:
|
|
265
|
+
if isinstance(value, bool):
|
|
266
|
+
return 0.0
|
|
267
|
+
if isinstance(value, int | float):
|
|
268
|
+
return float(value)
|
|
269
|
+
return 0.0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _round_cost(value: float) -> float:
|
|
273
|
+
return round(value, 4)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _round_pct(value: float) -> float:
|
|
277
|
+
return round(value, 1)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from jsonl_utils import iter_jsonl_dicts
|
|
19
|
+
from project_resolver import project_from_encoded_path, resolve_project_name
|
|
20
|
+
from time_utils import parse_optional_iso8601_utc
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
CLAUDE_PROJECTS_DIR = Path(os.path.expanduser("~/.claude/projects"))
|
|
25
|
+
_CACHE_TTL_SECONDS = 300.0
|
|
26
|
+
_cache: dict[int, tuple[float, PersonaProfile]] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class PersonaProfile:
|
|
31
|
+
hour_histogram: list[int]
|
|
32
|
+
top_projects: list[tuple[str, int]]
|
|
33
|
+
recent_titles: list[str]
|
|
34
|
+
total_sessions: int
|
|
35
|
+
total_messages: int
|
|
36
|
+
titles_by_session: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class _MetadataLine:
|
|
41
|
+
type: str
|
|
42
|
+
timestamp: datetime | None
|
|
43
|
+
session_id: str
|
|
44
|
+
cwd: str
|
|
45
|
+
title: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_profile(days_back: int = 30) -> PersonaProfile:
|
|
49
|
+
now = time.time()
|
|
50
|
+
cached = _cache.get(days_back)
|
|
51
|
+
if cached is not None:
|
|
52
|
+
cached_at, cached_profile = cached
|
|
53
|
+
if now - cached_at < _CACHE_TTL_SECONDS:
|
|
54
|
+
return cached_profile
|
|
55
|
+
|
|
56
|
+
profile = _load_profile_uncached(days_back)
|
|
57
|
+
_cache[days_back] = (now, profile)
|
|
58
|
+
return profile
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _reset_cache() -> None:
|
|
62
|
+
_cache.clear()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_profile_uncached(days_back: int) -> PersonaProfile:
|
|
66
|
+
histogram = [0] * 24
|
|
67
|
+
sessions_by_project: dict[str, set[str]] = {}
|
|
68
|
+
message_sessions: set[str] = set()
|
|
69
|
+
session_last_message_at: dict[str, datetime] = {}
|
|
70
|
+
titles_by_session: dict[str, str] = {}
|
|
71
|
+
total_messages = 0
|
|
72
|
+
|
|
73
|
+
cutoff = datetime.now(UTC) - timedelta(days=max(0, days_back))
|
|
74
|
+
cutoff_ts = cutoff.timestamp()
|
|
75
|
+
|
|
76
|
+
if not CLAUDE_PROJECTS_DIR.is_dir():
|
|
77
|
+
return _empty_profile()
|
|
78
|
+
|
|
79
|
+
for jsonl_path in CLAUDE_PROJECTS_DIR.rglob("*.jsonl"):
|
|
80
|
+
try:
|
|
81
|
+
if jsonl_path.stat().st_mtime < cutoff_ts:
|
|
82
|
+
continue
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
logger.warning("failed to stat Claude project log %s: %s", jsonl_path, exc)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
fallback_project = _project_from_path(jsonl_path)
|
|
88
|
+
try:
|
|
89
|
+
for data in iter_jsonl_dicts(jsonl_path, errors="replace"):
|
|
90
|
+
parsed = _parse_metadata_line(data)
|
|
91
|
+
if parsed is None:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
session_id = parsed.session_id
|
|
95
|
+
if session_id:
|
|
96
|
+
title = parsed.title.strip()
|
|
97
|
+
if parsed.type == "ai-title" and title:
|
|
98
|
+
titles_by_session[session_id] = title
|
|
99
|
+
|
|
100
|
+
timestamp = parsed.timestamp
|
|
101
|
+
if timestamp is None or timestamp < cutoff:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
is_message = parsed.type in {"user", "assistant"}
|
|
105
|
+
if is_message:
|
|
106
|
+
histogram[timestamp.astimezone().hour] += 1
|
|
107
|
+
total_messages += 1
|
|
108
|
+
|
|
109
|
+
if session_id and is_message:
|
|
110
|
+
project = _project_from_cwd(parsed.cwd) or fallback_project
|
|
111
|
+
sessions_by_project.setdefault(project, set()).add(session_id)
|
|
112
|
+
message_sessions.add(session_id)
|
|
113
|
+
current_last = session_last_message_at.get(session_id)
|
|
114
|
+
if current_last is None or timestamp > current_last:
|
|
115
|
+
session_last_message_at[session_id] = timestamp
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
logger.warning("failed to read Claude project log %s: %s", jsonl_path, exc)
|
|
118
|
+
|
|
119
|
+
project_counts = Counter(
|
|
120
|
+
{project: len(session_ids) for project, session_ids in sessions_by_project.items()}
|
|
121
|
+
)
|
|
122
|
+
top_projects = sorted(project_counts.items(), key=lambda item: (-item[1], item[0]))[:5]
|
|
123
|
+
recent_titles = _recent_unique_titles(titles_by_session, session_last_message_at)
|
|
124
|
+
|
|
125
|
+
return PersonaProfile(
|
|
126
|
+
hour_histogram=histogram,
|
|
127
|
+
top_projects=top_projects,
|
|
128
|
+
recent_titles=recent_titles,
|
|
129
|
+
total_sessions=len(message_sessions),
|
|
130
|
+
total_messages=total_messages,
|
|
131
|
+
titles_by_session=titles_by_session,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _empty_profile() -> PersonaProfile:
|
|
136
|
+
return PersonaProfile(
|
|
137
|
+
hour_histogram=[0] * 24,
|
|
138
|
+
top_projects=[],
|
|
139
|
+
recent_titles=[],
|
|
140
|
+
total_sessions=0,
|
|
141
|
+
total_messages=0,
|
|
142
|
+
titles_by_session={},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parse_metadata_line(data: dict[str, Any]) -> _MetadataLine | None:
|
|
147
|
+
return _MetadataLine(
|
|
148
|
+
type=_as_str(data.get("type")),
|
|
149
|
+
timestamp=_parse_timestamp(data.get("timestamp")),
|
|
150
|
+
session_id=_as_str(data.get("sessionId") or data.get("session_id")),
|
|
151
|
+
cwd=_as_str(data.get("cwd")),
|
|
152
|
+
title=_as_str(data.get("aiTitle")),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_timestamp(value: Any) -> datetime | None:
|
|
157
|
+
return parse_optional_iso8601_utc(value)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _project_from_cwd(cwd: str) -> str:
|
|
161
|
+
if not cwd:
|
|
162
|
+
return ""
|
|
163
|
+
return resolve_project_name(cwd)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _project_from_path(jsonl_path: Path) -> str:
|
|
167
|
+
return project_from_encoded_path(jsonl_path, CLAUDE_PROJECTS_DIR)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _recent_unique_titles(
|
|
171
|
+
titles_by_session: dict[str, str],
|
|
172
|
+
session_last_message_at: dict[str, datetime],
|
|
173
|
+
) -> list[str]:
|
|
174
|
+
titles: list[str] = []
|
|
175
|
+
seen: set[str] = set()
|
|
176
|
+
ordered_sessions = sorted(
|
|
177
|
+
titles_by_session,
|
|
178
|
+
key=lambda session_id: session_last_message_at.get(
|
|
179
|
+
session_id,
|
|
180
|
+
datetime.min.replace(tzinfo=UTC),
|
|
181
|
+
),
|
|
182
|
+
reverse=True,
|
|
183
|
+
)
|
|
184
|
+
for session_id in ordered_sessions:
|
|
185
|
+
if session_id not in session_last_message_at:
|
|
186
|
+
continue
|
|
187
|
+
title = titles_by_session[session_id]
|
|
188
|
+
normalized = title.strip()
|
|
189
|
+
if not normalized or normalized in seen:
|
|
190
|
+
continue
|
|
191
|
+
seen.add(normalized)
|
|
192
|
+
titles.append(normalized)
|
|
193
|
+
if len(titles) >= 8:
|
|
194
|
+
break
|
|
195
|
+
return titles
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _as_str(value: Any) -> str:
|
|
199
|
+
return value if isinstance(value, str) else ""
|