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 ADDED
@@ -0,0 +1,3 @@
1
+ """Local observed-usage history. No inference or network clients."""
2
+
3
+ __version__ = "1.3.0"
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())