@ictechgy/context-guard 0.4.15 → 0.5.1

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 (32) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.ko.md +128 -2
  3. package/README.md +144 -3
  4. package/docs/distribution.md +100 -0
  5. package/package.json +4 -1
  6. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  7. package/plugins/context-guard/README.ko.md +43 -1
  8. package/plugins/context-guard/README.md +44 -1
  9. package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
  10. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  11. package/plugins/context-guard/bin/context-guard-audit +169 -66
  12. package/plugins/context-guard/bin/context-guard-bench +9865 -211
  13. package/plugins/context-guard/bin/context-guard-compress +90 -8
  14. package/plugins/context-guard/bin/context-guard-diet +1 -7
  15. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  16. package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
  17. package/plugins/context-guard/bin/context-guard-guard-read +496 -57
  18. package/plugins/context-guard/bin/context-guard-mcp +2 -1
  19. package/plugins/context-guard/bin/context-guard-pack +1570 -150
  20. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  21. package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
  22. package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
  23. package/plugins/context-guard/bin/context-guard-setup +1944 -222
  24. package/plugins/context-guard/bin/context-guard-statusline +163 -55
  25. package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
  26. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  27. package/plugins/context-guard/bin/context-guard-trim-output +795 -48
  28. package/plugins/context-guard/brief/README.md +19 -0
  29. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  30. package/plugins/context-guard/lib/context_guard_commands.py +10 -2
  31. package/plugins/context-guard/lib/credential_policy.py +185 -0
  32. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -70,3 +70,22 @@ Each block is wrapped in stable markers:
70
70
  To remove brief mode, delete the block between (and including) those two marker lines. Only
71
71
  one brief-mode block should be present at a time; installing a different level replaces the
72
72
  existing block rather than stacking a second one.
73
+
74
+ ## Quiet narration is separate
75
+
76
+ [`narration-mode.quiet.md`](narration-mode.quiet.md) is a default-off, Claude-only rule for
77
+ reducing discretionary progress narration. It is not a brief-mode level and does not change
78
+ final-answer requirements or reasoning depth. Manage it only through the isolated,
79
+ project-scoped rules operation:
80
+
81
+ ```bash
82
+ context-guard setup --rules-only --agent claude --scope project --narration-mode quiet --plan
83
+ context-guard setup --rules-only --agent claude --scope project --narration-mode quiet --yes
84
+ context-guard setup --rules-only --agent claude --scope project --narration-mode default --yes
85
+ ```
86
+
87
+ This operation manages only the ContextGuard narration span in project `CLAUDE.md`; it does
88
+ not read or change Claude settings, hooks, permissions, statusline, model defaults, or other
89
+ agents' rule files. The rule preserves approvals and decisions, blockers, failures,
90
+ destructive or security warnings, required progress, final results, changed files, and
91
+ verification. It is best-effort guidance and does not guarantee token or cost savings.
@@ -0,0 +1,21 @@
1
+ <!-- BEGIN context-guard:narration-mode mode=quiet version=1 -->
2
+ ## ContextGuard quiet narration (advisory)
3
+
4
+ Best effort: reduce only discretionary intermediate narration. Skip routine preambles,
5
+ per-tool narration, filler, and repeated interim summaries when they add no useful
6
+ information.
7
+
8
+ Always preserve required user-facing communication:
9
+
10
+ - user approvals and decisions;
11
+ - blockers and failures;
12
+ - destructive-risk and security warnings;
13
+ - progress required by higher-priority instructions;
14
+ - the final result;
15
+ - changed files; and
16
+ - verification evidence.
17
+
18
+ This mode does not require a shorter final answer and does not change reasoning effort.
19
+ It asks Claude to reduce discretionary narration; it does not guarantee token or cost savings,
20
+ and no numeric savings should be claimed without matched provider evidence.
21
+ <!-- END context-guard:narration-mode -->
@@ -29,9 +29,12 @@ IMPLEMENTATION_PAIRS = (('context_guard_cli.py', 'context-guard'),
29
29
  ('trim_command_output.py', 'context-guard-trim-output'))
