@ictechgy/context-guard 0.4.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +4 -0
  4. package/README.ko.md +353 -0
  5. package/README.md +353 -0
  6. package/context-guard-kit/README.md +76 -0
  7. package/context-guard-kit/benchmark_runner.py +1898 -0
  8. package/context-guard-kit/claude_transcript_cost_audit.py +1591 -0
  9. package/context-guard-kit/context_compress.py +543 -0
  10. package/context-guard-kit/context_escrow.py +919 -0
  11. package/context-guard-kit/context_guard_cli.py +149 -0
  12. package/context-guard-kit/context_guard_diet.py +1036 -0
  13. package/context-guard-kit/context_pack.py +929 -0
  14. package/context-guard-kit/failed_attempt_nudge.py +567 -0
  15. package/context-guard-kit/guard_large_read.py +690 -0
  16. package/context-guard-kit/hook_secret_patterns.py +43 -0
  17. package/context-guard-kit/read_symbol.py +483 -0
  18. package/context-guard-kit/rewrite_bash_for_token_budget.py +501 -0
  19. package/context-guard-kit/sanitize_output.py +725 -0
  20. package/context-guard-kit/settings.example.json +67 -0
  21. package/context-guard-kit/setup_wizard.py +1724 -0
  22. package/context-guard-kit/statusline.sh +362 -0
  23. package/context-guard-kit/statusline_merged.sh +157 -0
  24. package/context-guard-kit/tool_schema_pruner.py +837 -0
  25. package/context-guard-kit/trim_command_output.py +1098 -0
  26. package/docs/distribution.md +55 -0
  27. package/package.json +70 -0
  28. package/packaging/homebrew/context-guard.rb.template +34 -0
  29. package/plugins/context-guard/.claude-plugin/plugin.json +41 -0
  30. package/plugins/context-guard/LICENSE +201 -0
  31. package/plugins/context-guard/NOTICE +4 -0
  32. package/plugins/context-guard/README.ko.md +135 -0
  33. package/plugins/context-guard/README.md +135 -0
  34. package/plugins/context-guard/bin/claude-read-symbol +6 -0
  35. package/plugins/context-guard/bin/claude-sanitize-output +6 -0
  36. package/plugins/context-guard/bin/claude-token-artifact +6 -0
  37. package/plugins/context-guard/bin/claude-token-audit +6 -0
  38. package/plugins/context-guard/bin/claude-token-bench +6 -0
  39. package/plugins/context-guard/bin/claude-token-diet +6 -0
  40. package/plugins/context-guard/bin/claude-token-failed-nudge +6 -0
  41. package/plugins/context-guard/bin/claude-token-guard-read +6 -0
  42. package/plugins/context-guard/bin/claude-token-rewrite-bash +6 -0
  43. package/plugins/context-guard/bin/claude-token-setup +6 -0
  44. package/plugins/context-guard/bin/claude-token-statusline +6 -0
  45. package/plugins/context-guard/bin/claude-token-statusline-merged +6 -0
  46. package/plugins/context-guard/bin/claude-trim-output +6 -0
  47. package/plugins/context-guard/bin/context-guard +149 -0
  48. package/plugins/context-guard/bin/context-guard-artifact +919 -0
  49. package/plugins/context-guard/bin/context-guard-audit +1591 -0
  50. package/plugins/context-guard/bin/context-guard-bench +1898 -0
  51. package/plugins/context-guard/bin/context-guard-compress +543 -0
  52. package/plugins/context-guard/bin/context-guard-diet +1036 -0
  53. package/plugins/context-guard/bin/context-guard-failed-nudge +567 -0
  54. package/plugins/context-guard/bin/context-guard-guard-read +690 -0
  55. package/plugins/context-guard/bin/context-guard-pack +929 -0
  56. package/plugins/context-guard/bin/context-guard-read-symbol +483 -0
  57. package/plugins/context-guard/bin/context-guard-rewrite-bash +501 -0
  58. package/plugins/context-guard/bin/context-guard-sanitize-output +725 -0
  59. package/plugins/context-guard/bin/context-guard-setup +1724 -0
  60. package/plugins/context-guard/bin/context-guard-statusline +362 -0
  61. package/plugins/context-guard/bin/context-guard-statusline-merged +157 -0
  62. package/plugins/context-guard/bin/context-guard-tool-prune +837 -0
  63. package/plugins/context-guard/bin/context-guard-trim-output +1098 -0
  64. package/plugins/context-guard/brief/README.md +65 -0
  65. package/plugins/context-guard/brief/brief-mode.lite.md +29 -0
  66. package/plugins/context-guard/brief/brief-mode.standard.md +31 -0
  67. package/plugins/context-guard/brief/brief-mode.ultra.md +32 -0
  68. package/plugins/context-guard/lib/hook_secret_patterns.py +43 -0
  69. package/plugins/context-guard/skills/audit/SKILL.md +39 -0
  70. package/plugins/context-guard/skills/optimize/SKILL.md +48 -0
  71. package/plugins/context-guard/skills/setup/SKILL.md +40 -0
