@ictechgy/context-guard 0.4.15 → 0.4.16

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 (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.ko.md +46 -1
  3. package/README.md +58 -2
  4. package/package.json +1 -1
  5. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  6. package/plugins/context-guard/README.ko.md +18 -0
  7. package/plugins/context-guard/README.md +18 -0
  8. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  9. package/plugins/context-guard/bin/context-guard-audit +169 -66
  10. package/plugins/context-guard/bin/context-guard-bench +5765 -224
  11. package/plugins/context-guard/bin/context-guard-compress +90 -8
  12. package/plugins/context-guard/bin/context-guard-diet +1 -7
  13. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  14. package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
  15. package/plugins/context-guard/bin/context-guard-guard-read +490 -55
  16. package/plugins/context-guard/bin/context-guard-pack +110 -11
  17. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  18. package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
  19. package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
  20. package/plugins/context-guard/bin/context-guard-setup +1073 -147
  21. package/plugins/context-guard/bin/context-guard-statusline +131 -54
  22. package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
  23. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  24. package/plugins/context-guard/bin/context-guard-trim-output +89 -13
  25. package/plugins/context-guard/brief/README.md +19 -0
  26. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  27. package/plugins/context-guard/lib/context_guard_commands.py +6 -2
  28. package/plugins/context-guard/lib/credential_policy.py +177 -0
  29. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env python3
2
2
  """Best-effort Claude Code transcript usage auditor.
3
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.
4
+ Claude Code transcript schemas may change. Token totals use the deterministic
5
+ ``row.message.usage`` contract; other bounded usage-like shapes mark results
6
+ partial instead of being silently counted. Cost and diagnostic metadata retain
7
+ their bounded schema-tolerant scans. Parse/read skips are reported so totals are
8
+ not mistaken for billing-authoritative data.
7
9
  """
8
10
  from __future__ import annotations
9
11
 
@@ -23,6 +25,19 @@ from dataclasses import dataclass, field
23
25
  from pathlib import Path
24
26
  from typing import Any, BinaryIO, Iterable
25
27
 
28
+ _SCRIPT_DIR = Path(__file__).resolve().parent
29
+ _REDUCER_DIR = _SCRIPT_DIR
30
+ if not (_REDUCER_DIR / "transcript_usage_reducer.py").is_file():
31
+ _REDUCER_DIR = _SCRIPT_DIR.parent / "lib"
32
+ if str(_REDUCER_DIR) not in sys.path:
33
+ sys.path.insert(0, str(_REDUCER_DIR))
34
+
35
+ from transcript_usage_reducer import ( # noqa: E402
36
+ REDUCER_SCHEMA,
37
+ UsageReducer,
38
+ hash_file_identity,
39
+ )
40
+
26
41
  TOKEN_KEY_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
27
42
  ("input", ("input_tokens",)),
28
43
  ("output", ("output_tokens",)),
@@ -188,6 +203,9 @@ class UsageSummary:
188
203
  cache_record_timestamps: list[_dt.datetime] = field(default_factory=list)
189
204
  positive_cache_record_timestamps: list[_dt.datetime] = field(default_factory=list)
190
205
  prompt_cache_audit: PromptCacheAudit = field(default_factory=PromptCacheAudit)
206
+ usage_reducer_schema: str = REDUCER_SCHEMA
207
+ usage_reducer_counters: Counter[str] = field(default_factory=Counter)
208
+ usage_reducer_partial: bool = False
191
209
  cache_friendliness_cache: dict[str, Any] | None = field(default=None, init=False, repr=False)
192
210
  cache_diagnostics_cache: dict[str, Any] | None = field(default=None, init=False, repr=False)
193
211
  cache_layout_advice_cache: dict[str, Any] | None = field(default=None, init=False, repr=False)
@@ -242,11 +260,7 @@ def iter_jsonl_files(paths: Iterable[str]) -> Iterable[Path]:
242
260
  if path.is_file() and path.suffix in {".jsonl", ".json"}:
243
261
  candidates = [path]
244
262
  elif path.is_dir():
245
- candidates = (
246
- candidate
247
- for pattern in ("*.jsonl", "*.json")
248
- for candidate in path.rglob(pattern)
249
- )
263
+ candidates = sorted(path.rglob("*.jsonl"))
250
264
  else:
251
265
  continue
252
266
  for candidate in candidates:
@@ -720,53 +734,9 @@ def add_usage(
720
734
  show_paths: bool = False,
721
735
  show_commands: bool = False,
722
736
  ) -> RecordUsage:
723
- root_model = None
724
- root_query_source = None
725
- parsed_timestamp = None
726
- if isinstance(root, dict):
727
- root_model = first_string(root, MODEL_KEYS)
728
- root_query_source = first_string(root, QUERY_SOURCE_KEYS)
729
- parsed_timestamp = record_timestamp(root)
730
-
731
737
  record = RecordUsage()
732
- cache_telemetry_present = False
733
- positive_cache_telemetry_present = False
734
738
  summary.prompt_cache_audit.observe(root)
735
739
  for d in walk(root):
736
- local_tokens: Counter[str] = Counter()
737
- present_buckets = add_token_groups(local_tokens, d)
738
-
739
- # OpenTelemetry-style records sometimes use {name, value, attributes.type}.
740
- name = d.get("name") or d.get("metric")
741
- if name == "claude_code.token.usage":
742
- value = d.get("value")
743
- if value is None:
744
- value = d.get("sum")
745
- if value is None:
746
- value = d.get("count")
747
- attrs = d.get("attributes") or {}
748
- token_type = attrs.get("type", "unknown") if isinstance(attrs, dict) else "unknown"
749
- metric = finite_nonnegative_number(value, clamp_negative=True)
750
- if metric is not None:
751
- bucket = normalize_token_bucket(str(token_type))
752
- local_tokens[bucket] += int(metric)
753
- present_buckets.add(bucket)
754
-
755
- for bucket in present_buckets:
756
- summary.token_field_presence[bucket] += 1
757
- if "cache_read" in present_buckets or "cache_creation" in present_buckets:
758
- cache_telemetry_present = True
759
- if local_tokens.get("cache_read", 0) > 0 or local_tokens.get("cache_creation", 0) > 0:
760
- positive_cache_telemetry_present = True
761
-
762
- if local_tokens:
763
- summary.tokens.update(local_tokens)
764
- record.tokens.update(local_tokens)
765
- model = sanitize_label(first_string(d, MODEL_KEYS) or root_model or "unknown", 80)
766
- query_source = sanitize_label(first_string(d, QUERY_SOURCE_KEYS) or root_query_source or "unknown", 80)
767
- summary.by_model[model].update(local_tokens)
768
- summary.by_query_source[query_source].update(local_tokens)
769
-
770
740
  for key in COST_KEYS:
771
741
  val = d.get(key)
772
742
  metric = finite_nonnegative_number(val, clamp_negative=False)
@@ -776,17 +746,11 @@ def add_usage(
776
746
  record.cost_usd += cost
777
747
  summary.cost_field_count += 1
778
748
  break
779
- if parsed_timestamp is not None and cache_telemetry_present:
780
- summary.cache_record_timestamps.append(parsed_timestamp)
781
- if parsed_timestamp is not None and positive_cache_telemetry_present:
782
- summary.positive_cache_record_timestamps.append(parsed_timestamp)
783
749
  commands, tools = collect_record_hints(root, show_commands=show_commands)
784
750
  record.commands = commands
785
751
  record.tools = tools
786
- record_total = sum(record.tokens.values())
787
- if file is not None and (record_total or record.cost_usd):
752
+ if file is not None and record.cost_usd:
788
753
  file_key = path_label(file, show_paths=show_paths)
789
- summary.by_file[file_key] += record_total
790
754
  summary.cost_by_file[file_key] += record.cost_usd
791
755
  for command in commands:
792
756
  summary.by_command[command] += 1
@@ -805,6 +769,42 @@ def parse_json_line(line: str) -> Any:
805
769
  return json.loads(line)
806
770
 
807
771
 
772
+ def _apply_usage_reduction(
773
+ summary: UsageSummary,
774
+ reducer: UsageReducer,
775
+ row_metadata: dict[int, tuple[Path, str]],
776
+ *,
777
+ show_paths: bool,
778
+ ) -> None:
779
+ reduction = reducer.finalize()
780
+ summary.usage_reducer_schema = reduction.schema
781
+ summary.usage_reducer_counters.update(reduction.counters)
782
+ summary.usage_reducer_partial = reduction.partial
783
+ summary.tokens.update(reduction.tokens)
784
+ for selection in reduction.selections:
785
+ local_tokens = Counter(selection.tokens)
786
+ for bucket in selection.present_buckets:
787
+ summary.token_field_presence[bucket] += 1
788
+ if local_tokens:
789
+ model = sanitize_label(selection.model, 80)
790
+ summary.by_model[model].update(local_tokens)
791
+ metadata = row_metadata.get(selection.row_ordinal)
792
+ if metadata is not None:
793
+ file, query_source = metadata
794
+ if local_tokens:
795
+ summary.by_query_source[query_source].update(local_tokens)
796
+ summary.by_file[path_label(file, show_paths=show_paths)] += sum(local_tokens.values())
797
+ cache_present = bool({"cache_read", "cache_creation"} & set(selection.present_buckets))
798
+ positive_cache = (
799
+ selection.tokens.get("cache_read", 0) > 0
800
+ or selection.tokens.get("cache_creation", 0) > 0
801
+ )
802
+ if selection.timestamp is not None and cache_present:
803
+ summary.cache_record_timestamps.append(selection.timestamp)
804
+ if selection.timestamp is not None and positive_cache:
805
+ summary.positive_cache_record_timestamps.append(selection.timestamp)
806
+
807
+
808
808
  def scan(
809
809
  paths: list[str],
810
810
  show_paths: bool = False,
@@ -813,6 +813,38 @@ def scan(
813
813
  ) -> UsageSummary:
814
814
  limits = limits or ScanLimits()
815
815
  summary = UsageSummary()
816
+ reducer = UsageReducer()
817
+ row_metadata: dict[int, tuple[Path, str]] = {}
818
+ file_identities: dict[Path, str] = {}
819
+ next_ordinal = 0
820
+
821
+ def observe_row(file: Path, obj: Any, location: str) -> None:
822
+ nonlocal next_ordinal
823
+ if not isinstance(obj, dict):
824
+ summary.skipped_records += 1
825
+ reducer.note_invalid_row()
826
+ summary.note_error(
827
+ f"{path_label(file, show_paths=show_paths)}:{location}: "
828
+ "skipped non-object transcript row"
829
+ )
830
+ return
831
+ ordinal = next_ordinal
832
+ next_ordinal += 1
833
+ summary.records += 1
834
+ query_source = sanitize_label(first_string(obj, QUERY_SOURCE_KEYS) or "unknown", 80)
835
+ file_identity = file_identities.get(file)
836
+ if file_identity is None:
837
+ file_identity = hash_file_identity(file)
838
+ file_identities[file] = file_identity
839
+ accepted = reducer.observe(
840
+ obj,
841
+ file_identity=file_identity,
842
+ row_ordinal=ordinal,
843
+ )
844
+ if accepted:
845
+ row_metadata[ordinal] = (file, query_source)
846
+ add_usage(summary, obj, file, show_paths=show_paths, show_commands=show_commands)
847
+
816
848
  for file in iter_jsonl_files(paths):
817
849
  if summary.files >= limits.max_files:
818
850
  summary.skipped_files += 1
@@ -834,9 +866,35 @@ def scan(
834
866
  f"({size} bytes > {limits.max_file_bytes})"
835
867
  )
836
868
  continue
869
+ if file.suffix == ".json":
870
+ try:
871
+ raw = handle.read(size + 1)
872
+ parsed = parse_json_line(raw.decode("utf-8", errors="strict"))
873
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
874
+ summary.skipped_records += 1
875
+ reducer.note_invalid_row()
876
+ reason = "invalid UTF-8" if isinstance(exc, UnicodeDecodeError) else exc.msg
877
+ summary.note_error(
878
+ f"{path_label(file, show_paths=show_paths)}: JSON parse error: {reason}"
879
+ )
880
+ continue
881
+ except RecursionError:
882
+ summary.skipped_records += 1
883
+ reducer.note_invalid_row()
884
+ summary.note_error(
885
+ f"{path_label(file, show_paths=show_paths)}: "
886
+ "JSON parse error: nested JSON exceeds supported depth"
887
+ )
888
+ continue
889
+ rows = parsed if isinstance(parsed, list) else [parsed]
890
+ for index, obj in enumerate(rows):
891
+ observe_row(file, obj, f"item-{index}")
892
+ continue
893
+
837
894
  for line_no, line in iter_bounded_lines(handle, limits.max_line_bytes):
838
895
  if line is None:
839
896
  summary.skipped_records += 1
897
+ reducer.note_invalid_row()
840
898
  summary.note_error(
841
899
  f"{path_label(file, show_paths=show_paths)}:{line_no}: "
842
900
  f"skipped oversized JSONL record (> {limits.max_line_bytes} bytes)"
@@ -849,18 +907,26 @@ def scan(
849
907
  obj = parse_json_line(line)
850
908
  except json.JSONDecodeError as exc:
851
909
  summary.skipped_records += 1
852
- summary.note_error(f"{path_label(file, show_paths=show_paths)}:{line_no}: JSON parse error: {exc.msg}")
910
+ reducer.note_invalid_row()
911
+ summary.note_error(
912
+ f"{path_label(file, show_paths=show_paths)}:{line_no}: "
913
+ f"JSON parse error: {exc.msg}"
914
+ )
853
915
  continue
854
- except RecursionError as exc:
916
+ except RecursionError:
855
917
  summary.skipped_records += 1
856
- summary.note_error(f"{path_label(file, show_paths=show_paths)}:{line_no}: JSON parse error: nested JSON exceeds supported depth")
918
+ reducer.note_invalid_row()
919
+ summary.note_error(
920
+ f"{path_label(file, show_paths=show_paths)}:{line_no}: "
921
+ "JSON parse error: nested JSON exceeds supported depth"
922
+ )
857
923
  continue
858
- summary.records += 1
859
- add_usage(summary, obj, file, show_paths=show_paths, show_commands=show_commands)
924
+ observe_row(file, obj, f"line-{line_no}")
860
925
  except OSError as exc:
861
926
  summary.skipped_files += 1
862
927
  summary.note_error(f"{path_label(file, show_paths=show_paths)}: read error: {os_error_summary(exc)}")
863
928
  continue
929
+ _apply_usage_reduction(summary, reducer, row_metadata, show_paths=show_paths)
864
930
  return summary
865
931
 
866
932
 
@@ -933,7 +999,7 @@ def build_headroom_availability(summary: UsageSummary) -> dict[str, Any]:
933
999
 
934
1000
  def scan_integrity(summary: UsageSummary) -> dict[str, Any]:
935
1001
  skipped = summary.skipped_files + summary.skipped_records
936
- complete = skipped == 0 and not summary.parse_errors
1002
+ complete = skipped == 0 and not summary.parse_errors and not summary.usage_reducer_partial
937
1003
  return {
938
1004
  "status": "complete" if complete else "partial",
939
1005
  "files_scanned": summary.files,
@@ -943,6 +1009,15 @@ def scan_integrity(summary: UsageSummary) -> dict[str, Any]:
943
1009
  "scan_truncated": summary.scan_truncated,
944
1010
  "skipped_records": summary.skipped_records,
945
1011
  "parse_error_count": len(summary.parse_errors),
1012
+ "usage_reducer_schema": summary.usage_reducer_schema,
1013
+ "usage_reducer_partial": summary.usage_reducer_partial,
1014
+ "usage_conflict": summary.usage_reducer_counters.get("usage_conflict", 0),
1015
+ "numeric_overflow": summary.usage_reducer_counters.get("numeric_overflow", 0),
1016
+ "invalid_numeric": summary.usage_reducer_counters.get("invalid_numeric", 0),
1017
+ "no_id_fallback": summary.usage_reducer_counters.get("no_id_fallback", 0),
1018
+ "ineligible_usage_shape": summary.usage_reducer_counters.get(
1019
+ "ineligible_usage_shape", 0
1020
+ ),
946
1021
  "complete": complete,
947
1022
  "reason": (
948
1023
  "All candidate transcript files/records were parsed within configured limits."
@@ -1987,7 +2062,8 @@ def build_recommendations(summary: UsageSummary, top: int) -> list[dict[str, Any
1987
2062
  rec["confidence"] = finding.get("confidence")
1988
2063
  recs.append(rec)
1989
2064
  break
1990
- if output_tokens >= 5_000 or output_ratio >= 0.35:
2065
+ has_command_or_tool_evidence = bool(summary.by_command or summary.by_tool)
2066
+ if has_command_or_tool_evidence and (output_tokens >= 5_000 or output_ratio >= 0.35):
1991
2067
  recs.append(recommendation(
1992
2068
  "trim-output-heavy-sessions",
1993
2069
  "Output tokens are a major hotspot",
@@ -2171,6 +2247,7 @@ def summary_json(
2171
2247
  "scan_truncated": summary.scan_truncated,
2172
2248
  "skipped_records": summary.skipped_records,
2173
2249
  "parse_errors": summary.parse_errors,
2250
+ "scan_integrity": scan_integrity(summary),
2174
2251
  "scan_limits": {
2175
2252
  "max_file_bytes": limits.max_file_bytes,
2176
2253
  "max_line_bytes": limits.max_line_bytes,
@@ -2178,6 +2255,24 @@ def summary_json(
2178
2255
  },
2179
2256
  "total_tokens": summary.total_tokens,
2180
2257
  "tokens": dict(summary.tokens),
2258
+ "usage_reducer": {
2259
+ "schema": summary.usage_reducer_schema,
2260
+ "partial": summary.usage_reducer_partial,
2261
+ **{
2262
+ key: summary.usage_reducer_counters.get(key, 0)
2263
+ for key in (
2264
+ "observed_rows",
2265
+ "eligible_candidates",
2266
+ "selected_candidates",
2267
+ "usage_conflict",
2268
+ "numeric_overflow",
2269
+ "invalid_numeric",
2270
+ "invalid_row",
2271
+ "no_id_fallback",
2272
+ "ineligible_usage_shape",
2273
+ )
2274
+ },
2275
+ },
2181
2276
  "cache_metrics": {
2182
2277
  "cache_hit_rate": round(summary.cache_hit_rate, 4),
2183
2278
  "cache_amortization": round(summary.cache_amortization, 4),
@@ -2282,6 +2377,14 @@ def main() -> int:
2282
2377
  f"scan_limits=max_file_bytes:{limits.max_file_bytes} "
2283
2378
  f"max_line_bytes:{limits.max_line_bytes} max_files:{limits.max_files}"
2284
2379
  )
2380
+ print(
2381
+ f"usage_reducer={summary.usage_reducer_schema} "
2382
+ f"partial={str(summary.usage_reducer_partial).lower()} "
2383
+ f"conflicts={summary.usage_reducer_counters.get('usage_conflict', 0)} "
2384
+ f"overflows={summary.usage_reducer_counters.get('numeric_overflow', 0)} "
2385
+ f"no_id_fallback={summary.usage_reducer_counters.get('no_id_fallback', 0)} "
2386
+ f"ineligible_usage_shape={summary.usage_reducer_counters.get('ineligible_usage_shape', 0)}"
2387
+ )
2285
2388
  print(f"observed_total_tokens={summary.total_tokens}")
2286
2389
  if summary.cost_usd:
2287
2390
  print(f"observed_cost_usd={summary.cost_usd:.4f}")