30
30
 
31
31
  HELPER_PAIRS = (('hook_secret_patterns.py', 'lib/hook_secret_patterns.py'),
32
+ ('bash_reference_policy.py', 'bin/bash_reference_policy.py'),
33
+ ('credential_policy.py', 'lib/credential_policy.py'),
32
34
  ('context_guard_commands.py', 'lib/context_guard_commands.py'),
33
35
  ('context_guard_command_manifest_loader.py',
34
- 'lib/context_guard_command_manifest_loader.py'))
36
+ 'lib/context_guard_command_manifest_loader.py'),
37
+ ('transcript_usage_reducer.py', 'lib/transcript_usage_reducer.py'))
35
38
 
36
39
  NPM_BINS = ('context-guard',
37
40
  'context-guard-cost',
@@ -87,6 +90,7 @@ DISPATCHER_SUBCOMMANDS = {'setup': ('context-guard-setup',),
87
90
  'scan': ('context-guard-diet', 'scan'),
88
91
  'trim-output': ('context-guard-trim-output',),
89
92
  'trim': ('context-guard-trim-output',),
93
+ 'reference': ('context-guard-trim-output', '--expand-bash-reference'),
90
94
  'sanitize-output': ('context-guard-sanitize-output',),
91
95
  'sanitize': ('context-guard-sanitize-output',),
92
96
  'filter': ('context-guard-filter',),
@@ -194,6 +198,7 @@ PLUGIN_ENTRYPOINTS = ('claude-read-symbol',
194
198
  'context-guard-trim-output')
195
199
 
196
200
  DISPATCHER_SMOKE_CASES = ({'entrypoint': 'context-guard', 'args': ['experiments', 'list', '--json'], 'mode': 'json'},
201
+ {'entrypoint': 'context-guard', 'args': ['reference', '--help'], 'mode': 'text'},
197
202
  {'entrypoint': 'context-guard', 'args': ['cost', '--help'], 'mode': 'text'},
198
203
  {'entrypoint': 'context-guard', 'args': ['route-advisor', '--help'], 'mode': 'text'},
199
204
  {'entrypoint': 'context-guard', 'args': ['cache-score', '--help'], 'mode': 'text'},
@@ -214,6 +219,7 @@ EXPECTED_COMMAND_PACK_FILES = ('plugins/context-guard/bin/claude-read-symbol',
214
219
  'plugins/context-guard/bin/claude-token-statusline',
215
220
  'plugins/context-guard/bin/claude-token-statusline-merged',
216
221
  'plugins/context-guard/bin/claude-trim-output',
222
+ 'plugins/context-guard/bin/bash_reference_policy.py',
217
223
  'plugins/context-guard/bin/context-guard',
218
224
  'plugins/context-guard/bin/context-guard-artifact',
219
225
  'plugins/context-guard/bin/context-guard-audit',
@@ -236,6 +242,8 @@ EXPECTED_COMMAND_PACK_FILES = ('plugins/context-guard/bin/claude-read-symbol',
236
242
  'plugins/context-guard/bin/context-guard-statusline-merged',
237
243
  'plugins/context-guard/bin/context-guard-tool-prune',
238
244
  'plugins/context-guard/bin/context-guard-trim-output',
245
+ 'plugins/context-guard/lib/credential_policy.py',
239
246
  'plugins/context-guard/lib/context_guard_command_manifest_loader.py',
240
247
  'plugins/context-guard/lib/context_guard_commands.py',
241
- 'plugins/context-guard/lib/hook_secret_patterns.py')
248
+ 'plugins/context-guard/lib/hook_secret_patterns.py',
249
+ 'plugins/context-guard/lib/transcript_usage_reducer.py')
@@ -0,0 +1,185 @@
1
+ """Pure shared credential classification and high-confidence redaction policy."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+
7
+ SECRET_KEY = (
8
+ r"[A-Za-z0-9_.-]*(?:api[_-]?key|apikey|token|secret|password|passwd|pwd|"
9
+ r"private[_-]?key|access[_-]?key|client[_-]?secret|credential|signature|sig|"
10
+ r"ssh[_-]?key|ssh[_-]?command|pgp[_-]?private[_-]?key)[A-Za-z0-9_.-]*"
11
+ r"|(?:pass|[A-Za-z0-9_.-]+[._-]pass)"
12
+ r"(?:[._-](?:v\d+|prod|production|dev|test|backup))?|smtp[_-]?pass"
13
+ r"|(?:session(?:[_-]?(?:id|token))?|sessionid|sid|jsessionid|"
14
+ r"csrf(?:[_-]?token)?|xsrf(?:[_-]?token)?)"
15
+ r"|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|"
16
+ r"GOOGLE_APPLICATION_CREDENTIALS|AZURE_CLIENT_SECRET"
17
+ )
18
+ URL_LIKE_RE = re.compile(r"\b[A-Za-z][A-Za-z0-9+.-]*://[^\s]+")
19
+ URL_SECRET_PARAM_RE = re.compile(rf"(?i)([?&#;](?:{SECRET_KEY})=)[^\s?&#;]+")
20
+ SCHEMELESS_SECRET_PARAM_RE = re.compile(rf"(?i)([?&#](?:{SECRET_KEY})=)[^\s?&#;]+")
21
+ CAMEL_ACRONYM_BOUNDARY_RE = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
22
+ CAMEL_WORD_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
23
+ EXACT_SENSITIVE_KEYS = frozenset(
24
+ {
25
+ "access_key",
26
+ "access_key_id",
27
+ "access_token",
28
+ "api_key",
29
+ "apikey",
30
+ "auth",
31
+ "authorization",
32
+ "aws_access_key_id",
33
+ "aws_secret_access_key",
34
+ "aws_session_token",
35
+ "azure_client_secret",
36
+ "client_secret",
37
+ "cookie",
38
+ "credential",
39
+ "credential_helper",
40
+ "credentials",
41
+ "csrf",
42
+ "csrf_token",
43
+ "google_application_credentials",
44
+ "id_token",
45
+ "jsessionid",
46
+ "jwt",
47
+ "pass",
48
+ "password",
49
+ "passwd",
50
+ "private_key",
51
+ "privatekey",
52
+ "proxy_authorization",
53
+ "pwd",
54
+ "refresh_token",
55
+ "secret",
56
+ "session",
57
+ "session_id",
58
+ "session_token",
59
+ "sessionid",
60
+ "set_cookie",
61
+ "sid",
62
+ "sig",
63
+ "signature",
64
+ "smtp_pass",
65
+ "smtppass",
66
+ "ssh_command",
67
+ "ssh_key",
68
+ "sshkey",
69
+ "token",
70
+ "x_amz_credential",
71
+ "x_amz_security_token",
72
+ "x_amz_signature",
73
+ "xsrf",
74
+ "xsrf_token",
75
+ }
76
+ )
77
+ SENSITIVE_KEY_SUFFIXES = (
78
+ "_access_key",
79
+ "_access_token",
80
+ "_api_key",
81
+ "_client_secret",
82
+ "_credential",
83
+ "_credentials",
84
+ "_password",
85
+ "_private_key",
86
+ "_refresh_token",
87
+ "_secret",
88
+ "_secret_key",
89
+ "_session_token",
90
+ "_ssh_command",
91
+ "_token",
92
+ )
93
+ SENSITIVE_KEY_QUALIFIER_RE = re.compile(
94
+ r"(?:^|_)(?:api_?key|apikey|token|secret|password|passwd|pass|pwd|"
95
+ r"private_key|access_key|client_secret|credential|signature|sig|"
96
+ r"session_id|session_token)(?:_(?:v\d+|prod|production|dev|test|backup))?$"
97
+ )
98
+ INLINE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
99
+ (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
100
+ (re.compile(r"(?i)\bBasic\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
101
+ (re.compile(r"(?i)(--(?:api[_-]?key|token|secret|password|client[_-]?secret)\s+)\S+"), r"\1[REDACTED]"),
102
+ (re.compile(r"(?i)(--(?:api[_-]?key|token|secret|password|client[_-]?secret)=)\S+"), r"\1[REDACTED]"),
103
+ (re.compile(r"(?i)((?:-p|-u|--user)\s+)\S+:\S+"), r"\1[REDACTED]"),
104
+ (re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"), "[REDACTED]"),
105
+ (re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "[REDACTED]"),
106
+ (re.compile(r"glpat-[A-Za-z0-9_-]{12,}"), "[REDACTED]"),
107
+ (re.compile(r"xox[abprs]-[A-Za-z0-9-]{10,}"), "[REDACTED]"),
108
+ (re.compile(r"(?:AKIA|ASIA)[0-9A-Z]{16}"), "[REDACTED]"),
109
+ (re.compile(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}"), "[REDACTED]"),
110
+ (re.compile(r"sk-(?:ant|proj)-[A-Za-z0-9_-]{12,}"), "[REDACTED]"),
111
+ (re.compile(r"sk-[A-Za-z0-9][A-Za-z0-9_-]{20,}"), "[REDACTED]"),
112
+ (re.compile(r"npm_[A-Za-z0-9]{20,}"), "[REDACTED]"),
113
+ (re.compile(r"AIza[0-9A-Za-z_\-]{20,}"), "[REDACTED]"),
114
+ (re.compile(r"SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"), "[REDACTED]"),
115
+ (re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), "[REDACTED]"),
116
+ # FIX-6: URL userinfo 리댁션 — 비밀번호 파트(`:pass`)를 선택적으로 만들어
117
+ # `scheme://TOKEN@host`(콜론 없는 토큰 전용 형태, 가장 흔한 PAT 임베딩 방식)도
118
+ # 함께 잡는다. 기존 패턴은 `user:pass@` 두 부분을 모두 요구해 벤더 접두사가
119
+ # 없는 토큰(Azure DevOps PAT, 사내 PAT, 범용 토큰 등 — `ghp_`/`github_pat_`/
120
+ # `glpat-`처럼 위에서 별도 패턴이 잡는 벤더가 아닌 토큰)을 통과시켰다(실측
121
+ # 3/3 누수). `scheme://` 접두사는 여전히 필수이므로 `git log` 저자 줄의
122
+ # `<user@host>` 같은 스킴 없는 평범한 이메일 언급은 과잉 리댁션하지 않는다
123
+ # — 이 경계는 sanitizer_mode_cases의 `bare_userinfo_without_scheme` 음성
124
+ # 케이스로 고정한다(tests/context_guard_a1_oracles.py).
125
+ (re.compile(r"([a-z][a-z0-9+.-]*://)[^/\s:@]+(?::[^/\s@]+)?@", re.IGNORECASE), r"\1[REDACTED]@"),
126
+ )
127
+
128
+
129
+ def normalize_sensitive_key(key: str) -> str:
130
+ key = CAMEL_ACRONYM_BOUNDARY_RE.sub("_", key)
131
+ key = CAMEL_WORD_BOUNDARY_RE.sub("_", key)
132
+ key = re.sub(r"[_.-]+", "_", key)
133
+ return re.sub(r"_+", "_", key).strip("_").lower()
134
+
135
+
136
+ def is_sensitive_key(key: str) -> bool:
137
+ normalized = normalize_sensitive_key(key.strip().strip("\"'"))
138
+ return (
139
+ normalized in EXACT_SENSITIVE_KEYS
140
+ or normalized.endswith(SENSITIVE_KEY_SUFFIXES)
141
+ or SENSITIVE_KEY_QUALIFIER_RE.search(normalized) is not None
142
+ )
143
+
144
+
145
+ def redact_url_like_secret_params(line: str) -> tuple[str, bool]:
146
+ redacted = False
147
+
148
+ def url_repl(match: re.Match[str]) -> str:
149
+ nonlocal redacted
150
+
151
+ def param_repl(param_match: re.Match[str]) -> str:
152
+ nonlocal redacted
153
+ prefix = param_match.group(1)
154
+ key = prefix[1:].split("=", 1)[0]
155
+ if not is_sensitive_key(key):
156
+ return param_match.group(0)
157
+ redacted = True
158
+ return prefix + "[REDACTED]"
159
+
160
+ return URL_SECRET_PARAM_RE.sub(param_repl, match.group(0))
161
+
162
+ line = URL_LIKE_RE.sub(url_repl, line)
163
+
164
+ def fragment_repl(param_match: re.Match[str]) -> str:
165
+ nonlocal redacted
166
+ prefix = param_match.group(1)
167
+ key = prefix[1:].split("=", 1)[0]
168
+ if not is_sensitive_key(key):
169
+ return param_match.group(0)
170
+ redacted = True
171
+ return prefix + "[REDACTED]"
172
+
173
+ return SCHEMELESS_SECRET_PARAM_RE.sub(fragment_repl, line), redacted
174
+
175
+
176
+ def redact_high_confidence_credentials(text: str) -> tuple[str, int]:
177
+ """Redact context-independent credential values without path policy."""
178
+ redactions = 0
179
+ text, url_redacted = redact_url_like_secret_params(text)
180
+ if url_redacted:
181
+ redactions += 1
182
+ for pattern, replacement in INLINE_PATTERNS:
183
+ text, count = pattern.subn(replacement, text)
184
+ redactions += count
185
+ return text, redactions
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic transcript usage reduction shared by audit and statusline.
3
+
4
+ Token totals intentionally recognize one Claude transcript shape:
5
+ ``row.message.usage`` with the model at ``row.message.model``. Other bounded
6
+ usage-like shapes are not summed, but are recorded as ineligible and make the
7
+ result partial so schema drift cannot silently look complete. Repeated
8
+ response rows are grouped before summing so snapshots, streaming updates, and
9
+ nested usage lookalikes cannot be counted as independent usage.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from collections import defaultdict
14
+ from dataclasses import dataclass
15
+ import datetime as _dt
16
+ import hashlib
17
+ import json
18
+ import math
19
+ import os
20
+ import re
21
+ from typing import Any, Iterable
22
+
23
+
24
+ REDUCER_SCHEMA = "usage-reducer-v2"
25
+ UINT63_MAX = (1 << 63) - 1
26
+ FILE_IDENTITY_RE = re.compile(r"^[0-9a-fA-F]{64}$")
27
+ TOKEN_FIELDS: tuple[tuple[str, tuple[str, ...]], ...] = (
28
+ ("input", ("input_tokens",)),
29
+ ("output", ("output_tokens",)),
30
+ ("cache_creation", ("cache_creation_input_tokens", "cacheCreation")),
31
+ ("cache_read", ("cache_read_input_tokens", "cacheRead")),
32
+ )
33
+ TIMESTAMP_KEYS = ("timestamp", "created_at", "createdAt", "time", "ts")
34
+ COUNTER_KEYS = (
35
+ "observed_rows",
36
+ "eligible_candidates",
37
+ "selected_candidates",
38
+ "usage_conflict",
39
+ "numeric_overflow",
40
+ "invalid_numeric",
41
+ "invalid_row",
42
+ "no_id_fallback",
43
+ "ineligible_usage_shape",
44
+ )
45
+ USAGE_LIKE_NODE_LIMIT = 4096
46
+ USAGE_LIKE_DEPTH_LIMIT = 64
47
+ TOKEN_FIELD_ALIASES = frozenset(alias for _bucket, aliases in TOKEN_FIELDS for alias in aliases)
48
+
49
+
50
+ def hash_file_identity(path: str | os.PathLike[str]) -> str:
51
+ """Hash a canonical local transcript identity without returning its path."""
52
+ canonical = os.path.realpath(os.path.abspath(os.fspath(path)))
53
+ return hashlib.sha256(os.fsencode(canonical)).hexdigest()
54
+
55
+
56
+ def canonical_row_sha256(row: Any) -> str | None:
57
+ """Hash a complete canonical JSON row, rejecting non-JSON numeric values."""
58
+ try:
59
+ encoded = json.dumps(
60
+ row,
61
+ ensure_ascii=False,
62
+ sort_keys=True,
63
+ separators=(",", ":"),
64
+ allow_nan=False,
65
+ ).encode("utf-8")
66
+ except (TypeError, ValueError, OverflowError, RecursionError, UnicodeError):
67
+ return None
68
+ return hashlib.sha256(encoded).hexdigest()
69
+
70
+
71
+ def _canonical_file_identity(value: str) -> str:
72
+ text = str(value)
73
+ if FILE_IDENTITY_RE.fullmatch(text):
74
+ return text.lower()
75
+ return hashlib.sha256(text.encode("utf-8", errors="surrogatepass")).hexdigest()
76
+
77
+
78
+ def _session_id(row: dict[str, Any]) -> str:
79
+ for key in ("session_id", "sessionId"):
80
+ value = row.get(key)
81
+ if isinstance(value, str):
82
+ return value
83
+ return ""
84
+
85
+
86
+ def _message_id(message: dict[str, Any]) -> str | None:
87
+ value = message.get("id")
88
+ if isinstance(value, str) and value:
89
+ return value
90
+ return None
91
+
92
+
93
+ def _model(message: dict[str, Any]) -> str:
94
+ value = message.get("model")
95
+ if not isinstance(value, str):
96
+ return "unknown"
97
+ safe_value = value.encode("utf-8", errors="replace").decode("utf-8")
98
+ compact = " ".join(safe_value.strip().split())
99
+ return compact[:120] or "unknown"
100
+
101
+
102
+ def _parse_timestamp(value: Any) -> _dt.datetime | None:
103
+ if isinstance(value, bool):
104
+ return None
105
+ if isinstance(value, str):
106
+ text = value.strip()
107
+ if not text:
108
+ return None
109
+ try:
110
+ if text.endswith("Z"):
111
+ text = text[:-1] + "+00:00"
112
+ parsed = _dt.datetime.fromisoformat(text)
113
+ except ValueError:
114
+ return None
115
+ if parsed.tzinfo is None:
116
+ parsed = parsed.replace(tzinfo=_dt.timezone.utc)
117
+ try:
118
+ return parsed.astimezone(_dt.timezone.utc)
119
+ except (OverflowError, ValueError):
120
+ return None
121
+ if isinstance(value, (int, float)) and math.isfinite(float(value)) and value >= 0:
122
+ seconds = float(value)
123
+ if seconds > 10_000_000_000:
124
+ seconds /= 1000.0
125
+ try:
126
+ return _dt.datetime.fromtimestamp(seconds, tz=_dt.timezone.utc)
127
+ except (OverflowError, OSError, ValueError):
128
+ return None
129
+ return None
130
+
131
+
132
+ def _row_timestamp(row: dict[str, Any], message: dict[str, Any]) -> _dt.datetime | None:
133
+ for container in (row, message):
134
+ for key in TIMESTAMP_KEYS:
135
+ if key in container:
136
+ parsed = _parse_timestamp(container.get(key))
137
+ if parsed is not None:
138
+ return parsed
139
+ return None
140
+
141
+
142
+ def _usage_values(usage: dict[str, Any]) -> tuple[tuple[str, int], ...] | None:
143
+ values: list[tuple[str, int]] = []
144
+ found = False
145
+ for bucket, aliases in TOKEN_FIELDS:
146
+ raw: Any = None
147
+ present = False
148
+ for alias in aliases:
149
+ if alias in usage:
150
+ raw = usage.get(alias)
151
+ present = True
152
+ break
153
+ if not present:
154
+ continue
155
+ found = True
156
+ if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0 or raw > UINT63_MAX:
157
+ return None
158
+ values.append((bucket, raw))
159
+ if not found:
160
+ return ()
161
+ return tuple(values)
162
+
163
+
164
+ def _contains_usage_like_tokens(root: Any) -> bool:
165
+ """Boundedly detect token-bearing shapes outside ``row.message.usage``."""
166
+ stack: list[tuple[Any, int]] = [(root, 0)]
167
+ seen: set[int] = set()
168
+ visited = 0
169
+ while stack and visited < USAGE_LIKE_NODE_LIMIT:
170
+ value, depth = stack.pop()
171
+ visited += 1
172
+ if isinstance(value, dict):
173
+ identity = id(value)
174
+ if identity in seen:
175
+ continue
176
+ seen.add(identity)
177
+ if TOKEN_FIELD_ALIASES.intersection(value):
178
+ return True
179
+ if value.get("name") == "claude_code.token.usage":
180
+ return True
181
+ if depth < USAGE_LIKE_DEPTH_LIMIT:
182
+ stack.extend((child, depth + 1) for child in value.values())
183
+ elif isinstance(value, list):
184
+ identity = id(value)
185
+ if identity in seen:
186
+ continue
187
+ seen.add(identity)
188
+ if depth < USAGE_LIKE_DEPTH_LIMIT:
189
+ stack.extend((child, depth + 1) for child in value)
190
+ return False
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class UsageSelection:
195
+ file_identity: str
196
+ row_ordinal: int
197
+ tokens: dict[str, int]
198
+ present_buckets: tuple[str, ...]
199
+ model: str
200
+ timestamp: _dt.datetime | None
201
+ used_no_id_fallback: bool
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class UsageReduction:
206
+ schema: str
207
+ tokens: dict[str, int]
208
+ by_model: dict[str, dict[str, int]]
209
+ counters: dict[str, int]
210
+ partial: bool
211
+ selections: tuple[UsageSelection, ...]
212
+
213
+
214
+ @dataclass(frozen=True)
215
+ class _Candidate:
216
+ file_identity: str
217
+ row_ordinal: int
218
+ usage_items: tuple[tuple[str, int], ...]
219
+ model: str
220
+ timestamp: _dt.datetime | None
221
+ used_no_id_fallback: bool
222
+
223
+ @property
224
+ def value(self) -> tuple[tuple[str, int], ...]:
225
+ return self.usage_items
226
+
227
+ @property
228
+ def precedence(self) -> tuple[int, _dt.datetime, int]:
229
+ minimum = _dt.datetime.min.replace(tzinfo=_dt.timezone.utc)
230
+ return (1 if self.timestamp is not None else 0, self.timestamp or minimum, self.row_ordinal)
231
+
232
+
233
+ class UsageReducer:
234
+ """Collect response candidates and finalize their selected aggregate."""
235
+
236
+ def __init__(self) -> None:
237
+ self._groups: dict[tuple[str, str, str], list[_Candidate]] = defaultdict(list)
238
+ self._counters = {key: 0 for key in COUNTER_KEYS}
239
+ self._partial = False
240
+ self._next_ordinal = 0
241
+
242
+ def note_invalid_row(self, count: int = 1) -> None:
243
+ amount = max(0, int(count))
244
+ if amount:
245
+ self._counters["invalid_row"] += amount
246
+ self._partial = True
247
+
248
+ def observe(
249
+ self,
250
+ row: Any,
251
+ *,
252
+ file_identity: str,
253
+ row_ordinal: int | None = None,
254
+ ) -> bool:
255
+ ordinal = self._next_ordinal if row_ordinal is None else int(row_ordinal)
256
+ self._next_ordinal = max(self._next_ordinal + 1, ordinal + 1)
257
+ self._counters["observed_rows"] += 1
258
+ if not isinstance(row, dict):
259
+ self.note_invalid_row()
260
+ return False
261
+ message = row.get("message")
262
+ if not isinstance(message, dict):
263
+ if _contains_usage_like_tokens(row):
264
+ self._counters["ineligible_usage_shape"] += 1
265
+ self._partial = True
266
+ return False
267
+ usage = message.get("usage")
268
+ if not isinstance(usage, dict):
269
+ if _contains_usage_like_tokens(row):
270
+ self._counters["ineligible_usage_shape"] += 1
271
+ self._partial = True
272
+ return False
273
+ usage_items = _usage_values(usage)
274
+ if usage_items is None:
275
+ self._counters["invalid_numeric"] += 1
276
+ self._partial = True
277
+ return False
278
+ if not usage_items:
279
+ return False
280
+
281
+ canonical_file = _canonical_file_identity(file_identity)
282
+ message_id = _message_id(message)
283
+ used_no_id_fallback = message_id is None
284
+ if message_id is None:
285
+ row_digest = canonical_row_sha256(row)
286
+ if row_digest is None:
287
+ self.note_invalid_row()
288
+ return False
289
+ group_suffix = f"row:{row_digest}"
290
+ else:
291
+ group_suffix = f"id:{message_id}"
292
+ group_key = (canonical_file, _session_id(row), group_suffix)
293
+ first_for_group = group_key not in self._groups
294
+ candidate = _Candidate(
295
+ file_identity=canonical_file,
296
+ row_ordinal=ordinal,
297
+ usage_items=usage_items,
298
+ model=_model(message),
299
+ timestamp=_row_timestamp(row, message),
300
+ used_no_id_fallback=used_no_id_fallback,
301
+ )
302
+ self._groups[group_key].append(candidate)
303
+ self._counters["eligible_candidates"] += 1
304
+ if used_no_id_fallback and first_for_group:
305
+ self._counters["no_id_fallback"] += 1
306
+ return True
307
+
308
+ def extend(
309
+ self,
310
+ rows: Iterable[Any],
311
+ *,
312
+ file_identity: str,
313
+ start_ordinal: int | None = None,
314
+ ) -> None:
315
+ ordinal = self._next_ordinal if start_ordinal is None else int(start_ordinal)
316
+ for row in rows:
317
+ self.observe(row, file_identity=file_identity, row_ordinal=ordinal)
318
+ ordinal += 1
319
+
320
+ def finalize(self) -> UsageReduction:
321
+ totals: dict[str, int] = defaultdict(int)
322
+ by_model: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
323
+ counters = dict(self._counters)
324
+ partial = self._partial
325
+ selections: list[UsageSelection] = []
326
+
327
+ for group_key in sorted(self._groups):
328
+ candidates = self._groups[group_key]
329
+ if len({candidate.value for candidate in candidates}) > 1:
330
+ counters["usage_conflict"] += 1
331
+ partial = True
332
+ selected = max(candidates, key=lambda candidate: candidate.precedence)
333
+ values = dict(selected.usage_items)
334
+ if any(totals[bucket] > UINT63_MAX - value for bucket, value in values.items()):
335
+ counters["numeric_overflow"] += 1
336
+ partial = True
337
+ continue
338
+ for bucket, value in values.items():
339
+ totals[bucket] += value
340
+ by_model[selected.model][bucket] += value
341
+ selections.append(
342
+ UsageSelection(
343
+ file_identity=selected.file_identity,
344
+ row_ordinal=selected.row_ordinal,
345
+ tokens={bucket: value for bucket, value in values.items() if value},
346
+ present_buckets=tuple(bucket for bucket, _value in selected.usage_items),
347
+ model=selected.model,
348
+ timestamp=selected.timestamp,
349
+ used_no_id_fallback=selected.used_no_id_fallback,
350
+ )
351
+ )
352
+
353
+ counters["selected_candidates"] = len(selections)
354
+ stable_totals = {key: totals[key] for key in sorted(totals) if totals[key]}
355
+ stable_by_model = {
356
+ model: {key: buckets[key] for key in sorted(buckets) if buckets[key]}
357
+ for model, buckets in sorted(by_model.items())
358
+ if any(buckets.values())
359
+ }
360
+ return UsageReduction(
361
+ schema=REDUCER_SCHEMA,
362
+ tokens=stable_totals,
363
+ by_model=stable_by_model,
364
+ counters={key: counters.get(key, 0) for key in COUNTER_KEYS},
365
+ partial=partial,
366
+ selections=tuple(selections),
367
+ )
368
+
369
+
370
+ def reduce_rows(
371
+ rows: Iterable[Any],
372
+ *,
373
+ file_identity: str,
374
+ start_ordinal: int = 0,
375
+ ) -> UsageReduction:
376
+ reducer = UsageReducer()
377
+ reducer.extend(rows, file_identity=file_identity, start_ordinal=start_ordinal)
378
+ return reducer.finalize()