@@ -0,0 +1,1591 @@
1
+ #!/usr/bin/env python3
2
+ """Best-effort Claude Code transcript usage auditor.
3
+
4
+ Claude Code transcript schemas may change. This script scans JSONL objects for
5
+ common token/cost fields rather than relying on one exact schema. It reports
6
+ parse/read skips so totals are not mistaken for billing-authoritative data.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import datetime as _dt
12
+ import errno
13
+ import hashlib
14
+ import json
15
+ import math
16
+ import os
17
+ import re
18
+ import shlex
19
+ import stat
20
+ import sys
21
+ from collections import Counter, defaultdict
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, BinaryIO, Iterable
25
+
26
+ TOKEN_KEY_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
27
+ ("input", ("input_tokens",)),
28
+ ("output", ("output_tokens",)),
29
+ ("cache_creation", ("cache_creation_input_tokens", "cacheCreation")),
30
+ ("cache_read", ("cache_read_input_tokens", "cacheRead")),
31
+ )
32
+ KNOWN_TOKEN_BUCKETS = {bucket for bucket, _ in TOKEN_KEY_GROUPS}
33
+ TOKEN_TYPE_ALIASES = {
34
+ "input": "input",
35
+ "input_tokens": "input",
36
+ "output": "output",
37
+ "output_tokens": "output",
38
+ "cacheRead": "cache_read",
39
+ "cache_read": "cache_read",
40
+ "cache_read_input_tokens": "cache_read",
41
+ "cacheCreation": "cache_creation",
42
+ "cache_creation": "cache_creation",
43
+ "cache_creation_input_tokens": "cache_creation",
44
+ }
45
+ COST_KEYS = ("total_cost_usd", "cost_usd", "costUSD")
46
+ MODEL_KEYS = ("model", "model_id", "modelId")
47
+ QUERY_SOURCE_KEYS = ("query_source", "querySource")
48
+ FEASIBILITY_SCHEMA_VERSION = "contextguard.metric-feasibility.v1.1"
49
+ FEASIBILITY_PRODUCER = "context-guard-audit"
50
+ MAX_ERROR_EXAMPLES = 20
51
+ JSON_PARSE_RECURSION_LIMIT = 10_000
52
+ READ_CHUNK_BYTES = 64 * 1024
53
+ DEFAULT_MAX_FILE_BYTES = 50 * 1024 * 1024
54
+ DEFAULT_MAX_LINE_BYTES = 2 * 1024 * 1024
55
+ MAX_FILE_BYTES_LIMIT = 2 * 1024 * 1024 * 1024
56
+ MAX_LINE_BYTES_LIMIT = 128 * 1024 * 1024
57
+ SECRET_VALUE_RE = re.compile(
58
+ r"(?i)(gh[pousr]_[A-Za-z0-9_]{8,}|github_pat_[A-Za-z0-9_]{20,}|"
59
+ r"xox[abprs]-[A-Za-z0-9-]{8,}|(?:AKIA|ASIA)[0-9A-Z]{8,}|"
60
+ r"AIza[0-9A-Za-z_\-]{8,}|Bearer\s+[A-Za-z0-9._~+/=-]+|"
61
+ r"Basic\s+[A-Za-z0-9._~+/=-]+|"
62
+ r"sk-ant-[A-Za-z0-9_-]{12,}|sk-[A-Za-z0-9_-]{12,}|glpat-[A-Za-z0-9_-]{12,}|"
63
+ r"npm_[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|"
64
+ r"[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@|"
65
+ r"(?:--password|-p)\s+\S+|(?:-u|--user)\s+\S+:\S+|"
66
+ r"(api[_-]?key|token|secret|password)=\S+)"
67
+ )
68
+ REDACTED_PATH_COMPONENT = "[REDACTED-PATH-COMPONENT]"
69
+ COMMAND_KEYS = ("command", "cmd")
70
+ TOOL_NAME_KEYS = ("tool_name", "toolName", "tool")
71
+ PROMPT_AUDIT_MAX_RECORDS = 200
72
+ PROMPT_AUDIT_MAX_TEXT_BYTES = 32 * 1024
73
+ PROMPT_AUDIT_MAX_SEGMENTS_PER_RECORD = 32
74
+ PROMPT_AUDIT_PREFIX_SEGMENTS = 3
75
+ PROMPT_AUDIT_TAIL_SEGMENTS = 3
76
+ PROMPT_AUDIT_MIN_RECORDS = 3
77
+ PROMPT_PREFIX_VOLATILE_THRESHOLD = 0.66
78
+ PROMPT_PREFIX_TAIL_CHURN_DELTA = 0.34
79
+ PROMPT_AUDIT_MAX_FINDINGS = 5
80
+ PROMPT_SEGMENT_HASH_CHARS = 16
81
+ PROMPT_AUDIT_MAX_TEXT_VALUES = 64
82
+ PROMPT_AUDIT_MAX_ROOT_NODES = 4096
83
+ PROMPT_AUDIT_MAX_CONTENT_NODES = 2048
84
+ PROMPT_AUDIT_MAX_DEPTH = 64
85
+ USER_PROMPT_ROLES = {"user", "human"}
86
+ TEXT_BLOCK_TYPES = {"text", "input_text"}
87
+
88
+
89
+ def push_bounded(
90
+ stack: list[tuple[Any, int]],
91
+ items: Iterable[Any],
92
+ depth: int,
93
+ *,
94
+ visited: int,
95
+ max_nodes: int,
96
+ ) -> bool:
97
+ """Push traversal children without letting broad structures grow unbounded."""
98
+ budget = max(0, max_nodes - visited - len(stack))
99
+ if budget <= 0:
100
+ return True
101
+ pushed = 0
102
+ capped = False
103
+ for item in items:
104
+ if pushed >= budget:
105
+ capped = True
106
+ break
107
+ stack.append((item, depth))
108
+ pushed += 1
109
+ return capped
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class PromptSegmentSample:
114
+ prefix_hashes: tuple[str, ...]
115
+ tail_hashes: tuple[str, ...]
116
+ segment_count: int
117
+ bytes_sampled: int
118
+ redactions: int
119
+
120
+
121
+ @dataclass
122
+ class RecordUsage:
123
+ tokens: Counter[str] = field(default_factory=Counter)
124
+ cost_usd: float = 0.0
125
+ commands: set[str] = field(default_factory=set)
126
+ tools: set[str] = field(default_factory=set)
127
+
128
+
129
+ @dataclass
130
+ class PromptCacheAudit:
131
+ sampled_records: int = 0
132
+ analyzed_prompt_records: int = 0
133
+ capped_records: int = 0
134
+ prompt_collection_capped_records: int = 0
135
+ total_segments: int = 0
136
+ total_bytes_sampled: int = 0
137
+ redacted_segments: int = 0
138
+ samples: list[PromptSegmentSample] = field(default_factory=list)
139
+
140
+ def observe(self, root: Any) -> None:
141
+ self.sampled_records += 1
142
+ segments, bytes_sampled, redactions, collection_capped = prompt_segments_for_record(root)
143
+ if collection_capped:
144
+ self.prompt_collection_capped_records += 1
145
+ if not segments:
146
+ return
147
+ if len(self.samples) >= PROMPT_AUDIT_MAX_RECORDS:
148
+ self.capped_records += 1
149
+ return
150
+ self.analyzed_prompt_records += 1
151
+ self.total_segments += len(segments)
152
+ self.total_bytes_sampled += bytes_sampled
153
+ self.redacted_segments += redactions
154
+ self.samples.append(PromptSegmentSample(
155
+ prefix_hashes=tuple(stable_hash(segment, PROMPT_SEGMENT_HASH_CHARS) for segment in segments[:PROMPT_AUDIT_PREFIX_SEGMENTS]),
156
+ tail_hashes=tuple(stable_hash(segment, PROMPT_SEGMENT_HASH_CHARS) for segment in segments[-PROMPT_AUDIT_TAIL_SEGMENTS:]),
157
+ segment_count=len(segments),
158
+ bytes_sampled=bytes_sampled,
159
+ redactions=redactions,
160
+ ))
161
+
162
+
163
+ @dataclass
164
+ class UsageSummary:
165
+ files: int = 0
166
+ records: int = 0
167
+ skipped_files: int = 0
168
+ skipped_records: int = 0
169
+ parse_errors: list[str] = field(default_factory=list)
170
+ tokens: Counter[str] = field(default_factory=Counter)
171
+ cost_usd: float = 0.0
172
+ by_model: dict[str, Counter[str]] = field(default_factory=lambda: defaultdict(Counter))
173
+ by_query_source: dict[str, Counter[str]] = field(default_factory=lambda: defaultdict(Counter))
174
+ by_file: Counter[str] = field(default_factory=Counter)
175
+ cost_by_file: Counter[str] = field(default_factory=Counter)
176
+ by_command: Counter[str] = field(default_factory=Counter)
177
+ by_tool: Counter[str] = field(default_factory=Counter)
178
+ token_field_presence: Counter[str] = field(default_factory=Counter)
179
+ cost_field_count: int = 0
180
+ prompt_cache_audit: PromptCacheAudit = field(default_factory=PromptCacheAudit)
181
+ cache_friendliness_cache: dict[str, Any] | None = field(default=None, init=False, repr=False)
182
+
183
+ @property
184
+ def total_tokens(self) -> int:
185
+ return sum(self.tokens.values())
186
+
187
+ @property
188
+ def cache_hit_rate(self) -> float:
189
+ """cache_read의 입력 측 비중 = cache_read / (input + cache_read + cache_creation).
190
+
191
+ cache_creation이 분모에 포함되므로 신규 prefix를 막 만든 세션에서는 비율이 낮게
192
+ 나타날 수 있다. 고전적 hit-rate(cache 가능 풀 대비 hit)가 아니라 입력 비용 절감
193
+ 지표로 해석해야 한다. denom == 0이면 0.0.
194
+ """
195
+ cr = self.tokens.get("cache_read", 0)
196
+ cc = self.tokens.get("cache_creation", 0)
197
+ inp = self.tokens.get("input", 0)
198
+ denom = cr + cc + inp
199
+ return (cr / denom) if denom > 0 else 0.0
200
+
201
+ @property
202
+ def cache_amortization(self) -> float:
203
+ """cache_read / cache_creation. 토큰 단위로 본 평균 재사용 배수의 근사.
204
+
205
+ cache_creation == 0인 경우 의미가 정의되지 않으므로 0.0을 반환한다 (정의되지 않음을
206
+ 표현하기 위해 cache_amortization_defined 플래그를 함께 노출한다). 같은 prefix가
207
+ 길이 변화 없이 N회 재사용되면 토큰 비도 약 N배가 되지만, prefix 길이가 변하는
208
+ 세션에서는 정확히 호출 횟수가 아닌 토큰 비율로 본 근사값임에 주의.
209
+ """
210
+ cc = self.tokens.get("cache_creation", 0)
211
+ cr = self.tokens.get("cache_read", 0)
212
+ return (cr / cc) if cc > 0 else 0.0
213
+
214
+ @property
215
+ def cache_amortization_defined(self) -> bool:
216
+ """cache_amortization이 의미를 갖는지 여부. cache_creation > 0일 때만 True."""
217
+ return self.tokens.get("cache_creation", 0) > 0
218
+
219
+ def note_error(self, message: str) -> None:
220
+ if len(self.parse_errors) < MAX_ERROR_EXAMPLES:
221
+ self.parse_errors.append(message)
222
+
223
+
224
+ def iter_jsonl_files(paths: Iterable[str]) -> Iterable[Path]:
225
+ seen: set[Path] = set()
226
+ for raw in paths:
227
+ path = Path(raw).expanduser()
228
+ root = path.resolve()
229
+ candidates: Iterable[Path]
230
+ if path.is_file() and path.suffix in {".jsonl", ".json"}:
231
+ candidates = [path]
232
+ elif path.is_dir():
233
+ candidates = (
234
+ candidate
235
+ for pattern in ("*.jsonl", "*.json")
236
+ for candidate in path.rglob(pattern)
237
+ )
238
+ else:
239
+ continue
240
+ for candidate in candidates:
241
+ if candidate.is_symlink():
242
+ # The scanner opens candidates with O_NOFOLLOW and will skip
243
+ # this path. Do not let a rejected link reserve its target's
244
+ # dedupe key and suppress a later real transcript in scope.
245
+ yield candidate
246
+ continue
247
+ resolved = candidate.resolve()
248
+ try:
249
+ resolved.relative_to(root if path.is_dir() else root.parent)
250
+ except ValueError:
251
+ continue
252
+ if resolved in seen:
253
+ continue
254
+ seen.add(resolved)
255
+ yield candidate
256
+
257
+
258
+ def walk(obj: Any) -> Iterable[dict[str, Any]]:
259
+ stack = [obj]
260
+ while stack:
261
+ current = stack.pop()
262
+ if isinstance(current, dict):
263
+ yield current
264
+ stack.extend(current.values())
265
+ elif isinstance(current, list):
266
+ stack.extend(current)
267
+
268
+
269
+ def first_string(obj: dict[str, Any], keys: Iterable[str]) -> str | None:
270
+ for key in keys:
271
+ val = obj.get(key)
272
+ if isinstance(val, str):
273
+ return val
274
+ if isinstance(val, dict):
275
+ nested = val.get("id") or val.get("name")
276
+ if isinstance(nested, str):
277
+ return nested
278
+ return None
279
+
280
+
281
+ MAX_METRIC_VALUE = 10**18
282
+
283
+
284
+ def finite_nonnegative_number(value: Any, *, clamp_negative: bool) -> int | float | None:
285
+ if isinstance(value, bool):
286
+ return None
287
+ if isinstance(value, int):
288
+ if value < 0 and not clamp_negative:
289
+ return None
290
+ return min(max(value, 0), MAX_METRIC_VALUE)
291
+ if isinstance(value, float):
292
+ if not math.isfinite(value) or (value < 0 and not clamp_negative):
293
+ return None
294
+ return min(max(value, 0.0), float(MAX_METRIC_VALUE))
295
+ return None
296
+
297
+
298
+ def normalize_token_bucket(raw: str) -> str:
299
+ return TOKEN_TYPE_ALIASES.get(raw, raw)
300
+
301
+
302
+ def stable_token_counter(tokens: Counter[str]) -> dict[str, int]:
303
+ return {bucket: tokens[bucket] for bucket in sorted(KNOWN_TOKEN_BUCKETS) if tokens.get(bucket, 0) != 0}
304
+
305
+
306
+ def stable_token_presence(presence: Counter[str]) -> dict[str, int]:
307
+ return {bucket: presence[bucket] for bucket in sorted(KNOWN_TOKEN_BUCKETS) if presence.get(bucket, 0) > 0}
308
+
309
+
310
+ def add_token_groups(local_tokens: Counter[str], d: dict[str, Any]) -> set[str]:
311
+ present: set[str] = set()
312
+ for bucket, keys in TOKEN_KEY_GROUPS:
313
+ for raw_key in keys:
314
+ val = d.get(raw_key)
315
+ metric = finite_nonnegative_number(val, clamp_negative=True)
316
+ if metric is not None:
317
+ local_tokens[bucket] += int(metric)
318
+ present.add(bucket)
319
+ break
320
+ return present
321
+
322
+
323
+ def sanitize_label(value: str, limit: int = 120) -> str:
324
+ compact = " ".join(value.strip().split())
325
+ compact = SECRET_VALUE_RE.sub("[REDACTED]", compact)
326
+ if len(compact) > limit:
327
+ compact = compact[: limit - 15].rstrip() + " ...[truncated]"
328
+ return compact
329
+
330
+
331
+ def stable_hash(value: str, length: int = 12) -> str:
332
+ return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()[:length]
333
+
334
+
335
+ def truncate_utf8(text: str, max_bytes: int) -> tuple[str, bool]:
336
+ raw = text.encode("utf-8", errors="replace")
337
+ if len(raw) <= max_bytes:
338
+ return text, False
339
+ return raw[:max_bytes].decode("utf-8", errors="ignore"), True
340
+
341
+
342
+ def collect_content_text(value: Any, out: list[str]) -> bool:
343
+ """Collect allowlisted text blocks without recursive descent.
344
+
345
+ Returns True when collection hit a bounded traversal cap. Deep or very broad
346
+ transcript shapes should downgrade cache-friendliness evidence instead of
347
+ crashing the whole audit.
348
+ """
349
+ capped = False
350
+ visited = 0
351
+ stack: list[tuple[Any, int]] = [(value, 0)]
352
+ while stack and len(out) < PROMPT_AUDIT_MAX_TEXT_VALUES:
353
+ current, depth = stack.pop()
354
+ visited += 1
355
+ if visited > PROMPT_AUDIT_MAX_CONTENT_NODES or depth > PROMPT_AUDIT_MAX_DEPTH:
356
+ capped = True
357
+ break
358
+ if isinstance(current, str):
359
+ if current.strip():
360
+ out.append(current)
361
+ continue
362
+ if isinstance(current, list):
363
+ if depth >= PROMPT_AUDIT_MAX_DEPTH:
364
+ capped = True
365
+ continue
366
+ capped = push_bounded(
367
+ stack,
368
+ reversed(current),
369
+ depth + 1,
370
+ visited=visited,
371
+ max_nodes=PROMPT_AUDIT_MAX_CONTENT_NODES,
372
+ ) or capped
373
+ continue
374
+ if not isinstance(current, dict):
375
+ continue
376
+ block_type = current.get("type")
377
+ if block_type in TEXT_BLOCK_TYPES and isinstance(current.get("text"), str):
378
+ stack.append((current.get("text"), depth + 1))
379
+ continue
380
+ if depth >= PROMPT_AUDIT_MAX_DEPTH:
381
+ capped = True
382
+ continue
383
+ if "content" in current:
384
+ capped = push_bounded(
385
+ stack,
386
+ (current.get("content"),),
387
+ depth + 1,
388
+ visited=visited,
389
+ max_nodes=PROMPT_AUDIT_MAX_CONTENT_NODES,
390
+ ) or capped
391
+ if isinstance(current.get("text"), str):
392
+ capped = push_bounded(
393
+ stack,
394
+ (current.get("text"),),
395
+ depth + 1,
396
+ visited=visited,
397
+ max_nodes=PROMPT_AUDIT_MAX_CONTENT_NODES,
398
+ ) or capped
399
+ if stack or len(out) >= PROMPT_AUDIT_MAX_TEXT_VALUES:
400
+ capped = True
401
+ return capped
402
+
403
+
404
+ def extract_prompt_texts(root: Any) -> tuple[list[str], bool]:
405
+ """Best-effort prompt text extraction from allowlisted user/prompt shapes."""
406
+ texts: list[str] = []
407
+ capped = False
408
+ visited = 0
409
+ stack: list[tuple[Any, int]] = [(root, 0)]
410
+ while stack and len(texts) < PROMPT_AUDIT_MAX_TEXT_VALUES:
411
+ current, depth = stack.pop()
412
+ visited += 1
413
+ if visited > PROMPT_AUDIT_MAX_ROOT_NODES or depth > PROMPT_AUDIT_MAX_DEPTH:
414
+ capped = True
415
+ break
416
+ if isinstance(current, dict):
417
+ role = current.get("role")
418
+ role_text = str(role).lower() if isinstance(role, str) else ""
419
+ if role_text in USER_PROMPT_ROLES:
420
+ if "content" in current:
421
+ capped = collect_content_text(current.get("content"), texts) or capped
422
+ if isinstance(current.get("text"), str):
423
+ capped = collect_content_text(current.get("text"), texts) or capped
424
+ if isinstance(current.get("prompt"), str):
425
+ capped = collect_content_text(current.get("prompt"), texts) or capped
426
+ # Role-scoped content was handled above; do not re-walk it and
427
+ # risk duplicating text blocks.
428
+ continue
429
+ prompt = current.get("prompt")
430
+ if isinstance(prompt, str) and prompt.strip():
431
+ texts.append(prompt)
432
+ if depth >= PROMPT_AUDIT_MAX_DEPTH:
433
+ capped = True
434
+ continue
435
+ capped = push_bounded(
436
+ stack,
437
+ current.values(),
438
+ depth + 1,
439
+ visited=visited,
440
+ max_nodes=PROMPT_AUDIT_MAX_ROOT_NODES,
441
+ ) or capped
442
+ elif isinstance(current, list):
443
+ if depth >= PROMPT_AUDIT_MAX_DEPTH:
444
+ capped = True
445
+ continue
446
+ capped = push_bounded(
447
+ stack,
448
+ reversed(current),
449
+ depth + 1,
450
+ visited=visited,
451
+ max_nodes=PROMPT_AUDIT_MAX_ROOT_NODES,
452
+ ) or capped
453
+ if stack or len(texts) >= PROMPT_AUDIT_MAX_TEXT_VALUES:
454
+ capped = True
455
+ return texts, capped
456
+
457
+
458
+ def prompt_segments_for_record(root: Any) -> tuple[list[str], int, int, bool]:
459
+ texts, collection_capped = extract_prompt_texts(root)
460
+ if not texts:
461
+ return [], 0, 0, collection_capped
462
+ budget = PROMPT_AUDIT_MAX_TEXT_BYTES
463
+ segments: list[str] = []
464
+ bytes_sampled = 0
465
+ redactions = 0
466
+ for text in texts:
467
+ if budget <= 0 or len(segments) >= PROMPT_AUDIT_MAX_SEGMENTS_PER_RECORD:
468
+ break
469
+ clipped, _truncated = truncate_utf8(text, budget)
470
+ sanitized, count = SECRET_VALUE_RE.subn("[REDACTED]", clipped)
471
+ redactions += count
472
+ bytes_sampled += len(sanitized.encode("utf-8", errors="replace"))
473
+ budget = max(0, PROMPT_AUDIT_MAX_TEXT_BYTES - bytes_sampled)
474
+ for raw_line in sanitized.splitlines():
475
+ compact = " ".join(raw_line.strip().split())
476
+ if not compact:
477
+ continue
478
+ segment, _ = truncate_utf8(compact, 512)
479
+ segments.append(segment)
480
+ if len(segments) >= PROMPT_AUDIT_MAX_SEGMENTS_PER_RECORD:
481
+ break
482
+ if not segments and sanitized.strip():
483
+ segment, _ = truncate_utf8(" ".join(sanitized.strip().split()), 512)
484
+ if segment:
485
+ segments.append(segment)
486
+ return segments, bytes_sampled, redactions, collection_capped
487
+
488
+
489
+ def safe_resolve(path: Path) -> Path:
490
+ try:
491
+ return path.resolve()
492
+ except (OSError, RuntimeError):
493
+ return path.absolute()
494
+
495
+
496
+ def path_component_contains_secret(component: str) -> bool:
497
+ return bool(component and component not in {".", ".."} and SECRET_VALUE_RE.search(component))
498
+
499
+
500
+ def sanitize_path_component(component: str) -> str:
501
+ if not component or component in {".", ".."}:
502
+ return component
503
+ if not path_component_contains_secret(component):
504
+ return component
505
+ return REDACTED_PATH_COMPONENT
506
+
507
+
508
+ def sanitize_path_text(path: str) -> str:
509
+ return "/".join(sanitize_path_component(component) for component in path.replace(os.sep, "/").split("/"))
510
+
511
+
512
+ def display_path_hash(path: Path) -> str:
513
+ return stable_hash(sanitize_path_text(str(safe_resolve(path))))
514
+
515
+
516
+ def path_label(path: Path, show_paths: bool = False) -> str:
517
+ if show_paths:
518
+ return sanitize_path_text(str(path))
519
+ name = sanitize_label(sanitize_path_component(path.name or "transcript"), 80)
520
+ return f"{name}#path:{display_path_hash(path)}"
521
+
522
+
523
+ def command_label(command: str, show_commands: bool = False) -> str:
524
+ sanitized = sanitize_label(command)
525
+ if show_commands:
526
+ return sanitized
527
+ try:
528
+ argv = shlex.split(sanitized)
529
+ except ValueError:
530
+ argv = sanitized.split()
531
+ if not argv:
532
+ category = "command"
533
+ elif len(argv) >= 3 and argv[0] in {"python", "python3"} and argv[1] == "-m":
534
+ category = " ".join(argv[:3])
535
+ elif len(argv) >= 2 and argv[0] in {"npm", "pnpm", "yarn", "bun"} and argv[1] in {"run", "run-script"}:
536
+ category = " ".join(argv[:3]) if len(argv) >= 3 else " ".join(argv[:2])
537
+ else:
538
+ category = argv[0]
539
+ return f"{category}#cmd:{stable_hash(sanitized)}"
540
+
541
+
542
+ def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
543
+ try:
544
+ number = int(value)
545
+ except (TypeError, ValueError, OverflowError):
546
+ return default
547
+ return min(max(number, minimum), maximum)
548
+
549
+
550
+ def require_scan_limit(parser: argparse.ArgumentParser, option: str, value: int, maximum: int) -> int:
551
+ if value < 1 or value > maximum:
552
+ parser.error(f"{option} must be between 1 and {maximum}")
553
+ return value
554
+
555
+
556
+ def os_error_summary(exc: OSError) -> str:
557
+ """Return OSError metadata without embedding raw filenames from str(exc)."""
558
+ parts = [exc.__class__.__name__]
559
+ if exc.errno is not None:
560
+ parts.append(f"errno={exc.errno}")
561
+ message = sanitize_label(str(exc.strerror or ""), 160)
562
+ if message:
563
+ parts.append(message)
564
+ return ": ".join(parts)
565
+
566
+
567
+ @dataclass(frozen=True)
568
+ class ScanLimits:
569
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES
570
+ max_line_bytes: int = DEFAULT_MAX_LINE_BYTES
571
+
572
+
573
+ def open_regular_no_symlink(file: Path):
574
+ """Open a transcript candidate only if it is still a regular non-symlink file."""
575
+ before = file.lstat()
576
+ if stat.S_ISLNK(before.st_mode):
577
+ raise OSError(errno.ELOOP, "transcript file must not be a symlink", str(file))
578
+ if not stat.S_ISREG(before.st_mode):
579
+ raise OSError(errno.EINVAL, "transcript file must be a regular file", str(file))
580
+ flags = os.O_RDONLY
581
+ for optional_flag in ("O_CLOEXEC", "O_NOFOLLOW", "O_NONBLOCK"):
582
+ flags |= getattr(os, optional_flag, 0)
583
+ fd = os.open(file, flags)
584
+ try:
585
+ opened = os.fstat(fd)
586
+ after = file.lstat()
587
+ if (
588
+ not stat.S_ISREG(opened.st_mode)
589
+ or not os.path.samestat(before, opened)
590
+ or not os.path.samestat(after, opened)
591
+ ):
592
+ raise OSError(errno.ELOOP, "transcript file changed while opening", str(file))
593
+ return os.fdopen(fd, "rb")
594
+ except Exception:
595
+ os.close(fd)
596
+ raise
597
+
598
+
599
+ def iter_bounded_lines(handle: BinaryIO, max_line_bytes: int) -> Iterable[tuple[int, str | None]]:
600
+ """Yield decoded lines without retaining an oversized JSONL record in memory.
601
+
602
+ `None` means the record exceeded `max_line_bytes` and was skipped after the
603
+ iterator consumed bytes up to the next newline. This keeps transcript audit
604
+ robust when a corrupted trace contains one huge single-line payload.
605
+ """
606
+ line_no = 1
607
+ buffer = bytearray()
608
+ oversized = False
609
+ while True:
610
+ chunk = handle.read(READ_CHUNK_BYTES)
611
+ if not chunk:
612
+ if oversized:
613
+ yield line_no, None
614
+ elif buffer:
615
+ yield line_no, buffer.decode("utf-8", errors="replace")
616
+ break
617
+
618
+ start = 0
619
+ while start < len(chunk):
620
+ newline = chunk.find(b"\n", start)
621
+ end = len(chunk) if newline == -1 else newline + 1
622
+ piece = chunk[start:end]
623
+
624
+ if not oversized:
625
+ if len(buffer) + len(piece) > max_line_bytes:
626
+ buffer.clear()
627
+ oversized = True
628
+ else:
629
+ buffer.extend(piece)
630
+
631
+ if newline == -1:
632
+ break
633
+
634
+ if oversized:
635
+ yield line_no, None
636
+ else:
637
+ yield line_no, buffer.decode("utf-8", errors="replace")
638
+ buffer.clear()
639
+ line_no += 1
640
+ oversized = False
641
+ start = end
642
+
643
+
644
+ def collect_record_hints(root: Any, show_commands: bool = False) -> tuple[set[str], set[str]]:
645
+ commands: set[str] = set()
646
+ tools: set[str] = set()
647
+ for d in walk(root):
648
+ for key in COMMAND_KEYS:
649
+ value = d.get(key)
650
+ if isinstance(value, str) and value.strip():
651
+ commands.add(command_label(value, show_commands=show_commands))
652
+ for key in TOOL_NAME_KEYS:
653
+ value = d.get(key)
654
+ if isinstance(value, str) and value.strip():
655
+ name = sanitize_label(value, 80)
656
+ if name and len(name.split()) <= 4:
657
+ tools.add(name)
658
+ return commands, tools
659
+
660
+
661
+ def add_usage(
662
+ summary: UsageSummary,
663
+ root: Any,
664
+ file: Path | None = None,
665
+ show_paths: bool = False,
666
+ show_commands: bool = False,
667
+ ) -> RecordUsage:
668
+ root_model = None
669
+ root_query_source = None
670
+ if isinstance(root, dict):
671
+ root_model = first_string(root, MODEL_KEYS)
672
+ root_query_source = first_string(root, QUERY_SOURCE_KEYS)
673
+
674
+ record = RecordUsage()
675
+ summary.prompt_cache_audit.observe(root)
676
+ for d in walk(root):
677
+ local_tokens: Counter[str] = Counter()
678
+ present_buckets = add_token_groups(local_tokens, d)
679
+
680
+ # OpenTelemetry-style records sometimes use {name, value, attributes.type}.
681
+ name = d.get("name") or d.get("metric")
682
+ if name == "claude_code.token.usage":
683
+ value = d.get("value")
684
+ if value is None:
685
+ value = d.get("sum")
686
+ if value is None:
687
+ value = d.get("count")
688
+ attrs = d.get("attributes") or {}
689
+ token_type = attrs.get("type", "unknown") if isinstance(attrs, dict) else "unknown"
690
+ metric = finite_nonnegative_number(value, clamp_negative=True)
691
+ if metric is not None:
692
+ bucket = normalize_token_bucket(str(token_type))
693
+ local_tokens[bucket] += int(metric)
694
+ present_buckets.add(bucket)
695
+
696
+ for bucket in present_buckets:
697
+ summary.token_field_presence[bucket] += 1
698
+
699
+ if local_tokens:
700
+ summary.tokens.update(local_tokens)
701
+ record.tokens.update(local_tokens)
702
+ model = sanitize_label(first_string(d, MODEL_KEYS) or root_model or "unknown", 80)
703
+ query_source = sanitize_label(first_string(d, QUERY_SOURCE_KEYS) or root_query_source or "unknown", 80)
704
+ summary.by_model[model].update(local_tokens)
705
+ summary.by_query_source[query_source].update(local_tokens)
706
+
707
+ for key in COST_KEYS:
708
+ val = d.get(key)
709
+ metric = finite_nonnegative_number(val, clamp_negative=False)
710
+ if metric is not None:
711
+ cost = float(metric)
712
+ summary.cost_usd += cost
713
+ record.cost_usd += cost
714
+ summary.cost_field_count += 1
715
+ break
716
+ commands, tools = collect_record_hints(root, show_commands=show_commands)
717
+ record.commands = commands
718
+ record.tools = tools
719
+ record_total = sum(record.tokens.values())
720
+ if file is not None and (record_total or record.cost_usd):
721
+ file_key = path_label(file, show_paths=show_paths)
722
+ summary.by_file[file_key] += record_total
723
+ summary.cost_by_file[file_key] += record.cost_usd
724
+ for command in commands:
725
+ summary.by_command[command] += 1
726
+ for tool in tools:
727
+ summary.by_tool[tool] += 1
728
+ return record
729
+
730
+
731
+ def parse_json_line(line: str) -> Any:
732
+ # Python 3.11's json decoder can hit the interpreter recursion limit on
733
+ # deeply nested transcript payloads before our iterative walker sees them.
734
+ # Raise the process limit enough for realistic hostile fixtures, while still
735
+ # treating too-deep input as a skipped parse record instead of crashing.
736
+ if sys.getrecursionlimit() < JSON_PARSE_RECURSION_LIMIT:
737
+ sys.setrecursionlimit(JSON_PARSE_RECURSION_LIMIT)
738
+ return json.loads(line)
739
+
740
+
741
+ def scan(
742
+ paths: list[str],
743
+ show_paths: bool = False,
744
+ show_commands: bool = False,
745
+ limits: ScanLimits | None = None,
746
+ ) -> UsageSummary:
747
+ limits = limits or ScanLimits()
748
+ summary = UsageSummary()
749
+ for file in iter_jsonl_files(paths):
750
+ summary.files += 1
751
+ try:
752
+ with open_regular_no_symlink(file) as handle:
753
+ size = os.fstat(handle.fileno()).st_size
754
+ if size > limits.max_file_bytes:
755
+ summary.skipped_files += 1
756
+ summary.note_error(
757
+ f"{path_label(file, show_paths=show_paths)}: skipped oversized transcript file "
758
+ f"({size} bytes > {limits.max_file_bytes})"
759
+ )
760
+ continue
761
+ for line_no, line in iter_bounded_lines(handle, limits.max_line_bytes):
762
+ if line is None:
763
+ summary.skipped_records += 1
764
+ summary.note_error(
765
+ f"{path_label(file, show_paths=show_paths)}:{line_no}: "
766
+ f"skipped oversized JSONL record (> {limits.max_line_bytes} bytes)"
767
+ )
768
+ continue
769
+ line = line.strip()
770
+ if not line:
771
+ continue
772
+ try:
773
+ obj = parse_json_line(line)
774
+ except json.JSONDecodeError as exc:
775
+ summary.skipped_records += 1
776
+ summary.note_error(f"{path_label(file, show_paths=show_paths)}:{line_no}: JSON parse error: {exc.msg}")
777
+ continue
778
+ except RecursionError as exc:
779
+ summary.skipped_records += 1
780
+ summary.note_error(f"{path_label(file, show_paths=show_paths)}:{line_no}: JSON parse error: nested JSON exceeds supported depth")
781
+ continue
782
+ summary.records += 1
783
+ add_usage(summary, obj, file, show_paths=show_paths, show_commands=show_commands)
784
+ except OSError as exc:
785
+ summary.skipped_files += 1
786
+ summary.note_error(f"{path_label(file, show_paths=show_paths)}: read error: {os_error_summary(exc)}")
787
+ continue
788
+ return summary
789
+
790
+
791
+ def print_counter(title: str, counter: Counter[str], top: int) -> None:
792
+ print(f"\n{title}")
793
+ for key, val in counter.most_common(top):
794
+ print(f" {key:24s} {val:12d}")
795
+
796
+
797
+ def counter_json(counter: Counter[str], top: int) -> list[dict[str, Any]]:
798
+ return [{"name": key, "value": val} for key, val in counter.most_common(top)]
799
+
800
+
801
+ def utc_now_iso() -> str:
802
+ return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
803
+
804
+
805
+ def availability_status(*, present: bool, skipped: bool = False, partial: bool = False) -> str:
806
+ if present and partial:
807
+ return "partial"
808
+ if present:
809
+ return "available"
810
+ if skipped:
811
+ return "partial"
812
+ return "missing"
813
+
814
+
815
+ # 측정 증거 3-상태 등급. status(available/partial/missing)와 직교하는 보조 축으로,
816
+ # 값이 "어떻게" 알려졌는지를 GUI/소비자에게 노출한다.
817
+ EVIDENCE_OBSERVED = "observed"
818
+ EVIDENCE_INFERRED = "inferred"
819
+ EVIDENCE_UNAVAILABLE = "unavailable"
820
+
821
+
822
+ def evidence_class(*, observed: bool, inferable: bool = False) -> str:
823
+ """관측/추론/불가 3-상태 증거 등급을 반환한다.
824
+
825
+ - observed: transcript 필드에서 직접 읽은 값.
826
+ - inferred: 관측값에서 문서화된 공식으로 파생한 값(추정치).
827
+ - unavailable: scan 데이터만으로는 판별할 수 없는 값.
828
+
829
+ observed가 우선한다. 직접 관측이 없고 inferable한 경우에만 inferred로, 둘 다
830
+ 아니면 unavailable로 분류해 보수적 측정 원칙을 지킨다.
831
+ """
832
+ if observed:
833
+ return EVIDENCE_OBSERVED
834
+ if inferable:
835
+ return EVIDENCE_INFERRED
836
+ return EVIDENCE_UNAVAILABLE
837
+
838
+
839
+ def build_headroom_availability(summary: UsageSummary) -> dict[str, Any]:
840
+ """Context-window headroom 가용성/증거 등급을 보수적으로 분류한다.
841
+
842
+ transcript JSON에는 live `context_window`/잔여 토큰 정보가 없으므로 과거 scan
843
+ 만으로는 headroom을 관측하거나 추론할 수 없다. 따라서 status는 기존 context와
844
+ 동일하게 "missing", evidence는 "unavailable"로 둔다. live statusline snapshot을
845
+ 입력으로 받는 미래 surface에서는 observed로 승급될 수 있음을 contract로 남긴다.
846
+ """
847
+ return {
848
+ "status": "missing",
849
+ "evidence": EVIDENCE_UNAVAILABLE,
850
+ "reason": (
851
+ "Transcript scans do not carry live context-window or remaining-token data, "
852
+ "so context headroom cannot be observed or conservatively inferred from history alone."
853
+ ),
854
+ "observable_via": "live_statusline_snapshot",
855
+ }
856
+
857
+
858
+ def scan_integrity(summary: UsageSummary) -> dict[str, Any]:
859
+ skipped = summary.skipped_files + summary.skipped_records
860
+ complete = skipped == 0 and not summary.parse_errors
861
+ return {
862
+ "status": "complete" if complete else "partial",
863
+ "files_scanned": summary.files,
864
+ "records_scanned": summary.records,
865
+ "skipped_files": summary.skipped_files,
866
+ "skipped_records": summary.skipped_records,
867
+ "parse_error_count": len(summary.parse_errors),
868
+ "complete": complete,
869
+ "reason": (
870
+ "All candidate transcript files/records were parsed within configured limits."
871
+ if complete
872
+ else "Some transcript files or records were skipped; downstream GUI surfaces should label totals as partial."
873
+ ),
874
+ }
875
+
876
+
877
+ def build_metric_availability(summary: UsageSummary) -> dict[str, Any]:
878
+ token_presence = stable_token_presence(summary.token_field_presence)
879
+ has_any_token = bool(token_presence)
880
+ has_cache_read = summary.token_field_presence.get("cache_read", 0) > 0
881
+ has_cache_creation = summary.token_field_presence.get("cache_creation", 0) > 0
882
+ has_cache_any = has_cache_read or has_cache_creation
883
+ cache_partial = has_cache_any and not (has_cache_read and has_cache_creation)
884
+ skipped = bool(summary.skipped_files or summary.skipped_records or summary.parse_errors)
885
+ has_input = summary.token_field_presence.get("input", 0) > 0
886
+ has_output = summary.token_field_presence.get("output", 0) > 0
887
+ return {
888
+ "tokens": {
889
+ "status": availability_status(present=has_any_token, skipped=skipped and not has_any_token, partial=skipped and has_any_token),
890
+ "present_fields": token_presence,
891
+ "evidence": evidence_class(observed=has_any_token),
892
+ },
893
+ "input": {
894
+ "status": availability_status(present=has_input, partial=skipped and has_input),
895
+ "present_count": summary.token_field_presence.get("input", 0),
896
+ "evidence": evidence_class(observed=has_input),
897
+ },
898
+ "output": {
899
+ "status": availability_status(present=has_output, partial=skipped and has_output),
900
+ "present_count": summary.token_field_presence.get("output", 0),
901
+ "evidence": evidence_class(observed=has_output),
902
+ },
903
+ "cache": {
904
+ "status": availability_status(present=has_cache_any, partial=cache_partial or (skipped and has_cache_any)),
905
+ "present_fields": {
906
+ "cache_read": summary.token_field_presence.get("cache_read", 0),
907
+ "cache_creation": summary.token_field_presence.get("cache_creation", 0),
908
+ },
909
+ "zero_values_observed": {
910
+ "cache_read": has_cache_read and summary.tokens.get("cache_read", 0) == 0,
911
+ "cache_creation": has_cache_creation and summary.tokens.get("cache_creation", 0) == 0,
912
+ },
913
+ # 원시 cache 토큰 수는 관측값(observed)이지만, share/reuse 비율은 관측값에서
914
+ # 파생한 추정값(inferred)이므로 별도로 분류해 노출한다.
915
+ "evidence": evidence_class(observed=has_cache_any),
916
+ "derived": {
917
+ "cache_read_share": {
918
+ "evidence": evidence_class(observed=False, inferable=has_cache_any),
919
+ "value": summary.cache_hit_rate if has_cache_any else None,
920
+ },
921
+ "cache_reuse_ratio": {
922
+ "evidence": evidence_class(observed=False, inferable=summary.cache_amortization_defined),
923
+ "value": summary.cache_amortization if summary.cache_amortization_defined else None,
924
+ },
925
+ },
926
+ },
927
+ "cost": {
928
+ "status": availability_status(present=summary.cost_field_count > 0, partial=skipped and summary.cost_field_count > 0),
929
+ "present_count": summary.cost_field_count,
930
+ "observed_cost_usd": summary.cost_usd,
931
+ "evidence": evidence_class(observed=summary.cost_field_count > 0),
932
+ },
933
+ "context": {
934
+ "status": "missing",
935
+ "evidence": EVIDENCE_UNAVAILABLE,
936
+ "reason": (
937
+ "Transcript scans do not include live Claude Code context_window data. "
938
+ "Pass a live statusline snapshot in a future surface to populate context availability."
939
+ ),
940
+ },
941
+ "headroom": build_headroom_availability(summary),
942
+ }
943
+
944
+
945
+ def segment_stability(samples: list[PromptSegmentSample], attr: str, window: int) -> tuple[float, int, int]:
946
+ stabilities: list[float] = []
947
+ unique_total = 0
948
+ observed_positions = 0
949
+ for pos in range(window):
950
+ values: list[str] = []
951
+ for sample in samples:
952
+ hashes = getattr(sample, attr)
953
+ if len(hashes) > pos:
954
+ values.append(hashes[pos])
955
+ if not values:
956
+ continue
957
+ counts = Counter(values)
958
+ observed_positions += 1
959
+ unique_total += len(counts)
960
+ stabilities.append(max(counts.values()) / len(values))
961
+ if not stabilities:
962
+ return 0.0, 0, 0
963
+ return sum(stabilities) / len(stabilities), unique_total, observed_positions
964
+
965
+
966
+ def segment_position_stats(samples: list[PromptSegmentSample], attr: str, window: int) -> list[dict[str, Any]]:
967
+ stats: list[dict[str, Any]] = []
968
+ for pos in range(window):
969
+ values: list[str] = []
970
+ for sample in samples:
971
+ hashes = getattr(sample, attr)
972
+ if len(hashes) > pos:
973
+ values.append(hashes[pos])
974
+ if not values:
975
+ continue
976
+ counts = Counter(values)
977
+ stability = max(counts.values()) / len(values)
978
+ stats.append({
979
+ "position": pos,
980
+ "stability": stability,
981
+ "volatile_share": 1.0 - stability,
982
+ "unique_hashes": len(counts),
983
+ })
984
+ return stats
985
+
986
+
987
+ def prompt_window_overlap_counts(samples: list[PromptSegmentSample]) -> tuple[int, int]:
988
+ """Return (non_overlapping, overlapping) prefix/tail evidence counts.
989
+
990
+ Prefix and tail segment windows are independent evidence only when the
991
+ sampled prompt has enough segments for the configured windows not to share
992
+ positions. Short prompts are still useful, but prefix-vs-tail deltas from
993
+ overlapping windows are lower-confidence diagnostics.
994
+ """
995
+ non_overlapping = 0
996
+ overlapping = 0
997
+ for sample in samples:
998
+ if sample.segment_count >= PROMPT_AUDIT_PREFIX_SEGMENTS + PROMPT_AUDIT_TAIL_SEGMENTS:
999
+ non_overlapping += 1
1000
+ else:
1001
+ overlapping += 1
1002
+ return non_overlapping, overlapping
1003
+
1004
+
1005
+ def build_cache_friendliness(summary: UsageSummary) -> dict[str, Any]:
1006
+ audit = summary.prompt_cache_audit
1007
+ skipped = bool(
1008
+ summary.skipped_files
1009
+ or summary.skipped_records
1010
+ or summary.parse_errors
1011
+ or audit.capped_records
1012
+ or audit.prompt_collection_capped_records
1013
+ )
1014
+ samples = audit.samples
1015
+ if not samples:
1016
+ return {
1017
+ "status": "partial" if skipped else "missing",
1018
+ "confidence": "partial" if skipped else "unavailable",
1019
+ "evidence": EVIDENCE_UNAVAILABLE,
1020
+ "heuristic": True,
1021
+ "sampled_records": audit.sampled_records,
1022
+ "analyzed_prompt_records": 0,
1023
+ "non_overlapping_prompt_records": 0,
1024
+ "overlapping_prompt_records": 0,
1025
+ "prefix_tail_windows_overlap": False,
1026
+ "prompt_collection_capped_records": audit.prompt_collection_capped_records,
1027
+ "skipped_evidence": skipped,
1028
+ "segment_window": {"prefix_segments": PROMPT_AUDIT_PREFIX_SEGMENTS, "tail_segments": PROMPT_AUDIT_TAIL_SEGMENTS},
1029
+ "signals": {
1030
+ "stable_prefix_share": None,
1031
+ "volatile_prefix_share": None,
1032
+ "volatile_tail_share": None,
1033
+ "cache_reuse_ratio": summary.cache_amortization if summary.cache_amortization_defined else None,
1034
+ "cache_read_share": summary.cache_hit_rate,
1035
+ },
1036
+ "findings": [],
1037
+ "caveats": [
1038
+ "No allowlisted user prompt text was found in scanned transcript records; cache layout cannot be inferred.",
1039
+ "Deep or broad prompt content structures are bounded and skipped rather than recursively expanded.",
1040
+ "Provider cache token fields, when present, remain diagnostic telemetry rather than ContextGuard-caused token reduction.",
1041
+ ],
1042
+ }
1043
+
1044
+ prefix_stability, prefix_unique, prefix_positions = segment_stability(samples, "prefix_hashes", PROMPT_AUDIT_PREFIX_SEGMENTS)
1045
+ tail_stability, tail_unique, tail_positions = segment_stability(samples, "tail_hashes", PROMPT_AUDIT_TAIL_SEGMENTS)
1046
+ prefix_position_stats = segment_position_stats(samples, "prefix_hashes", PROMPT_AUDIT_PREFIX_SEGMENTS)
1047
+ non_overlapping_prompt_records, overlapping_prompt_records = prompt_window_overlap_counts(samples)
1048
+ prefix_tail_windows_overlap = overlapping_prompt_records > 0
1049
+ volatile_prefix = 1.0 - prefix_stability
1050
+ volatile_tail = 1.0 - tail_stability
1051
+ most_volatile_prefix = max(prefix_position_stats, key=lambda item: item["volatile_share"], default=None)
1052
+ max_prefix_position_volatile = float(most_volatile_prefix["volatile_share"]) if most_volatile_prefix else 0.0
1053
+ analyzed = audit.analyzed_prompt_records
1054
+ status = "available"
1055
+ if skipped or analyzed < PROMPT_AUDIT_MIN_RECORDS or non_overlapping_prompt_records == 0:
1056
+ status = "partial"
1057
+ confidence = "partial" if status == "partial" or prefix_tail_windows_overlap else "observed"
1058
+ average_prefix_churn = (
1059
+ volatile_prefix >= PROMPT_PREFIX_VOLATILE_THRESHOLD
1060
+ and (volatile_prefix - volatile_tail) >= PROMPT_PREFIX_TAIL_CHURN_DELTA
1061
+ )
1062
+ early_prefix_churn = (
1063
+ max_prefix_position_volatile >= PROMPT_PREFIX_VOLATILE_THRESHOLD
1064
+ and (max_prefix_position_volatile - volatile_tail) >= PROMPT_PREFIX_TAIL_CHURN_DELTA
1065
+ )
1066
+ findings: list[dict[str, Any]] = []
1067
+ if analyzed >= PROMPT_AUDIT_MIN_RECORDS and (average_prefix_churn or early_prefix_churn):
1068
+ findings.append({
1069
+ "id": "volatile-content-near-prefix",
1070
+ "severity": "P1",
1071
+ "confidence": confidence,
1072
+ "title": "Volatile content appears near prompt prefix",
1073
+ "reason": (
1074
+ "Observed user prompt segment hashes churn much more near the prefix than in the tail window; "
1075
+ "provider cache telemetry is used only as corroborating diagnostic context."
1076
+ ),
1077
+ "action": "Move generated logs, diffs, file evidence, and run-specific context after stable instructions and reusable policy text.",
1078
+ "heuristic": True,
1079
+ "evidence": {
1080
+ "records": analyzed,
1081
+ "non_overlapping_prompt_records": non_overlapping_prompt_records,
1082
+ "overlapping_prompt_records": overlapping_prompt_records,
1083
+ "prefix_tail_windows_overlap": prefix_tail_windows_overlap,
1084
+ "confidence": confidence,
1085
+ "prefix_positions": prefix_positions,
1086
+ "tail_positions": tail_positions,
1087
+ "prefix_unique_hashes": prefix_unique,
1088
+ "tail_unique_hashes": tail_unique,
1089
+ "volatile_prefix_share": round(volatile_prefix, 4),
1090
+ "volatile_tail_share": round(volatile_tail, 4),
1091
+ "max_prefix_position_volatile_share": round(max_prefix_position_volatile, 4),
1092
+ "max_prefix_position": most_volatile_prefix["position"] if most_volatile_prefix else None,
1093
+ "trigger": "prefix_window_average" if average_prefix_churn else "early_prefix_position",
1094
+ "cache_creation": summary.tokens.get("cache_creation", 0),
1095
+ "cache_read": summary.tokens.get("cache_read", 0),
1096
+ },
1097
+ })
1098
+ findings = findings[:PROMPT_AUDIT_MAX_FINDINGS]
1099
+ return {
1100
+ "status": status,
1101
+ "confidence": confidence,
1102
+ "evidence": EVIDENCE_OBSERVED,
1103
+ "heuristic": True,
1104
+ "sampled_records": audit.sampled_records,
1105
+ "analyzed_prompt_records": analyzed,
1106
+ "non_overlapping_prompt_records": non_overlapping_prompt_records,
1107
+ "overlapping_prompt_records": overlapping_prompt_records,
1108
+ "prefix_tail_windows_overlap": prefix_tail_windows_overlap,
1109
+ "capped_records": audit.capped_records,
1110
+ "prompt_collection_capped_records": audit.prompt_collection_capped_records,
1111
+ "skipped_evidence": skipped,
1112
+ "total_segments": audit.total_segments,
1113
+ "total_bytes_sampled": audit.total_bytes_sampled,
1114
+ "redacted_segments": audit.redacted_segments,
1115
+ "segment_window": {"prefix_segments": PROMPT_AUDIT_PREFIX_SEGMENTS, "tail_segments": PROMPT_AUDIT_TAIL_SEGMENTS},
1116
+ "thresholds": {
1117
+ "min_records": PROMPT_AUDIT_MIN_RECORDS,
1118
+ "prefix_volatile_threshold": PROMPT_PREFIX_VOLATILE_THRESHOLD,
1119
+ "prefix_tail_churn_delta": PROMPT_PREFIX_TAIL_CHURN_DELTA,
1120
+ },
1121
+ "signals": {
1122
+ "stable_prefix_share": round(prefix_stability, 4),
1123
+ "volatile_prefix_share": round(volatile_prefix, 4),
1124
+ "volatile_tail_share": round(volatile_tail, 4),
1125
+ "max_prefix_position_volatile_share": round(max_prefix_position_volatile, 4),
1126
+ "cache_reuse_ratio": summary.cache_amortization if summary.cache_amortization_defined else None,
1127
+ "cache_read_share": summary.cache_hit_rate,
1128
+ },
1129
+ "findings": findings,
1130
+ "caveats": [
1131
+ "Prompt layout findings are heuristic and based on bounded redacted user-message segment hashes, not raw prompt text or exact provider cache-prefix state.",
1132
+ "When prefix and tail segment windows overlap in short prompts, cache-friendliness findings are partial-confidence diagnostics.",
1133
+ "Deep or broad prompt content structures are bounded and make cache-friendliness evidence partial.",
1134
+ "Provider cache read/write fields are diagnostic telemetry and do not prove ContextGuard-caused token reduction.",
1135
+ "Unknown transcript prompt schemas are skipped rather than inferred aggressively.",
1136
+ ],
1137
+ }
1138
+
1139
+
1140
+ def cache_friendliness_for_summary(summary: UsageSummary) -> dict[str, Any]:
1141
+ if summary.cache_friendliness_cache is None:
1142
+ summary.cache_friendliness_cache = build_cache_friendliness(summary)
1143
+ return summary.cache_friendliness_cache
1144
+
1145
+
1146
+ def build_metric_caveats(summary: UsageSummary) -> list[str]:
1147
+ caveats = [
1148
+ "Values are observed from local Claude Code transcript JSON/JSONL fields and are not official billing records.",
1149
+ "Claude Code transcript schemas may change; skipped files/records and parse errors reduce confidence.",
1150
+ "cache-read share is cache_read / (input + cache_read + cache_creation), not a provider billing hit-rate.",
1151
+ "reuse ratio is cache_read / cache_creation when cache_creation is non-zero; it is undefined for cache-cold sessions.",
1152
+ "each metric carries an evidence class: observed (read from transcript fields), inferred "
1153
+ "(derived via a documented formula), or unavailable (not determinable from a historical scan).",
1154
+ "context headroom is unavailable from transcript scans; it requires a live statusline snapshot to be observed.",
1155
+ ]
1156
+ if summary.cost_field_count == 0:
1157
+ caveats.append("No cost fields were observed; use Claude Console or official billing exports for invoice-grade cost.")
1158
+ if not (summary.token_field_presence.get("cache_read") or summary.token_field_presence.get("cache_creation")):
1159
+ caveats.append("No cache fields were observed; hide cache UI or label cache availability as missing.")
1160
+ if summary.skipped_files or summary.skipped_records:
1161
+ caveats.append("Some transcript files or records were skipped, so hotspot rankings may be incomplete.")
1162
+ return caveats
1163
+
1164
+
1165
+ def feasibility_json(
1166
+ summary: UsageSummary,
1167
+ top: int = 15,
1168
+ include_recommendations: bool = False,
1169
+ limits: ScanLimits | None = None,
1170
+ *,
1171
+ generated_at: str | None = None,
1172
+ ) -> dict[str, Any]:
1173
+ generated_at = generated_at or utc_now_iso()
1174
+ base = summary_json(summary, top, include_recommendations=include_recommendations, limits=limits)
1175
+ availability = build_metric_availability(summary)
1176
+ integrity = scan_integrity(summary)
1177
+ stable_tokens = stable_token_counter(summary.tokens)
1178
+ stable_total_tokens = sum(stable_tokens.values())
1179
+ cache_friendliness = cache_friendliness_for_summary(summary)
1180
+ return {
1181
+ "schema_version": FEASIBILITY_SCHEMA_VERSION,
1182
+ "producer": FEASIBILITY_PRODUCER,
1183
+ "generated_at": generated_at,
1184
+ "consumer_contract": {
1185
+ "stable_top_level_fields": [
1186
+ "schema_version",
1187
+ "producer",
1188
+ "generated_at",
1189
+ "source_kind",
1190
+ "source_freshness",
1191
+ "scan_integrity",
1192
+ "metric_availability",
1193
+ "metric_caveats",
1194
+ "redaction_mode",
1195
+ "context_availability",
1196
+ "headroom_availability",
1197
+ "cache_friendliness",
1198
+ "totals",
1199
+ ],
1200
+ "diagnostic_fields": ["summary"],
1201
+ "summary_contract": (
1202
+ "summary is the legacy audit JSON payload for diagnostics and backward compatibility; "
1203
+ "new GUI prototypes should bind to stable top-level feasibility fields first."
1204
+ ),
1205
+ },
1206
+ "source_kind": "historical_transcript_scan",
1207
+ "source_freshness": {
1208
+ "status": "snapshot_at_scan_time",
1209
+ "live": False,
1210
+ "generated_at": generated_at,
1211
+ "description": "Local transcript files were scanned when this report was generated; this is not a live statusline snapshot.",
1212
+ },
1213
+ "scan_integrity": integrity,
1214
+ "metric_availability": availability,
1215
+ "metric_caveats": build_metric_caveats(summary),
1216
+ "redaction_mode": {
1217
+ "paths": "basename_plus_stable_hash_by_default",
1218
+ "commands": "command_category_plus_stable_hash_by_default",
1219
+ "secret_like_values": "pattern_redacted",
1220
+ "raw_path_and_command_flags": ["--show-paths", "--show-commands"],
1221
+ },
1222
+ "context_availability": availability["context"],
1223
+ "headroom_availability": availability["headroom"],
1224
+ "cache_friendliness": cache_friendliness,
1225
+ "totals": {
1226
+ "total_tokens": stable_total_tokens,
1227
+ "tokens": stable_tokens,
1228
+ "cost_usd_observed": summary.cost_usd,
1229
+ "cache_read_share": summary.cache_hit_rate,
1230
+ "cache_reuse_ratio": summary.cache_amortization if summary.cache_amortization_defined else None,
1231
+ },
1232
+ "summary": base,
1233
+ }
1234
+
1235
+
1236
+ def recommendation(
1237
+ ident: str,
1238
+ title: str,
1239
+ reason: str,
1240
+ action: str,
1241
+ priority: str,
1242
+ evidence: dict[str, Any],
1243
+ ) -> dict[str, Any]:
1244
+ return {
1245
+ "id": ident,
1246
+ "priority": priority,
1247
+ "title": title,
1248
+ "reason": reason,
1249
+ "action": action,
1250
+ "evidence": evidence,
1251
+ }
1252
+
1253
+
1254
+ def build_recommendations(summary: UsageSummary, top: int) -> list[dict[str, Any]]:
1255
+ recs: list[dict[str, Any]] = []
1256
+ total = max(0, summary.total_tokens)
1257
+ if total == 0:
1258
+ recs.append(recommendation(
1259
+ "no-usage-found",
1260
+ "No token usage found in scanned transcripts",
1261
+ "The scanner did not find recognizable Claude Code usage fields.",
1262
+ "Verify the transcript path or run again against ~/.claude/projects after more Claude Code activity.",
1263
+ "P2",
1264
+ {"files_scanned": summary.files, "records": summary.records},
1265
+ ))
1266
+ return recs
1267
+
1268
+ output_tokens = summary.tokens.get("output", 0)
1269
+ input_tokens = summary.tokens.get("input", 0)
1270
+ cache_creation = summary.tokens.get("cache_creation", 0)
1271
+ cache_read = summary.tokens.get("cache_read", 0)
1272
+ output_ratio = output_tokens / total
1273
+ input_ratio = input_tokens / total
1274
+ cache_friendliness = cache_friendliness_for_summary(summary)
1275
+ for finding in cache_friendliness.get("findings", []):
1276
+ if isinstance(finding, dict) and finding.get("id") == "volatile-content-near-prefix":
1277
+ evidence = dict(finding.get("evidence") or {})
1278
+ evidence["heuristic"] = True
1279
+ if finding.get("confidence"):
1280
+ evidence["confidence"] = finding.get("confidence")
1281
+ rec = recommendation(
1282
+ "move-volatile-context-after-stable-prefix",
1283
+ "Volatile context appears before stable prompt prefix",
1284
+ str(finding.get("reason") or "Observed prompt prefix churn is higher than tail churn."),
1285
+ str(finding.get("action") or "Move run-specific context after stable instructions."),
1286
+ str(finding.get("severity") or "P1"),
1287
+ evidence,
1288
+ )
1289
+ rec["heuristic"] = True
1290
+ if finding.get("confidence"):
1291
+ rec["confidence"] = finding.get("confidence")
1292
+ recs.append(rec)
1293
+ break
1294
+ if output_tokens >= 5_000 or output_ratio >= 0.35:
1295
+ recs.append(recommendation(
1296
+ "trim-output-heavy-sessions",
1297
+ "Output tokens are a major hotspot",
1298
+ f"Output accounts for {output_ratio:.0%} of observed tokens.",
1299
+ "Enable/keep Bash output trimming and add runner-aware failure extraction for repeated test/build commands.",
1300
+ "P0",
1301
+ {"output_tokens": output_tokens, "total_tokens": total},
1302
+ ))
1303
+ if input_tokens >= 5_000 or input_ratio >= 0.45:
1304
+ recs.append(recommendation(
1305
+ "reduce-large-reads",
1306
+ "Input tokens are a major hotspot",
1307
+ f"Input accounts for {input_ratio:.0%} of observed tokens.",
1308
+ "Prefer diff-first review, symbol-scoped reads, and large-file read guards before sending whole files to Claude.",
1309
+ "P0",
1310
+ {"input_tokens": input_tokens, "total_tokens": total},
1311
+ ))
1312
+ if (
1313
+ cache_creation >= 10_000
1314
+ and cache_read >= 1
1315
+ and summary.cache_amortization < 0.5
1316
+ ):
1317
+ recs.append(recommendation(
1318
+ "improve-prompt-cache-reuse",
1319
+ "Prompt cache reuse looks low",
1320
+ (
1321
+ f"Cache amortization is {summary.cache_amortization:.2f}x "
1322
+ f"(cache_read={cache_read}, cache_creation={cache_creation}); each cached prefix is barely re-served."
1323
+ ),
1324
+ "Keep stable instructions early, move volatile context later, and avoid editing large instruction files during active sessions.",
1325
+ "P1",
1326
+ {
1327
+ "cache_creation": cache_creation,
1328
+ "cache_read": cache_read,
1329
+ "cache_amortization": round(summary.cache_amortization, 4),
1330
+ "cache_hit_rate": round(summary.cache_hit_rate, 4),
1331
+ },
1332
+ ))
1333
+ if cache_creation >= 50_000 and 1.0 <= summary.cache_amortization < 5.0:
1334
+ recs.append(recommendation(
1335
+ "evaluate-1h-ttl-cache",
1336
+ "Cache writes are large; evaluate the 1h TTL cache beta",
1337
+ (
1338
+ f"Heuristic only — cache amortization {summary.cache_amortization:.2f}x with "
1339
+ f"{cache_creation} write tokens; absolute write cost is high and reuse is moderate. "
1340
+ "This metric does not inspect timestamps, so confirm reuse spans >5min in a sample "
1341
+ "session before enabling 1h TTL."
1342
+ ),
1343
+ (
1344
+ "If sessions reuse the same prefix beyond the 5-minute default TTL, evaluate the 1h prompt cache "
1345
+ "beta (write 2x, read 0.1x). It pays off when reuse spans the gap between two 5-min cache writes."
1346
+ ),
1347
+ "P2",
1348
+ {
1349
+ "cache_creation": cache_creation,
1350
+ "cache_read": cache_read,
1351
+ "cache_amortization": round(summary.cache_amortization, 4),
1352
+ "cache_hit_rate": round(summary.cache_hit_rate, 4),
1353
+ "heuristic": True,
1354
+ },
1355
+ ))
1356
+ if cache_read >= 10_000 and summary.cache_hit_rate >= 0.5:
1357
+ rec = recommendation(
1358
+ "separate-cache-discounts-from-token-reduction",
1359
+ "Provider cache reuse is visible, but it is not token reduction",
1360
+ (
1361
+ f"Cache read share is {summary.cache_hit_rate:.0%}; this can reduce provider input cost/latency, "
1362
+ "but the prompt content may still be sent logically and should not be counted as ContextGuard token reduction."
1363
+ ),
1364
+ (
1365
+ "Report cache_read/cache_creation separately from bytes avoided by local guards, and keep stable cached "
1366
+ "instructions before volatile evidence to preserve provider-cache eligibility."
1367
+ ),
1368
+ "P2",
1369
+ {
1370
+ "cache_read": cache_read,
1371
+ "cache_creation": cache_creation,
1372
+ "cache_hit_rate": round(summary.cache_hit_rate, 4),
1373
+ "cache_amortization": round(summary.cache_amortization, 4) if summary.cache_amortization_defined else None,
1374
+ "provider_cache_telemetry_only": True,
1375
+ },
1376
+ )
1377
+ rec["heuristic"] = True
1378
+ recs.append(rec)
1379
+
1380
+ for command, record_count in summary.by_command.most_common(top):
1381
+ lowered = command.lower()
1382
+ if any(marker in lowered for marker in ("pytest", "jest", "vitest", "go test", "cargo test", "npm test", "pnpm test", "yarn test")):
1383
+ recs.append(recommendation(
1384
+ "runner-aware-test-summary",
1385
+ "Test command appears in transcript records",
1386
+ "A test command category was observed in transcript records; token totals are session-level, not precise per-command billing.",
1387
+ "Route this command through runner-aware failure extraction so Claude sees failing test names, file:line, assertion text, and rerun commands only.",
1388
+ "P0",
1389
+ {"command_hint": command, "record_count": record_count},
1390
+ ))
1391
+ break
1392
+
1393
+ top_files = summary.by_file.most_common(3)
1394
+ if top_files:
1395
+ largest_file, largest_tokens = top_files[0]
1396
+ if largest_tokens >= max(1_000, total * 0.25):
1397
+ recs.append(recommendation(
1398
+ "inspect-costliest-transcript",
1399
+ "One transcript file dominates observed usage",
1400
+ "A single transcript file accounts for a large share of observed tokens.",
1401
+ "Inspect this session first, then use /clear between unrelated tasks or /compact during long-running work.",
1402
+ "P1",
1403
+ {"file": largest_file, "tokens": largest_tokens, "share": round(largest_tokens / total, 3)},
1404
+ ))
1405
+
1406
+ if summary.by_model:
1407
+ model_totals = Counter({model: sum(tokens.values()) for model, tokens in summary.by_model.items()})
1408
+ model, model_tokens = model_totals.most_common(1)[0]
1409
+ if model != "unknown" and model_tokens >= max(2_000, total * 0.5):
1410
+ recs.append(recommendation(
1411
+ "route-heavy-work-by-model",
1412
+ "One model carries most observed token usage",
1413
+ "A single model dominates the observed transcript tokens.",
1414
+ "Use lower-cost/auxiliary models for broad search, logs, and first-pass summaries; reserve Claude for final reasoning and edits.",
1415
+ "P1",
1416
+ {"model": model, "tokens": model_tokens, "share": round(model_tokens / total, 3)},
1417
+ ))
1418
+
1419
+ if summary.skipped_files or summary.skipped_records:
1420
+ recs.append(recommendation(
1421
+ "fix-transcript-scan-gaps",
1422
+ "Some transcript data was skipped",
1423
+ "Skipped records can hide token hotspots and make recommendations less reliable.",
1424
+ "Review parse warnings and rerun with a narrower path if malformed or unrelated JSON files are mixed in.",
1425
+ "P2",
1426
+ {"skipped_files": summary.skipped_files, "skipped_records": summary.skipped_records},
1427
+ ))
1428
+ return recs
1429
+
1430
+
1431
+ def summary_json(
1432
+ summary: UsageSummary,
1433
+ top: int = 15,
1434
+ include_recommendations: bool = False,
1435
+ limits: ScanLimits | None = None,
1436
+ ) -> dict[str, Any]:
1437
+ limits = limits or ScanLimits()
1438
+ data = {
1439
+ "files": summary.files,
1440
+ "records": summary.records,
1441
+ "skipped_files": summary.skipped_files,
1442
+ "skipped_records": summary.skipped_records,
1443
+ "parse_errors": summary.parse_errors,
1444
+ "scan_limits": {
1445
+ "max_file_bytes": limits.max_file_bytes,
1446
+ "max_line_bytes": limits.max_line_bytes,
1447
+ },
1448
+ "total_tokens": summary.total_tokens,
1449
+ "tokens": dict(summary.tokens),
1450
+ "cache_metrics": {
1451
+ "cache_hit_rate": round(summary.cache_hit_rate, 4),
1452
+ "cache_amortization": round(summary.cache_amortization, 4),
1453
+ "cache_amortization_defined": summary.cache_amortization_defined,
1454
+ "cache_read_tokens": summary.tokens.get("cache_read", 0),
1455
+ "cache_creation_tokens": summary.tokens.get("cache_creation", 0),
1456
+ "input_tokens": summary.tokens.get("input", 0),
1457
+ },
1458
+ "cost_usd_observed": summary.cost_usd,
1459
+ "by_model": {k: dict(v) for k, v in summary.by_model.items()},
1460
+ "by_query_source": {k: dict(v) for k, v in summary.by_query_source.items()},
1461
+ "top_files": counter_json(summary.by_file, top),
1462
+ "top_commands": counter_json(summary.by_command, top),
1463
+ "top_tools": counter_json(summary.by_tool, top),
1464
+ "cache_friendliness": cache_friendliness_for_summary(summary),
1465
+ }
1466
+ if include_recommendations:
1467
+ data["recommendations"] = build_recommendations(summary, top)
1468
+ return data
1469
+
1470
+
1471
+ def print_recommendations(summary: UsageSummary, top: int) -> None:
1472
+ print("\nRecommendations")
1473
+ for idx, rec in enumerate(build_recommendations(summary, top), 1):
1474
+ print(f"{idx}. [{rec['priority']}] {rec['title']}")
1475
+ print(f" reason: {rec['reason']}")
1476
+ print(f" action: {rec['action']}")
1477
+ if rec.get("evidence"):
1478
+ print(f" evidence: {json.dumps(rec['evidence'], ensure_ascii=False, sort_keys=True)}")
1479
+
1480
+
1481
+ def main() -> int:
1482
+ parser = argparse.ArgumentParser()
1483
+ parser.add_argument("paths", nargs="*", default=[os.path.expanduser("~/.claude/projects")])
1484
+ parser.add_argument("--top", type=int, default=15)
1485
+ parser.add_argument("--json", action="store_true")
1486
+ parser.add_argument(
1487
+ "--feasibility-json",
1488
+ action="store_true",
1489
+ help="emit a GUI-consumable local metric availability report with schema, freshness, caveats, and redaction metadata",
1490
+ )
1491
+ parser.add_argument("--recommend", action="store_true", help="Print concrete token-saving recommendations")
1492
+ parser.add_argument(
1493
+ "--show-paths",
1494
+ action="store_true",
1495
+ help="Show transcript paths instead of basename+hash labels; local debugging only; secret-shaped path components remain redacted",
1496
+ )
1497
+ parser.add_argument("--show-commands", action="store_true", help="Show redacted command strings instead of command category+hash labels")
1498
+ parser.add_argument(
1499
+ "--max-file-bytes",
1500
+ type=int,
1501
+ default=DEFAULT_MAX_FILE_BYTES,
1502
+ help="skip transcript files larger than this many bytes (default: 50 MiB)",
1503
+ )
1504
+ parser.add_argument(
1505
+ "--max-line-bytes",
1506
+ type=int,
1507
+ default=DEFAULT_MAX_LINE_BYTES,
1508
+ help="skip individual JSONL records larger than this many bytes (default: 2 MiB)",
1509
+ )
1510
+ args = parser.parse_args()
1511
+ limits = ScanLimits(
1512
+ max_file_bytes=require_scan_limit(parser, "--max-file-bytes", args.max_file_bytes, MAX_FILE_BYTES_LIMIT),
1513
+ max_line_bytes=require_scan_limit(parser, "--max-line-bytes", args.max_line_bytes, MAX_LINE_BYTES_LIMIT),
1514
+ )
1515
+
1516
+ summary = scan(args.paths, show_paths=args.show_paths, show_commands=args.show_commands, limits=limits)
1517
+
1518
+ if args.feasibility_json:
1519
+ print(json.dumps(
1520
+ feasibility_json(summary, args.top, include_recommendations=args.recommend, limits=limits),
1521
+ indent=2,
1522
+ sort_keys=True,
1523
+ ))
1524
+ return 0
1525
+
1526
+ if args.json:
1527
+ print(json.dumps(
1528
+ summary_json(summary, args.top, include_recommendations=args.recommend, limits=limits),
1529
+ indent=2,
1530
+ sort_keys=True,
1531
+ ))
1532
+ return 0
1533
+
1534
+ print("Claude Code transcript usage audit")
1535
+ print(
1536
+ f"files_scanned={summary.files} records={summary.records} "
1537
+ f"skipped_files={summary.skipped_files} skipped_records={summary.skipped_records}"
1538
+ )
1539
+ print(f"scan_limits=max_file_bytes:{limits.max_file_bytes} max_line_bytes:{limits.max_line_bytes}")
1540
+ print(f"observed_total_tokens={summary.total_tokens}")
1541
+ if summary.cost_usd:
1542
+ print(f"observed_cost_usd={summary.cost_usd:.4f}")
1543
+ if summary.parse_errors:
1544
+ print("\nWarnings")
1545
+ for warning in summary.parse_errors:
1546
+ print(f" - {warning}")
1547
+ print_counter("Token buckets", summary.tokens, args.top)
1548
+
1549
+ print("\nCache reuse")
1550
+ print(f" cache_hit_rate {summary.cache_hit_rate:.2%}")
1551
+ if summary.cache_amortization_defined:
1552
+ print(f" cache_amortization {summary.cache_amortization:.2f}x")
1553
+ else:
1554
+ print(" cache_amortization n/a (no cache writes observed)")
1555
+ print(f" cache_read_tokens {summary.tokens.get('cache_read', 0):12d}")
1556
+ print(f" cache_creation_tokens {summary.tokens.get('cache_creation', 0):12d}")
1557
+ cache_friendliness = cache_friendliness_for_summary(summary)
1558
+ if cache_friendliness.get("status") != "missing":
1559
+ signals = cache_friendliness.get("signals", {})
1560
+ print("\nCache friendliness")
1561
+ print(f" status {cache_friendliness.get('status')}")
1562
+ print(f" heuristic {str(cache_friendliness.get('heuristic')).lower()}")
1563
+ print(f" analyzed_prompt_records {cache_friendliness.get('analyzed_prompt_records', 0):12d}")
1564
+ stable_prefix = signals.get("stable_prefix_share")
1565
+ volatile_prefix = signals.get("volatile_prefix_share")
1566
+ volatile_tail = signals.get("volatile_tail_share")
1567
+ if stable_prefix is not None:
1568
+ print(f" stable_prefix_share {stable_prefix:.2%}")
1569
+ if volatile_prefix is not None:
1570
+ print(f" volatile_prefix_share {volatile_prefix:.2%}")
1571
+ if volatile_tail is not None:
1572
+ print(f" volatile_tail_share {volatile_tail:.2%}")
1573
+ for finding in cache_friendliness.get("findings", []):
1574
+ if isinstance(finding, dict):
1575
+ print(f" finding [{finding.get('severity')}] {finding.get('id')}: {finding.get('title')}")
1576
+
1577
+ model_totals = Counter({model: sum(tokens.values()) for model, tokens in summary.by_model.items()})
1578
+ print_counter("By model", model_totals, args.top)
1579
+
1580
+ source_totals = Counter({src: sum(tokens.values()) for src, tokens in summary.by_query_source.items()})
1581
+ print_counter("By query_source", source_totals, args.top)
1582
+ print_counter("Top transcript files", summary.by_file, args.top)
1583
+ print_counter("Top command hints observed", summary.by_command, args.top)
1584
+ print_counter("Top tools observed", summary.by_tool, args.top)
1585
+ if args.recommend:
1586
+ print_recommendations(summary, args.top)
1587
+ return 0
1588
+
1589
+
1590
+ if __name__ == "__main__":
1591
+ raise SystemExit(main())