tokenatlas 1.3.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.
- tokenatlas/__init__.py +3 -0
- tokenatlas/__main__.py +370 -0
- tokenatlas/history.py +839 -0
- tokenatlas/overhead.py +680 -0
- tokenatlas/prices.json +760 -0
- tokenatlas/pricing.py +249 -0
- tokenatlas/prompt_store.py +155 -0
- tokenatlas/prompt_text.py +248 -0
- tokenatlas/prompts.py +112 -0
- tokenatlas/report.py +221 -0
- tokenatlas/report_template.html +63 -0
- tokenatlas/sessions.py +455 -0
- tokenatlas/why.py +1243 -0
- tokenatlas-1.3.0.dist-info/METADATA +771 -0
- tokenatlas-1.3.0.dist-info/RECORD +19 -0
- tokenatlas-1.3.0.dist-info/WHEEL +5 -0
- tokenatlas-1.3.0.dist-info/entry_points.txt +3 -0
- tokenatlas-1.3.0.dist-info/licenses/LICENSE +21 -0
- tokenatlas-1.3.0.dist-info/top_level.txt +1 -0
tokenatlas/__init__.py
ADDED
tokenatlas/__main__.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""python -m tokenatlas: local refresh, report, and doctor."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
13
|
+
|
|
14
|
+
from tokenatlas import why
|
|
15
|
+
from tokenatlas import __version__
|
|
16
|
+
from tokenatlas import sessions
|
|
17
|
+
from tokenatlas.history import History, summarize
|
|
18
|
+
from tokenatlas import pricing, prompt_store, prompts
|
|
19
|
+
from tokenatlas.report import build_report, coverage_key, read_report_state, render_report, report_state, write_report
|
|
20
|
+
|
|
21
|
+
DEFAULT_TIMEZONE='Europe/Stockholm'
|
|
22
|
+
FILTERS=('start','end','harness','project','session','turn','model','effort','provider','agent')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def aggregate(results):
|
|
26
|
+
"""Fold per-root refresh results: summed counters, worst status, and the per-root list."""
|
|
27
|
+
order=('ok','partial','missing')
|
|
28
|
+
total={k:sum(r[k] for r in results) for k,v in results[0].items() if type(v) is int}
|
|
29
|
+
return dict(harness=results[0]['harness'],status=max((r['status'] for r in results),key=lambda s:order.index(s) if s in order else len(order)),
|
|
30
|
+
last_attempt=max(r['last_attempt'] for r in results),errors=[e for r in results for e in r['errors']],
|
|
31
|
+
coverage_complete=False,roots=results,**total)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_duration(text):
|
|
35
|
+
"""'90s', '30m', '1h' or '2d' to seconds."""
|
|
36
|
+
found=re.fullmatch(r'(\d+)([smhd])',text)
|
|
37
|
+
if not found:raise ValueError(f'invalid duration {text!r}; use e.g. 90s, 30m, 1h or 2d')
|
|
38
|
+
return int(found[1])*{'s':1,'m':60,'h':3600,'d':86400}[found[2]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _open_in_browser(path):
|
|
42
|
+
where=f'could not open a browser; the report is at {path}'
|
|
43
|
+
try:opened=webbrowser.open(Path(path).resolve().as_uri())
|
|
44
|
+
except webbrowser.Error as exc:raise ValueError(f'{where} ({exc})') from exc
|
|
45
|
+
if not opened:raise ValueError(where)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _output_path(path,db):
|
|
49
|
+
"""Expanded HTML path; refuses the history database itself, also through a hard or symbolic link."""
|
|
50
|
+
path,db=Path(path).expanduser(),Path(db).expanduser()
|
|
51
|
+
if path.resolve()==db.resolve() or (path.exists() and db.exists() and os.path.samefile(path,db)):
|
|
52
|
+
raise ValueError('HTML output must not replace the history database')
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _spec(privacy,timezone,granularity,filters):
|
|
57
|
+
return {'privacy':privacy,'timezone':timezone,'granularity':granularity,'filters':{k:filters.get(k) for k in FILTERS}}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _present(root):
|
|
61
|
+
"""False only when the root is definitely not there; an unreadable or wrong-type one is present and fails in refresh."""
|
|
62
|
+
try:os.stat(root)
|
|
63
|
+
except (FileNotFoundError,NotADirectoryError):return False
|
|
64
|
+
except OSError:return True
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _with_problems(entry,problems):
|
|
69
|
+
"""Add Cowork traversal errors to a Claude refresh result and make it at least partial."""
|
|
70
|
+
order=('ok','partial','missing','error')
|
|
71
|
+
rank=lambda s:order.index(s) if s in order else len(order)
|
|
72
|
+
return dict(entry,errors=[*entry.get('errors',[]),*problems],status=max(entry['status'],'partial',key=rank)) if problems else entry
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def refresh_all(history):
|
|
76
|
+
"""Refresh every harness from its default roots; absent ones are reported, an OSError only fails its own harness."""
|
|
77
|
+
roots={'claude':why.CLAUDE_PROJECTS,'codex':why.CODEX_SESSIONS,'pi':why.PI_SESSIONS,'opencode':why.OPENCODE_DB}
|
|
78
|
+
order=('ok','partial','missing','error')
|
|
79
|
+
rank=lambda s:order.index(s) if s in order else len(order)
|
|
80
|
+
entries,worst=[],'ok'
|
|
81
|
+
for name,root in roots.items():
|
|
82
|
+
try:
|
|
83
|
+
# Claude: main root, then each Cowork transcript root (macOS; absent elsewhere and simply skipped).
|
|
84
|
+
cowork,problems=why.cowork_scan() if name=='claude' else ([],[])
|
|
85
|
+
found=[r for r in [root,*cowork] if _present(r)]
|
|
86
|
+
if not found and not problems:
|
|
87
|
+
entries.append({'harness':name,'status':'absent'});continue
|
|
88
|
+
results=[history.refresh(name,r) for r in found]
|
|
89
|
+
entry=(results[0] if len(results)==1 else aggregate(results)) if results else {'harness':name,'status':'ok','errors':[]}
|
|
90
|
+
entry=_with_problems(entry,problems)
|
|
91
|
+
except OSError as exc:
|
|
92
|
+
entry={'harness':name,'status':'error','errors':[f'{type(exc).__name__}: {exc}']}
|
|
93
|
+
entries.append(entry)
|
|
94
|
+
worst=max(worst,entry['status'],key=rank)
|
|
95
|
+
return {'status':worst,'harnesses':entries}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_top(result,texts=None):
|
|
99
|
+
"""Compact table of ranked prompts; cost is list-price, '≥' when some requests could not be priced."""
|
|
100
|
+
zone=ZoneInfo(DEFAULT_TIMEZONE)
|
|
101
|
+
rows=[('#','when','harness','project','models','req','sub','Mtok','cost','resume')]
|
|
102
|
+
for i,p in enumerate(result['prompts'],1):
|
|
103
|
+
cost='n/a' if p['cost'] is None else ('' if p['cost_complete'] else '≥')+f"${p['cost']:.2f}"
|
|
104
|
+
when=datetime.fromisoformat(p['first_ts']).astimezone(zone).strftime('%Y-%m-%d %H:%M')
|
|
105
|
+
rows.append((str(i),when,p['harness'],p['project_label'] or '-',','.join(p['models']) or '-',str(p['requests']),
|
|
106
|
+
str(p['subagents']),f"{p['total_tokens']/1e6:.2f}",cost,p['resume'] or '-'))
|
|
107
|
+
widths=[max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
|
|
108
|
+
lines=[' '.join(c.ljust(w) for c,w in zip(r,widths)).rstrip() for r in rows]
|
|
109
|
+
if texts: # stored previews go on an indented second line under their prompt
|
|
110
|
+
shown=[]
|
|
111
|
+
for line,p in zip(lines[1:],result['prompts']):
|
|
112
|
+
shown.append(line)
|
|
113
|
+
text=texts.get((p['harness'],p['session'],p['turn_id']))
|
|
114
|
+
if text:shown.append(' '+text)
|
|
115
|
+
lines=[lines[0],*shown]
|
|
116
|
+
if len(result['prompts'])<result['total_prompts']:lines.append(f"showing {len(result['prompts'])} of {result['total_prompts']} prompts")
|
|
117
|
+
return '\n'.join(lines)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def default_db():
|
|
121
|
+
"""Default history path; one-time move of the pre-rename agentmon directory (never used with --db)."""
|
|
122
|
+
base=Path(os.environ.get('XDG_STATE_HOME',Path.home()/'.local/state'))
|
|
123
|
+
new,old=base/'tokenatlas',base/'agentmon'
|
|
124
|
+
if old.is_dir():
|
|
125
|
+
if new.exists():
|
|
126
|
+
print(f'warning: {old} left in place; using {new}',file=sys.stderr)
|
|
127
|
+
else:
|
|
128
|
+
os.rename(old,new)
|
|
129
|
+
print(f'moved history from {old} to {new}',file=sys.stderr)
|
|
130
|
+
return new/'history.sqlite3'
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _visible_texts(history,db):
|
|
134
|
+
"""The stored prompt texts a private report may embed (the current global top k); the history is read only when a store exists."""
|
|
135
|
+
store=prompt_store.store_path(db)
|
|
136
|
+
return prompt_store.visible(store,history.records(),pricing.load_prices()) if os.path.lexists(store) else {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main(argv=None):
|
|
140
|
+
# Windows pipes default to a legacy code page without '≥' or '→'; replace such characters rather than crash.
|
|
141
|
+
for stream in (sys.stdout,sys.stderr):
|
|
142
|
+
if hasattr(stream,'reconfigure'):stream.reconfigure(errors='replace')
|
|
143
|
+
if Path(sys.argv[0]).name.lower() in ('energy-monitor','energy-monitor.exe','energy-monitor-script.py'):
|
|
144
|
+
print('energy-monitor is deprecated; use tokenatlas',file=sys.stderr)
|
|
145
|
+
parser=argparse.ArgumentParser(prog='tokenatlas',description='Local observed token history; no network or LLM calls.')
|
|
146
|
+
parser.add_argument('--version',action='version',version=f'%(prog)s {__version__}')
|
|
147
|
+
parser.add_argument('--db',type=Path,help='History database; default $XDG_STATE_HOME/tokenatlas/history.sqlite3.')
|
|
148
|
+
commands=parser.add_subparsers(dest='command',required=True)
|
|
149
|
+
refresh=commands.add_parser('refresh',help='Import changed files; preserve retained observations.')
|
|
150
|
+
which=refresh.add_mutually_exclusive_group(required=True)
|
|
151
|
+
which.add_argument('--harness',choices=('claude','codex','pi','opencode'))
|
|
152
|
+
which.add_argument('--all',action='store_true',help='Refresh every harness from its default roots; missing ones are reported as absent.')
|
|
153
|
+
refresh.add_argument('--root',type=Path,help='Override the harness session directory (with --harness).')
|
|
154
|
+
opener=commands.add_parser('open',help='Refresh, build the report (private by default) and open it in the browser.')
|
|
155
|
+
opener.add_argument('--html',type=Path,help='Report path; default $XDG_STATE_HOME/tokenatlas/report.html.')
|
|
156
|
+
opener.add_argument('--shared',action='store_true',help='Pseudonymize the report instead of keeping project labels.')
|
|
157
|
+
opener.add_argument('--no-refresh',action='store_true',help='Use the saved history as it is.')
|
|
158
|
+
snapshot=commands.add_parser('snapshot',help='Write a consistent private copy of the history database.')
|
|
159
|
+
snapshot.add_argument('out',type=Path)
|
|
160
|
+
importer=commands.add_parser('import',help="Merge another machine's snapshot into this database.")
|
|
161
|
+
importer.add_argument('snapshot',type=Path)
|
|
162
|
+
importer.add_argument('--label',required=True,help='Name for the source machine, e.g. pi:huginmunin.local.')
|
|
163
|
+
report=commands.add_parser('report',help='Report saved observations without rereading source logs.')
|
|
164
|
+
report.add_argument('--start',help='Inclusive ISO timestamp; offset required.')
|
|
165
|
+
report.add_argument('--end',help='Exclusive ISO timestamp; offset required.')
|
|
166
|
+
report.add_argument('--granularity',choices=('day','hour','minute'),default='day')
|
|
167
|
+
report.add_argument('--timezone',default=DEFAULT_TIMEZONE)
|
|
168
|
+
report.add_argument('--harness',choices=('claude','codex','pi','opencode'))
|
|
169
|
+
report.add_argument('--project',help='Exact full project identity, not basename.')
|
|
170
|
+
report.add_argument('--session')
|
|
171
|
+
report.add_argument('--turn')
|
|
172
|
+
report.add_argument('--model',help='Exact model ID.')
|
|
173
|
+
report.add_argument('--effort')
|
|
174
|
+
report.add_argument('--provider')
|
|
175
|
+
report.add_argument('--agent')
|
|
176
|
+
report.add_argument('--html',type=Path,help='Write a standalone interactive offline HTML report.')
|
|
177
|
+
report.add_argument('--private',action='store_true',help='Keep project labels and session IDs in HTML; default HTML uses pseudonyms.')
|
|
178
|
+
report.add_argument('--if-changed',action='store_true',help='With --html: skip when the history revision matches the existing report.')
|
|
179
|
+
report.add_argument('--max-age',help='With --html: skip when the existing report is younger than this (90s, 30m, 1h, 2d).')
|
|
180
|
+
report.add_argument('--records',action='store_true',help='Include per-observation counters and source-file references. Reports contain private local paths.')
|
|
181
|
+
for name,text in (('session','Show the session tree, per-model totals and outcomes for one root session.'),
|
|
182
|
+
('rate','List threads of a session, or record an outcome rating for a unit.')):
|
|
183
|
+
sub=commands.add_parser(name,help=text)
|
|
184
|
+
sub.add_argument('id',help='Root session id, or harness:id when ambiguous.')
|
|
185
|
+
sub.add_argument('--outcomes',type=Path,help='Outcomes JSONL; default outcomes.jsonl next to the database.')
|
|
186
|
+
sub.add_argument('--prices',type=Path,help='Override the price table.')
|
|
187
|
+
if name=='session':
|
|
188
|
+
sub.add_argument('--json',action='store_true')
|
|
189
|
+
sub.add_argument('--no-infer',action='store_true',help='Do not link headless children by time and cwd.')
|
|
190
|
+
else:
|
|
191
|
+
sub.add_argument('--unit');sub.add_argument('--thread',action='append',default=[])
|
|
192
|
+
sub.add_argument('--outcome',choices=sessions.OUTCOMES);sub.add_argument('--note',default='')
|
|
193
|
+
top=commands.add_parser('top',help='Rank the most expensive user prompts, subagent work rolled up into each.')
|
|
194
|
+
top.add_argument('-n','--limit',type=int,default=5)
|
|
195
|
+
top.add_argument('--by',choices=('cost','tokens'),default='cost')
|
|
196
|
+
top.add_argument('--start',help='Inclusive ISO timestamp; offset required.')
|
|
197
|
+
top.add_argument('--end',help='Exclusive ISO timestamp; offset required.')
|
|
198
|
+
top.add_argument('--harness',choices=('claude','codex','pi','opencode'))
|
|
199
|
+
top.add_argument('--project',help='Exact full project identity, not basename.')
|
|
200
|
+
top.add_argument('--prices',type=Path,help='Override the price table.')
|
|
201
|
+
top.add_argument('--json',action='store_true')
|
|
202
|
+
top.add_argument('--keep-text',action='store_true',help='Store the text of the current global top -n prompts in top-prompts.json next to the history (0600).')
|
|
203
|
+
top.add_argument('--forget-text',action='store_true',help='Delete the stored prompt text.')
|
|
204
|
+
top.add_argument('--with-text',action='store_true',help='With --json: include stored prompt text.')
|
|
205
|
+
overhead=commands.add_parser('overhead',help='Fixed context overhead: floor tokens, instruction and skill sizes.')
|
|
206
|
+
overhead.add_argument('--refresh',action='store_true',help='Rescan the default session roots first.')
|
|
207
|
+
overhead.add_argument('--harness',choices=('claude','codex','pi','opencode'))
|
|
208
|
+
overhead.add_argument('--since',help='Inclusive ISO timestamp of the session start.')
|
|
209
|
+
overhead.add_argument('--json',action='store_true')
|
|
210
|
+
commands.add_parser('doctor',help='Show source availability, import errors and known coverage limits.')
|
|
211
|
+
args=parser.parse_args(argv)
|
|
212
|
+
if args.db is None:args.db=default_db()
|
|
213
|
+
try:
|
|
214
|
+
start=end=None
|
|
215
|
+
if args.command=='refresh' and args.all and args.root:raise ValueError('--root cannot be used with --all')
|
|
216
|
+
if args.command=='top' and args.limit<1:raise ValueError('--limit must be at least 1')
|
|
217
|
+
if args.command=='top' and args.keep_text and args.forget_text:raise ValueError('--keep-text and --forget-text cannot be combined')
|
|
218
|
+
if args.command in ('report','top'):
|
|
219
|
+
if args.command=='report':
|
|
220
|
+
max_age=parse_duration(args.max_age) if args.max_age is not None else None
|
|
221
|
+
if (args.if_changed or max_age is not None) and not args.html:raise ValueError('--if-changed and --max-age need --html')
|
|
222
|
+
ZoneInfo(args.timezone)
|
|
223
|
+
for name in ('start','end'):
|
|
224
|
+
value=getattr(args,name)
|
|
225
|
+
if value:
|
|
226
|
+
parsed=datetime.fromisoformat(value.replace('Z','+00:00'))
|
|
227
|
+
if parsed.tzinfo is None:
|
|
228
|
+
raise ValueError(f'--{name} needs a timezone offset')
|
|
229
|
+
if name=='start':start=parsed
|
|
230
|
+
else:end=parsed
|
|
231
|
+
if start and end and start>=end:raise ValueError('--start must precede --end')
|
|
232
|
+
if args.command=='report' and args.html:_output_path(args.html,args.db)
|
|
233
|
+
if args.command=='open':path=_output_path(args.html or args.db.parent/'report.html',args.db)
|
|
234
|
+
if args.command=='overhead':
|
|
235
|
+
from tokenatlas import overhead as _overhead
|
|
236
|
+
return _overhead.run(args)
|
|
237
|
+
if args.command not in ('refresh','import','open') and not args.db.expanduser().is_file():
|
|
238
|
+
raise ValueError('history database does not exist; run refresh first')
|
|
239
|
+
if args.command=='rate' and (args.unit or args.thread or args.outcome) and not (args.unit and args.thread and args.outcome):
|
|
240
|
+
raise ValueError('rating needs --unit, --thread and --outcome')
|
|
241
|
+
with History(args.db) as history:
|
|
242
|
+
if args.command in ('session','rate'):
|
|
243
|
+
history.connection.execute('BEGIN')
|
|
244
|
+
price,retrieved=sessions.default_pricer(args.prices)
|
|
245
|
+
result=sessions.build_tree(history.records(),args.id,infer=not getattr(args,'no_infer',False),price=price)
|
|
246
|
+
path=args.outcomes or Path(args.db).with_name('outcomes.jsonl')
|
|
247
|
+
rated=sessions.load_outcomes(path,args.id)
|
|
248
|
+
if args.command=='session':
|
|
249
|
+
eff=sessions.efficiency(result,rated) if rated else None
|
|
250
|
+
if args.json:
|
|
251
|
+
result['efficiency']=eff;result['prices_retrieved']=retrieved
|
|
252
|
+
print(json.dumps(result,indent=2,sort_keys=True))
|
|
253
|
+
else:print(sessions.render(result,eff,retrieved))
|
|
254
|
+
elif not args.unit:
|
|
255
|
+
threads=[n for n in sessions.iter_nodes(result['root']) if n is not result['root']]
|
|
256
|
+
if not threads:
|
|
257
|
+
print('no rateable threads: the root thread is coordination, not a unit of work',file=sys.stderr)
|
|
258
|
+
for node in threads:
|
|
259
|
+
print(f"{sessions.thread_key(node)} {', '.join(node['models']) or '-'} {sessions._tokens(node)}"
|
|
260
|
+
f" {sessions._money(node['cost'],node['cost_coverage'],node['lower_bound'])}")
|
|
261
|
+
else:
|
|
262
|
+
keys={sessions.thread_key(n) for n in sessions.iter_nodes(result['root']) if n is not result['root']}
|
|
263
|
+
for key in args.thread:
|
|
264
|
+
if key not in keys:raise ValueError(f'unknown thread {key!r}; run rate {args.id} to list threads')
|
|
265
|
+
line={'v':1,'root_session':result['root']['id'],'unit':args.unit,
|
|
266
|
+
'threads':[sessions._thread_of_key(k) for k in args.thread],'outcome':args.outcome,
|
|
267
|
+
'note':args.note,'ts':datetime.now(ZoneInfo('UTC')).strftime('%Y-%m-%dT%H:%M:%SZ')}
|
|
268
|
+
sessions.append_outcome(path,line)
|
|
269
|
+
print(json.dumps(line,sort_keys=True))
|
|
270
|
+
return 0
|
|
271
|
+
if args.command=='open':
|
|
272
|
+
path=_output_path(path,args.db) # the database may have just been created under a case alias
|
|
273
|
+
if not args.no_refresh:
|
|
274
|
+
summary=refresh_all(history)
|
|
275
|
+
print('refresh: '+', '.join(f"{e['harness']} {e['status']}" for e in summary['harnesses']),file=sys.stderr)
|
|
276
|
+
history.connection.execute('BEGIN')
|
|
277
|
+
source_status=history.doctor()
|
|
278
|
+
spec=_spec('redacted' if args.shared else 'local',DEFAULT_TIMEZONE,'day',{})
|
|
279
|
+
texts=None if args.shared else _visible_texts(history,args.db) # shared reports ignore the store
|
|
280
|
+
state=report_state(history.revision,history.machine,spec,coverage_key(source_status),history.revision_token,prompt_store.texts_hash(texts))
|
|
281
|
+
if path.exists() and read_report_state(path)==state:
|
|
282
|
+
result={'html':str(path.resolve()),'skipped':True,'reason':'unchanged'}
|
|
283
|
+
else:
|
|
284
|
+
records=history.records()
|
|
285
|
+
payload=build_report(records,source_status,DEFAULT_TIMEZONE,redact=args.shared,prompt_texts=texts)
|
|
286
|
+
payload['initial_granularity']='day'
|
|
287
|
+
write_report(path,render_report(payload,state=state))
|
|
288
|
+
result={'html':str(path.resolve()),'observations':len(records),'privacy':payload['privacy']}
|
|
289
|
+
_open_in_browser(path)
|
|
290
|
+
elif args.command=='refresh' and args.all:
|
|
291
|
+
result=refresh_all(history)
|
|
292
|
+
elif args.command=='refresh':
|
|
293
|
+
roots={'claude':why.CLAUDE_PROJECTS,'codex':why.CODEX_SESSIONS,
|
|
294
|
+
'pi':why.PI_SESSIONS,'opencode':why.OPENCODE_DB}
|
|
295
|
+
if args.root or args.harness!='claude':
|
|
296
|
+
result=history.refresh(args.harness,args.root or roots[args.harness])
|
|
297
|
+
else:
|
|
298
|
+
# Main root, then each Cowork transcript root (macOS; absent elsewhere and simply skipped).
|
|
299
|
+
cowork,problems=why.cowork_scan()
|
|
300
|
+
results=[history.refresh('claude',root) for root in [roots['claude'],*cowork]]
|
|
301
|
+
result=_with_problems(results[0] if len(results)==1 else aggregate(results),problems)
|
|
302
|
+
elif args.command=='top':
|
|
303
|
+
history.connection.execute('BEGIN')
|
|
304
|
+
store=prompt_store.store_path(args.db)
|
|
305
|
+
if args.forget_text:
|
|
306
|
+
prompt_store.forget(store)
|
|
307
|
+
print(json.dumps({'forgotten':str(store)}));return 0
|
|
308
|
+
table=pricing.load_prices(args.prices)
|
|
309
|
+
everything=history.records() # rank over the whole history; the filters only choose which rows contribute
|
|
310
|
+
kept=prompt_store.update(store,everything,table,history.machine,args.limit,args.by) if args.keep_text else None
|
|
311
|
+
filtered=any(x is not None for x in (start,end,args.harness,args.project))
|
|
312
|
+
keep={prompts.ident(r) for r in history.records(start,end,args.harness,args.project)} if filtered else None
|
|
313
|
+
result=prompts.top_prompts(everything,table,args.limit,args.by,keep)
|
|
314
|
+
texts=prompt_store.visible(store,everything,table) # only the global top k: never text outside it
|
|
315
|
+
if kept:result['text_store']=kept
|
|
316
|
+
if not args.json:
|
|
317
|
+
print(render_top(result,texts))
|
|
318
|
+
if kept:print(f"kept text for {kept['kept']+kept['added']} prompts in {kept['path']} ({kept['added']} new, {kept['evicted']} evicted)",file=sys.stderr)
|
|
319
|
+
return 0
|
|
320
|
+
if args.with_text:
|
|
321
|
+
for p in result['prompts']:p['text']=texts.get((p['harness'],p['session'],p['turn_id']))
|
|
322
|
+
elif args.command=='snapshot':
|
|
323
|
+
result=history.snapshot(args.out)
|
|
324
|
+
elif args.command=='import':
|
|
325
|
+
result=history.import_snapshot(args.snapshot,args.label)
|
|
326
|
+
if result.get('warning'):print(f"usage: warning: {result['warning']}",file=sys.stderr)
|
|
327
|
+
elif args.command=='doctor':
|
|
328
|
+
history.connection.execute('BEGIN')
|
|
329
|
+
result=history.doctor()
|
|
330
|
+
else:
|
|
331
|
+
history.connection.execute('BEGIN')
|
|
332
|
+
source_status=history.doctor()
|
|
333
|
+
if args.html:
|
|
334
|
+
path=_output_path(args.html,args.db)
|
|
335
|
+
spec=_spec('local' if args.private else 'redacted',args.timezone,args.granularity,vars(args))
|
|
336
|
+
texts=_visible_texts(history,args.db) if args.private else None
|
|
337
|
+
state=report_state(history.revision,history.machine,spec,coverage_key(source_status),history.revision_token,prompt_store.texts_hash(texts))
|
|
338
|
+
if path.exists() and (args.if_changed or max_age is not None):
|
|
339
|
+
found=read_report_state(path)
|
|
340
|
+
age=time.time()-path.stat().st_mtime
|
|
341
|
+
# Options (identity) are never throttled: only a data change on an otherwise identical report waits.
|
|
342
|
+
reason=('unchanged' if args.if_changed and found==state else
|
|
343
|
+
'too recent' if max_age is not None and found is not None and found[0]==state[0] and 0<=age<max_age else None)
|
|
344
|
+
if reason:
|
|
345
|
+
print(json.dumps({'html':str(path.resolve()),'skipped':True,'reason':reason}))
|
|
346
|
+
return 0
|
|
347
|
+
records=history.records(start,end,args.harness,args.project,args.session,args.turn)
|
|
348
|
+
records=[row for row in records if all(getattr(args,key) is None or row[key]==getattr(args,key)
|
|
349
|
+
for key in ('model','effort','provider','agent'))]
|
|
350
|
+
# The HTML path prints only a short receipt, so skip the (costly) JSON summary there.
|
|
351
|
+
result={} if args.html else summarize(records,args.granularity,args.timezone)
|
|
352
|
+
result['window']={'start':args.start,'end':args.end}
|
|
353
|
+
result['source_status']=source_status
|
|
354
|
+
if args.records:result['records']=records
|
|
355
|
+
if args.html:
|
|
356
|
+
if texts is not None:
|
|
357
|
+
filtered=any(getattr(args,key) is not None for key in ('start','end','harness','project','session','turn','model','effort','provider','agent'))
|
|
358
|
+
texts=prompt_store.visible(prompt_store.store_path(args.db),history.records() if filtered else records,pricing.load_prices())
|
|
359
|
+
payload=build_report(records,source_status,args.timezone,redact=not args.private,prompt_texts=texts)
|
|
360
|
+
payload['initial_granularity']=args.granularity
|
|
361
|
+
write_report(path,render_report(payload,state=state))
|
|
362
|
+
result={'html':str(path.resolve()),'observations':len(records),
|
|
363
|
+
'privacy':payload['privacy'],'billing_verified':False,'coverage_complete':False}
|
|
364
|
+
print(json.dumps(result,indent=2,sort_keys=True))
|
|
365
|
+
return 0 if args.command!='refresh' or result['status']=='ok' else 2
|
|
366
|
+
except (OSError,ValueError,sqlite3.Error,ZoneInfoNotFoundError) as exc:
|
|
367
|
+
parser.exit(2,f'usage: {exc}\n')
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__=='__main__':raise SystemExit(main())
|