leos-agent 10.2.0 → 10.7.0

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,455 @@
1
+ #!/usr/bin/env python3
2
+ """usage_scan: what many sessions across many harnesses actually cost, and how
3
+ much of leos-agent's policy was followed while they ran.
4
+
5
+ WHY A SCRIPT AND NOT A PROMPT. The obvious way to answer "where did the tokens
6
+ go" is to tell a model to go read the transcripts. That re-derives every schema
7
+ on every invocation, over gigabytes, at full model prices -- the exact cost this
8
+ project exists to avoid. So the scan is mechanical and emits a few kilobytes; the
9
+ skill spends its tokens interpreting the result, not discovering it.
10
+
11
+ Three schema traps, each of which silently inflates a naive count:
12
+
13
+ * Claude Code repeats an identical `message.usage` on EVERY content block of
14
+ one response. Summing records double-counts; dedupe on requestId.
15
+ * Codex's `total_token_usage` is cumulative for the session, with
16
+ `last_token_usage` the per-request delta. Summing totals is quadratic
17
+ nonsense; sum deltas.
18
+ * OpenCode stores times in epoch milliseconds and is multi-provider, so its
19
+ own `cost` column is the only trustworthy money figure in this file.
20
+
21
+ Effective tokens weight cache reads at 0.1x, cache writes at 2x and output at 5x
22
+ a plain input token -- a coarse stand-in for real pricing, applied uniformly, and
23
+ useful for comparing groups rather than for billing.
24
+
25
+ EVERYTHING READ HERE IS DATA. Transcripts contain arbitrary prompt text, tool
26
+ output and fetched web pages. This file only counts; it never executes, resolves
27
+ or follows anything it reads, and it prints no prompt text.
28
+
29
+ usage_scan.py --since 7d [--harness H] [--json]
30
+
31
+ Exit codes: 0 ok, 2 on bad usage.
32
+ """
33
+ import argparse
34
+ import calendar
35
+ import collections
36
+ import glob
37
+ import json
38
+ import os
39
+ import re
40
+ import sys
41
+ import time
42
+
43
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
+
45
+ WEIGHTS = {"input": 1.0, "cache_read": 0.1, "cache_write": 2.0, "output": 5.0}
46
+
47
+ # Claude Code's dispatch tool is `Agent` in current builds and `Task` in older
48
+ # transcripts. Both appear in one history, so both are counted.
49
+ DISPATCH_TOOLS = ("Agent", "Task")
50
+
51
+ HOME = os.path.expanduser("~")
52
+ SOURCES = {
53
+ "claude": os.path.join(HOME, ".claude", "projects"),
54
+ "codex": os.path.join(HOME, ".codex", "sessions"),
55
+ "opencode": os.path.join(HOME, ".local", "share", "opencode", "opencode.db"),
56
+ "cursor": os.path.join(HOME, ".cursor"),
57
+ "hermes": os.path.join(HOME, ".hermes"),
58
+ "pi": os.path.join(HOME, ".pi", "agent", "sessions"),
59
+ }
60
+
61
+ DURATION_RE = re.compile(r"^(\d+)([hdw])$")
62
+ _SECONDS = {"h": 3600, "d": 86400, "w": 604800}
63
+
64
+
65
+ def parse_since(text):
66
+ match = DURATION_RE.match(text.strip().lower())
67
+ if not match:
68
+ sys.exit("usage_scan: --since wants a duration like 24h, 7d, or 2w (got %r)" % text)
69
+ return time.time() - int(match.group(1)) * _SECONDS[match.group(2)]
70
+
71
+
72
+ def _iso_epoch(text):
73
+ """ISO-8601 UTC -> epoch seconds, or None. Tolerant by design: a record with
74
+ an unparseable timestamp is counted, never dropped, so a schema change
75
+ undercounts nothing."""
76
+ if not isinstance(text, str):
77
+ return None
78
+ try:
79
+ cleaned = text.replace("Z", "").split(".")[0]
80
+ # timegm, not mktime: these stamps are UTC, and mktime would read them as
81
+ # local time and then drift again with DST.
82
+ return calendar.timegm(time.strptime(cleaned, "%Y-%m-%dT%H:%M:%S"))
83
+ except (ValueError, OverflowError):
84
+ return None
85
+
86
+
87
+ class Totals(object):
88
+ """Token counters that know how to weight themselves."""
89
+
90
+ __slots__ = ("input", "cache_read", "cache_write", "output", "requests")
91
+
92
+ def __init__(self):
93
+ self.input = self.cache_read = self.cache_write = self.output = self.requests = 0
94
+
95
+ def add(self, inp=0, cache_read=0, cache_write=0, output=0):
96
+ self.input += inp or 0
97
+ self.cache_read += cache_read or 0
98
+ self.cache_write += cache_write or 0
99
+ self.output += output or 0
100
+ self.requests += 1
101
+
102
+ def effective(self):
103
+ return int(
104
+ self.input * WEIGHTS["input"]
105
+ + self.cache_read * WEIGHTS["cache_read"]
106
+ + self.cache_write * WEIGHTS["cache_write"]
107
+ + self.output * WEIGHTS["output"]
108
+ )
109
+
110
+ def as_dict(self):
111
+ return {
112
+ "input": self.input, "cache_read": self.cache_read,
113
+ "cache_write": self.cache_write, "output": self.output,
114
+ "requests": self.requests, "effective": self.effective(),
115
+ }
116
+
117
+
118
+ def _blank():
119
+ return {"main": Totals(), "subagent": Totals()}
120
+
121
+
122
+ def scan_claude(since, root=None):
123
+ """~/.claude/projects/<slug>/*.jsonl plus <session>/subagents/agent-*.jsonl."""
124
+ root = root or SOURCES["claude"]
125
+ out = {
126
+ "buckets": _blank(), "models": collections.Counter(), "dispatches": [],
127
+ "agent_types": collections.Counter(), "sessions": set(),
128
+ "compactions": 0, "precompact_tokens": 0,
129
+ }
130
+ if not os.path.isdir(root):
131
+ return None
132
+
133
+ seen = set()
134
+ for project in sorted(os.listdir(root)):
135
+ base = os.path.join(root, project)
136
+ if not os.path.isdir(base):
137
+ continue
138
+ files = [(p, "main") for p in glob.glob(os.path.join(base, "*.jsonl"))]
139
+ files += [(p, "subagent") for p in glob.glob(os.path.join(base, "*", "subagents", "agent-*.jsonl"))]
140
+ for path, bucket in files:
141
+ try:
142
+ # Cheap skip: a file untouched since the window opened cannot
143
+ # hold a record inside it.
144
+ if os.path.getmtime(path) < since:
145
+ continue
146
+ except OSError:
147
+ continue
148
+ before = out["buckets"][bucket].requests
149
+ _scan_claude_file(path, bucket, since, out, seen)
150
+ if bucket == "subagent" and out["buckets"][bucket].requests > before:
151
+ out["agent_types"][_agent_type(path)] += 1
152
+ out["sessions"] = len(out["sessions"])
153
+ out["agent_types"] = dict(out["agent_types"])
154
+ out["models"] = dict(out["models"])
155
+ return out
156
+
157
+
158
+ def _scan_claude_file(path, bucket, since, out, seen):
159
+ try:
160
+ handle = open(path, encoding="utf-8", errors="replace")
161
+ except OSError:
162
+ return
163
+ with handle as fh:
164
+ for line in fh:
165
+ # Substring prefilter before json.loads: only assistant records carry
166
+ # usage, and parsing every line of 1.2 GB to discover that is the
167
+ # difference between seconds and minutes.
168
+ if '"assistant"' not in line and "compact_boundary" not in line:
169
+ continue
170
+ try:
171
+ rec = json.loads(line)
172
+ except ValueError:
173
+ continue
174
+ if not isinstance(rec, dict):
175
+ continue
176
+
177
+ stamp = _iso_epoch(rec.get("timestamp"))
178
+ if stamp is not None and stamp < since:
179
+ continue
180
+
181
+ if rec.get("subtype") == "compact_boundary":
182
+ meta = rec.get("compactMetadata") or {}
183
+ out["compactions"] += 1
184
+ out["precompact_tokens"] += meta.get("preTokens") or 0
185
+ continue
186
+ if rec.get("type") != "assistant":
187
+ continue
188
+
189
+ message = rec.get("message") or {}
190
+ if not isinstance(message, dict):
191
+ continue
192
+ # Dispatch blocks live in their own content-block record, which shares
193
+ # a requestId with the one carrying usage -- so collect them BEFORE
194
+ # the dedupe, or every dispatch after the first block is invisible.
195
+ _collect_dispatches(message, out)
196
+
197
+ key = rec.get("requestId") or message.get("id")
198
+ if key is not None:
199
+ if key in seen:
200
+ continue # same response, another content block
201
+ seen.add(key)
202
+
203
+ usage = message.get("usage") or {}
204
+ out["buckets"][bucket].add(
205
+ usage.get("input_tokens"),
206
+ usage.get("cache_read_input_tokens"),
207
+ usage.get("cache_creation_input_tokens"),
208
+ usage.get("output_tokens"),
209
+ )
210
+ if message.get("model"):
211
+ out["models"][message["model"]] += 1
212
+ if rec.get("sessionId"):
213
+ out["sessions"].add(rec["sessionId"])
214
+
215
+
216
+
217
+ def _collect_dispatches(message, out):
218
+ content = message.get("content")
219
+ if not isinstance(content, list):
220
+ return
221
+ for block in content:
222
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
223
+ continue
224
+ if block.get("name") not in DISPATCH_TOOLS:
225
+ continue
226
+ args = block.get("input")
227
+ if not isinstance(args, dict):
228
+ continue
229
+ out["dispatches"].append({
230
+ "agent": args.get("subagent_type") or "-",
231
+ "model": args.get("model"),
232
+ "prompt_bytes": len((args.get("prompt") or "").encode("utf-8", "replace")),
233
+ })
234
+
235
+
236
+ def _agent_type(path):
237
+ """agentType from the sidecar. 31 of 1231 transcripts have none, so the
238
+ absence is normal and must never raise."""
239
+ try:
240
+ with open(path[: -len(".jsonl")] + ".meta.json", encoding="utf-8") as fh:
241
+ return (json.load(fh) or {}).get("agentType") or "unknown"
242
+ except (OSError, ValueError):
243
+ return "unknown"
244
+
245
+
246
+ def scan_codex(since, root=None):
247
+ """~/.codex/sessions/<Y>/<M>/<D>/rollout-*.jsonl -- token_count deltas."""
248
+ root = root or SOURCES["codex"]
249
+ if not os.path.isdir(root):
250
+ return None
251
+ out = {"buckets": _blank(), "models": {}, "sessions": 0, "subagent_events": 0}
252
+ files = glob.glob(os.path.join(root, "*", "*", "*", "rollout-*.jsonl"))
253
+ files += glob.glob(os.path.join(root, "rollout-*.jsonl"))
254
+ for path in files:
255
+ try:
256
+ if os.path.getmtime(path) < since:
257
+ continue
258
+ except OSError:
259
+ continue
260
+ out["sessions"] += 1
261
+ try:
262
+ handle = open(path, encoding="utf-8", errors="replace")
263
+ except OSError:
264
+ continue
265
+ with handle as fh:
266
+ for line in fh:
267
+ if "token_count" not in line and "sub_agent_activity" not in line:
268
+ continue
269
+ try:
270
+ rec = json.loads(line)
271
+ except ValueError:
272
+ continue
273
+ if not isinstance(rec, dict):
274
+ continue
275
+ stamp = _iso_epoch(rec.get("timestamp"))
276
+ if stamp is not None and stamp < since:
277
+ continue
278
+ payload = rec.get("payload") or {}
279
+ if not isinstance(payload, dict):
280
+ continue
281
+ if payload.get("type") == "sub_agent_activity":
282
+ out["subagent_events"] += 1
283
+ continue
284
+ if payload.get("type") != "token_count":
285
+ continue
286
+ # last_token_usage is the delta for this request; total_token_usage
287
+ # is cumulative and must never be summed.
288
+ last = ((payload.get("info") or {}).get("last_token_usage")) or {}
289
+ out["buckets"]["main"].add(
290
+ last.get("input_tokens"),
291
+ last.get("cached_input_tokens"),
292
+ last.get("cache_write_input_tokens"),
293
+ last.get("output_tokens"),
294
+ )
295
+ return out
296
+
297
+
298
+ def scan_opencode(since, db=None):
299
+ """The one harness with a pre-aggregated per-session rollup, cost included."""
300
+ db = db or SOURCES["opencode"]
301
+ if not os.path.isfile(db):
302
+ return None
303
+ try:
304
+ import sqlite3
305
+ # Read-only URI: never take a write lock on a live harness's database.
306
+ conn = sqlite3.connect("file:%s?mode=ro" % db, uri=True, timeout=2.0)
307
+ except Exception as exc:
308
+ return {"error": "%s: %s" % (type(exc).__name__, exc)}
309
+ out = {"buckets": _blank(), "cost": 0.0, "sessions": 0, "models": collections.Counter()}
310
+ try:
311
+ rows = conn.execute(
312
+ "SELECT tokens_input, tokens_output, tokens_cache_read, tokens_cache_write, "
313
+ "cost, model, parent_id FROM session WHERE time_updated >= ?",
314
+ (int(since * 1000),),
315
+ ).fetchall()
316
+ except Exception as exc:
317
+ conn.close()
318
+ return {"error": "%s: %s" % (type(exc).__name__, exc)}
319
+ conn.close()
320
+ for inp, output, cread, cwrite, cost, model, parent in rows:
321
+ out["sessions"] += 1
322
+ out["cost"] += cost or 0.0
323
+ out["buckets"]["subagent" if parent else "main"].add(inp, cread, cwrite, output)
324
+ if model:
325
+ out["models"][model] += 1
326
+ out["models"] = dict(out["models"])
327
+ return out
328
+
329
+
330
+ def scan_guard():
331
+ """The guard's own record, and whether its blocks changed anything."""
332
+ try:
333
+ import dispatch_log
334
+ return dispatch_log.summarise(dispatch_log.read())
335
+ except Exception as exc:
336
+ return {"error": "%s: %s" % (type(exc).__name__, exc)}
337
+
338
+
339
+ def routing_compliance(dispatches):
340
+ """Problem (c), quantified: how many dispatches named a tier, and how many
341
+ let the parent's model ride along."""
342
+ tiers = collections.Counter()
343
+ inherited_bytes = 0
344
+ for d in dispatches:
345
+ agent = d.get("agent") or "-"
346
+ if agent.startswith("leo-"):
347
+ tiers[agent] += 1
348
+ elif d.get("model"):
349
+ tiers["explicit model"] += 1
350
+ else:
351
+ tiers["inherited"] += 1
352
+ inherited_bytes += d.get("prompt_bytes") or 0
353
+ total = sum(tiers.values())
354
+ return {
355
+ "dispatches": total,
356
+ "tiers": dict(tiers),
357
+ "inherited_share": round(tiers["inherited"] / total, 3) if total else 0.0,
358
+ "inherited_brief_bytes": inherited_bytes,
359
+ }
360
+
361
+
362
+ def collect(since, only=None):
363
+ report = {"since": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(since)), "harnesses": {}}
364
+ scanners = {"claude": scan_claude, "codex": scan_codex, "opencode": scan_opencode}
365
+ for name in ("claude", "codex", "opencode", "cursor", "hermes", "pi"):
366
+ if only and name != only:
367
+ continue
368
+ scanner = scanners.get(name)
369
+ data = scanner(since) if scanner else None
370
+ if data is None:
371
+ report["harnesses"][name] = {"status": "no data", "looked_in": SOURCES[name]}
372
+ continue
373
+ buckets = data.pop("buckets", None)
374
+ if buckets:
375
+ data["main"] = buckets["main"].as_dict()
376
+ data["subagent"] = buckets["subagent"].as_dict()
377
+ main, sub = data["main"]["effective"], data["subagent"]["effective"]
378
+ data["subagent_share"] = round(sub / (main + sub), 3) if (main + sub) else 0.0
379
+ data["status"] = "ok"
380
+ report["harnesses"][name] = data
381
+
382
+ claude = report["harnesses"].get("claude") or {}
383
+ report["routing"] = routing_compliance(claude.get("dispatches") or [])
384
+ claude.pop("dispatches", None)
385
+ report["guard"] = scan_guard()
386
+ return report
387
+
388
+
389
+ def render(report):
390
+ lines = ["leos-agent usage and effectiveness, since %s" % report["since"], ""]
391
+ for name, data in sorted(report["harnesses"].items()):
392
+ if data.get("status") != "ok":
393
+ lines.append("%-9s no data (%s)" % (name, data["looked_in"]))
394
+ continue
395
+ if data.get("error"):
396
+ lines.append("%-9s unreadable: %s" % (name, data["error"]))
397
+ continue
398
+ main, sub = data.get("main", {}), data.get("subagent", {})
399
+ lines.append("%-9s %d session(s) effective tokens: main %s, subagents %s (%.0f%% delegated)" % (
400
+ name, data.get("sessions", 0), "{:,}".format(main.get("effective", 0)),
401
+ "{:,}".format(sub.get("effective", 0)), 100 * data.get("subagent_share", 0.0)))
402
+ if main.get("cache_write"):
403
+ ratio = main["cache_read"] / main["cache_write"]
404
+ lines.append(" cache read/write ratio %.1f (higher is cheaper; a low ratio means cold prefixes)" % ratio)
405
+ if data.get("cost"):
406
+ lines.append(" provider-reported cost $%.2f" % data["cost"])
407
+ if data.get("compactions"):
408
+ lines.append(" %d compaction(s), %s tokens discarded" % (
409
+ data["compactions"], "{:,}".format(data["precompact_tokens"])))
410
+ if data.get("agent_types"):
411
+ top = sorted(data["agent_types"].items(), key=lambda kv: -kv[1])[:6]
412
+ lines.append(" subagents: " + ", ".join("%s %d" % kv for kv in top))
413
+
414
+ routing = report["routing"]
415
+ lines += ["", "Routing compliance (Claude Code dispatches seen in transcripts)"]
416
+ if not routing["dispatches"]:
417
+ lines.append(" none in this window")
418
+ else:
419
+ lines.append(" %d dispatch(es): %s" % (
420
+ routing["dispatches"], ", ".join("%s %d" % kv for kv in sorted(routing["tiers"].items()))))
421
+ lines.append(" %.0f%% named no model and inherited the parent's" % (100 * routing["inherited_share"]))
422
+
423
+ guard = report["guard"]
424
+ lines += ["", "Guard"]
425
+ if guard.get("error"):
426
+ lines.append(" log unreadable: %s" % guard["error"])
427
+ elif not guard.get("records"):
428
+ lines.append(" no dispatches recorded -- the guard may not be installed or approved on any harness")
429
+ else:
430
+ lines.append(" %d recorded, %d blocked, %d re-dispatched with a tier named" % (
431
+ guard["records"], guard.get("blocked", 0), guard.get("converted", 0)))
432
+ lines.append(" %d lone small spawn(s) (fan-outs excluded)" % guard.get("trivial_lone_spawns", 0))
433
+ if guard.get("errors"):
434
+ lines.append(" !! %d guard error(s): it failed open this many times" % guard["errors"])
435
+ missing = [h for h, d in report["harnesses"].items()
436
+ if d.get("status") == "ok" and h not in (guard.get("harnesses") or {})]
437
+ if missing:
438
+ lines.append(" no guard rows from: %s -- installed but not enforcing?" % ", ".join(sorted(missing)))
439
+ return "\n".join(lines)
440
+
441
+
442
+ def main(argv=None):
443
+ parser = argparse.ArgumentParser(prog="usage_scan.py", description=__doc__.splitlines()[0])
444
+ parser.add_argument("--since", default="7d", help="window, e.g. 24h, 7d, 2w (default 7d)")
445
+ parser.add_argument("--harness", choices=sorted(SOURCES), help="only this harness")
446
+ parser.add_argument("--json", action="store_true", help="machine-readable")
447
+ args = parser.parse_args(argv)
448
+
449
+ report = collect(parse_since(args.since), args.harness)
450
+ print(json.dumps(report, indent=1, sort_keys=True) if args.json else render(report))
451
+ return 0
452
+
453
+
454
+ if __name__ == "__main__":
455
+ sys.exit(main())