@ictechgy/context-guard 0.4.9 → 0.4.11

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 (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.ko.md +59 -31
  3. package/README.md +85 -36
  4. package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
  5. package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
  6. package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
  7. package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
  8. package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
  9. package/docs/benchmark-workflow-examples.md +3 -0
  10. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
  11. package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
  12. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
  13. package/docs/distribution.md +10 -7
  14. package/docs/experimental-benchmark-fixtures.md +30 -6
  15. package/package.json +4 -6
  16. package/packaging/homebrew/context-guard.rb.template +1 -1
  17. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  18. package/plugins/context-guard/README.ko.md +20 -14
  19. package/plugins/context-guard/README.md +26 -17
  20. package/plugins/context-guard/bin/context-guard +147 -25
  21. package/plugins/context-guard/bin/context-guard-artifact +884 -79
  22. package/plugins/context-guard/bin/context-guard-audit +33 -2
  23. package/plugins/context-guard/bin/context-guard-bench +1542 -31
  24. package/plugins/context-guard/bin/context-guard-cache-score +665 -0
  25. package/plugins/context-guard/bin/context-guard-compress +146 -1
  26. package/plugins/context-guard/bin/context-guard-cost +790 -6
  27. package/plugins/context-guard/bin/context-guard-experiments +463 -26
  28. package/plugins/context-guard/bin/context-guard-failed-nudge +9 -2
  29. package/plugins/context-guard/bin/context-guard-filter +163 -7
  30. package/plugins/context-guard/bin/context-guard-guard-read +3 -0
  31. package/plugins/context-guard/bin/context-guard-pack +892 -49
  32. package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
  33. package/plugins/context-guard/bin/context-guard-sanitize-output +76 -12
  34. package/plugins/context-guard/bin/context-guard-setup +165 -31
  35. package/plugins/context-guard/bin/context-guard-statusline +490 -283
  36. package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
  37. package/plugins/context-guard/bin/context-guard-tool-prune +480 -53
  38. package/plugins/context-guard/bin/context-guard-trim-output +288 -41
  39. package/plugins/context-guard/brief/README.md +5 -5
  40. package/plugins/context-guard/lib/context_guard_commands.py +230 -0
  41. package/plugins/context-guard/skills/setup/SKILL.md +1 -0
  42. package/context-guard-kit/README.md +0 -91
  43. package/context-guard-kit/benchmark_runner.py +0 -2401
  44. package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
  45. package/context-guard-kit/context_compress.py +0 -695
  46. package/context-guard-kit/context_escrow.py +0 -935
  47. package/context-guard-kit/context_filter.py +0 -637
  48. package/context-guard-kit/context_guard_cli.py +0 -325
  49. package/context-guard-kit/context_guard_diet.py +0 -1711
  50. package/context-guard-kit/context_pack.py +0 -2713
  51. package/context-guard-kit/cost_guard.py +0 -2349
  52. package/context-guard-kit/experimental_registry.py +0 -4348
  53. package/context-guard-kit/failed_attempt_nudge.py +0 -567
  54. package/context-guard-kit/guard_large_read.py +0 -690
  55. package/context-guard-kit/hook_secret_patterns.py +0 -43
  56. package/context-guard-kit/read_symbol.py +0 -483
  57. package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
  58. package/context-guard-kit/sanitize_output.py +0 -725
  59. package/context-guard-kit/settings.example.json +0 -67
  60. package/context-guard-kit/setup_wizard.py +0 -2515
  61. package/context-guard-kit/statusline.sh +0 -362
  62. package/context-guard-kit/statusline_merged.sh +0 -157
  63. package/context-guard-kit/tool_schema_pruner.py +0 -837
  64. package/context-guard-kit/trim_command_output.py +0 -1449
@@ -21,6 +21,11 @@
21
21
  # (미지정 시 자기 옆 디렉토리만 사용; PATH 탐색 안 함)
22
22
  set -u
23
23
 
24
+ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
25
+ printf 'ContextGuard helper: context-guard-statusline-merged\n'
26
+ exit 0
27
+ fi
28
+
24
29
  statusline_input_tmp=''
25
30
 
26
31
  statusline_tmp_base() {
@@ -23,15 +23,26 @@ from typing import Any, NoReturn
23
23
 
24
24
  TOOL_NAME = "context-guard-tool-prune"
25
25
  SCHEMA_VERSION = "contextguard.tool-prune.v1"
26
+ DEFER_SCHEMA_VERSION = "contextguard.tool-prune.defer.v1"
26
27
  DEFAULT_STORE_DIR = ".context-guard/tool-prune"
27
28
  DEFAULT_TOP = 5
29
+ DEFAULT_CORE_TOP = 3
30
+ DEFAULT_DEFERRED_TOP = 20
31
+ DEFAULT_NAMESPACE_TOP = 20
28
32
  DEFAULT_BUDGET_BYTES = 12_000
29
33
  DEFAULT_MAX_CATALOG_BYTES = 1_000_000
30
34
  DEFAULT_MAX_OUTPUT_BYTES = 65_536
31
35
  DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576
32
36
  DEFAULT_MAX_RECEIPT_BYTES = 16_384
33
37
  MAX_TOP = 200
38
+ MAX_DEFERRED_TOP = 1_000
39
+ MAX_NAMESPACE_TOP = 200
34
40
  MAX_LABEL_CHARS = 160
41
+ NO_FOLLOW_SUPPORTED = hasattr(os, "O_NOFOLLOW")
42
+ DIR_FD_OPEN_SUPPORTED = bool(os.supports_dir_fd and os.open in os.supports_dir_fd)
43
+ DIR_FD_MKDIR_SUPPORTED = bool(os.supports_dir_fd and os.mkdir in os.supports_dir_fd)
44
+ DIR_FD_STAT_SUPPORTED = bool(os.supports_dir_fd and os.stat in os.supports_dir_fd)
45
+ DIR_FD_UNLINK_SUPPORTED = bool(os.supports_dir_fd and os.unlink in os.supports_dir_fd)
35
46
  MAX_DESCRIPTION_CHARS = 360
36
47
  MAX_OMITTED_TOOLS = 30
37
48
  TOKEN_PROXY_CHARS_PER_TOKEN = 4
@@ -94,13 +105,17 @@ def byte_len_json(data: Any) -> int:
94
105
  return byte_len_text(json_bytes(data))
95
106
 
96
107
 
108
+ def proxy_tokens(chars: int) -> int:
109
+ return max(0, (int(chars) + TOKEN_PROXY_CHARS_PER_TOKEN - 1) // TOKEN_PROXY_CHARS_PER_TOKEN)
110
+
111
+
97
112
  def sha256_text(text: str) -> str:
98
113
  return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
99
114
 
100
115
 
101
116
  def bounded_int(value: object, *, default: int, minimum: int, maximum: int, name: str) -> int:
102
117
  try:
103
- number = int(value)
118
+ number = int(default if value is None else value)
104
119
  except (TypeError, ValueError, OverflowError):
105
120
  fail(f"{name} must be an integer")
106
121
  if number < minimum:
@@ -191,12 +206,26 @@ def sanitize_value(value: Any, *, sensitive_context: bool = False, sensitive_sch
191
206
 
192
207
 
193
208
  def read_limited_path(path: Path, max_bytes: int) -> str:
209
+ if not NO_FOLLOW_SUPPORTED:
210
+ fail("catalog reads require O_NOFOLLOW support")
211
+ reject_parent_traversal(path, label="catalog")
212
+ # Preserve clear diagnostics for stable symlink paths, then anchor the real
213
+ # read to an opened no-follow parent fd so parent/leaf swaps after this
214
+ # precheck still fail closed.
194
215
  reject_symlink_components(path)
195
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
216
+ parent_fd = open_private_directory_no_follow(path.parent, label="catalog directory", create=False)
217
+ flags = os.O_RDONLY | os.O_NOFOLLOW
218
+ if hasattr(os, "O_CLOEXEC"):
219
+ flags |= os.O_CLOEXEC
220
+ leaf = path.name
221
+ if leaf in {"", ".", ".."}:
222
+ os.close(parent_fd)
223
+ fail("catalog must name a regular file")
196
224
  try:
197
- fd = os.open(str(path), flags)
225
+ fd = os.open(leaf, flags, dir_fd=parent_fd)
198
226
  except OSError as exc:
199
- fail(f"catalog read failed: {exc}")
227
+ os.close(parent_fd)
228
+ fail(f"catalog read failed: {os_error_detail(exc)}")
200
229
  try:
201
230
  st = os.fstat(fd)
202
231
  if not stat.S_ISREG(st.st_mode):
@@ -206,6 +235,7 @@ def read_limited_path(path: Path, max_bytes: int) -> str:
206
235
  data = os.read(fd, max_bytes + 1)
207
236
  finally:
208
237
  os.close(fd)
238
+ os.close(parent_fd)
209
239
  if len(data) > max_bytes:
210
240
  fail(f"catalog exceeds --max-catalog-bytes: > {max_bytes}")
211
241
  return data.decode("utf-8", errors="replace")
@@ -399,15 +429,142 @@ def reject_symlink_components(path: Path) -> None:
399
429
  fail(f"refusing path through non-directory component: {current}")
400
430
 
401
431
 
432
+ def dir_fd_replace_supported() -> bool:
433
+ try:
434
+ import inspect
435
+
436
+ signature = inspect.signature(os.replace)
437
+ except (TypeError, ValueError):
438
+ return True
439
+ return "src_dir_fd" in signature.parameters and "dst_dir_fd" in signature.parameters
440
+
441
+
442
+ DIR_FD_REPLACE_SUPPORTED = dir_fd_replace_supported()
443
+
444
+
445
+ def reject_parent_traversal(path: Path, *, label: str) -> None:
446
+ if ".." in path.parts:
447
+ fail(f"{label} must not contain parent traversal")
448
+
449
+
450
+ def os_error_detail(exc: OSError) -> str:
451
+ detail = exc.strerror or str(exc) or exc.__class__.__name__
452
+ if exc.errno is not None:
453
+ return f"{detail} (errno {exc.errno})"
454
+ return detail
455
+
456
+
457
+ def no_follow_dir_flags() -> int:
458
+ if not NO_FOLLOW_SUPPORTED:
459
+ fail("private store IO requires O_NOFOLLOW support")
460
+ flags = os.O_RDONLY | os.O_NOFOLLOW
461
+ if hasattr(os, "O_CLOEXEC"):
462
+ flags |= os.O_CLOEXEC
463
+ if hasattr(os, "O_DIRECTORY"):
464
+ flags |= os.O_DIRECTORY
465
+ return flags
466
+
467
+
468
+ def private_temp_file_flags() -> int:
469
+ if not NO_FOLLOW_SUPPORTED:
470
+ fail("private store IO requires O_NOFOLLOW support")
471
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
472
+ if hasattr(os, "O_CLOEXEC"):
473
+ flags |= os.O_CLOEXEC
474
+ if hasattr(os, "O_NOCTTY"):
475
+ flags |= os.O_NOCTTY
476
+ return flags
477
+
478
+
479
+ def open_private_directory_no_follow(path: Path, *, label: str, create: bool) -> int:
480
+ reject_parent_traversal(path, label=label)
481
+ path = normalize_allowed_first_absolute_symlink(path.expanduser())
482
+ if not DIR_FD_OPEN_SUPPORTED:
483
+ fail(f"{label} requires dir_fd open support")
484
+ if create and not DIR_FD_MKDIR_SUPPORTED:
485
+ fail(f"{label} requires dir_fd mkdir support")
486
+ flags = no_follow_dir_flags()
487
+ if path.is_absolute():
488
+ root_flags = os.O_RDONLY | (os.O_CLOEXEC if hasattr(os, "O_CLOEXEC") else 0)
489
+ current_fd = os.open(path.anchor or os.sep, root_flags)
490
+ parts = path.parts[1:]
491
+ else:
492
+ current_fd = os.open(".", flags)
493
+ parts = path.parts
494
+ try:
495
+ for part in parts:
496
+ if part in {"", "."}:
497
+ continue
498
+ if part == "..":
499
+ fail(f"{label} must not contain parent traversal")
500
+ try:
501
+ next_fd = os.open(part, flags, dir_fd=current_fd)
502
+ except FileNotFoundError:
503
+ if not create:
504
+ raise
505
+ os.mkdir(part, 0o700, dir_fd=current_fd)
506
+ next_fd = os.open(part, flags, dir_fd=current_fd)
507
+ try:
508
+ if not stat.S_ISDIR(os.fstat(next_fd).st_mode):
509
+ fail(f"{label} must not traverse non-directory components")
510
+ except Exception:
511
+ os.close(next_fd)
512
+ raise
513
+ os.close(current_fd)
514
+ current_fd = next_fd
515
+ owned_fd = current_fd
516
+ current_fd = -1
517
+ return owned_fd
518
+ except OSError as exc:
519
+ fail(f"could not inspect {label}: {os_error_detail(exc)}")
520
+ finally:
521
+ if current_fd >= 0:
522
+ os.close(current_fd)
523
+
524
+
525
+ def precheck_private_leaf(parent_fd: int, leaf: str, *, label: str) -> None:
526
+ if not DIR_FD_STAT_SUPPORTED:
527
+ fail(f"{label} requires dir_fd stat support")
528
+ try:
529
+ st = os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False)
530
+ except FileNotFoundError:
531
+ return
532
+ except OSError as exc:
533
+ fail(f"could not inspect {label}: {os_error_detail(exc)}")
534
+ if not stat.S_ISREG(st.st_mode):
535
+ fail(f"{label} must be missing or a regular file")
536
+
537
+
538
+ def write_all_fd(fd: int, data: bytes) -> None:
539
+ view = memoryview(data)
540
+ offset = 0
541
+ while offset < len(view):
542
+ written = os.write(fd, view[offset:])
543
+ if written <= 0:
544
+ raise OSError("short write")
545
+ offset += written
546
+
547
+
548
+ def fsync_best_effort(fd: int) -> None:
549
+ try:
550
+ os.fsync(fd)
551
+ except OSError:
552
+ pass
553
+
554
+
402
555
  def ensure_private_dir(path: Path) -> None:
403
- path = normalize_allowed_first_absolute_symlink(path)
404
- reject_symlink_components(path)
556
+ reject_parent_traversal(path, label="store directory")
405
557
  try:
406
- path.mkdir(parents=True, exist_ok=True)
407
- reject_symlink_components(path)
408
- os.chmod(path, 0o700)
558
+ fd = open_private_directory_no_follow(path, label="store directory", create=True)
409
559
  except OSError as exc:
410
560
  fail(f"store directory unavailable: {exc}")
561
+ try:
562
+ try:
563
+ os.fchmod(fd, 0o700)
564
+ except OSError:
565
+ pass
566
+ finally:
567
+ os.close(fd)
411
568
 
412
569
 
413
570
  def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: int, label: str) -> int:
@@ -415,43 +572,77 @@ def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: in
415
572
  size = byte_len_text(text)
416
573
  if size > max_bytes:
417
574
  fail(f"{label} exceeds size cap: {size} > {max_bytes}")
418
- ensure_private_dir(path.parent)
419
- tmp = path.with_name(path.name + f".tmp-{os.getpid()}-{time.time_ns()}")
420
- flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
575
+ reject_parent_traversal(path, label=label)
576
+ if not DIR_FD_REPLACE_SUPPORTED:
577
+ fail(f"{label} write requires dir_fd replace support")
578
+ if not DIR_FD_UNLINK_SUPPORTED:
579
+ fail(f"{label} write requires dir_fd unlink support")
580
+ if not DIR_FD_STAT_SUPPORTED:
581
+ fail(f"{label} write requires dir_fd stat support")
582
+ parent_fd = open_private_directory_no_follow(path.parent, label="store directory", create=True)
583
+ fd = -1
584
+ temp_leaf: str | None = None
421
585
  try:
422
- fd = os.open(str(tmp), flags, 0o600)
423
- except OSError as exc:
424
- fail(f"{label} write failed: {exc}")
425
- try:
426
- with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
427
- handle.write(text)
428
- handle.flush()
429
- try:
430
- os.fsync(handle.fileno())
431
- except OSError:
432
- pass
433
- os.replace(tmp, path)
434
586
  try:
435
- os.chmod(path, 0o600)
587
+ os.fchmod(parent_fd, 0o700)
436
588
  except OSError:
437
589
  pass
590
+ leaf = path.name
591
+ if leaf in {"", ".", ".."}:
592
+ fail(f"{label} must name a regular file")
593
+ precheck_private_leaf(parent_fd, leaf, label=label)
594
+ for _attempt in range(20):
595
+ candidate = f".{leaf}.{os.getpid()}.{time.time_ns()}.tmp"
596
+ try:
597
+ fd = os.open(candidate, private_temp_file_flags(), 0o600, dir_fd=parent_fd)
598
+ temp_leaf = candidate
599
+ break
600
+ except FileExistsError:
601
+ continue
602
+ if fd < 0 or temp_leaf is None:
603
+ fail(f"{label} write failed: could not create temporary file")
604
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
605
+ fail(f"{label} temporary file must be a regular file")
606
+ os.fchmod(fd, 0o600)
607
+ write_all_fd(fd, text.encode("utf-8"))
608
+ fsync_best_effort(fd)
609
+ os.close(fd)
610
+ fd = -1
611
+ fsync_best_effort(parent_fd)
612
+ os.replace(temp_leaf, leaf, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
613
+ temp_leaf = None
614
+ fsync_best_effort(parent_fd)
615
+ except OSError as exc:
616
+ fail(f"{label} write failed: {os_error_detail(exc)}")
438
617
  except Exception:
439
- try:
440
- tmp.unlink()
441
- except OSError:
442
- pass
443
618
  raise
619
+ finally:
620
+ if fd >= 0:
621
+ os.close(fd)
622
+ if temp_leaf is not None:
623
+ try:
624
+ os.unlink(temp_leaf, dir_fd=parent_fd)
625
+ except OSError:
626
+ pass
627
+ os.close(parent_fd)
444
628
  return size
445
629
 
446
630
 
447
631
  def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, int]:
448
- if path.is_symlink():
449
- fail(f"{label} must not be a symlink")
450
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
632
+ reject_parent_traversal(path, label=label)
633
+ parent_fd = open_private_directory_no_follow(path.parent, label=f"{label} directory", create=False)
634
+ flags = os.O_RDONLY | os.O_NOFOLLOW
635
+ if hasattr(os, "O_CLOEXEC"):
636
+ flags |= os.O_CLOEXEC
637
+ leaf = path.name
638
+ if leaf in {"", ".", ".."}:
639
+ os.close(parent_fd)
640
+ fail(f"{label} must name a regular file")
451
641
  try:
452
- fd = os.open(str(path), flags)
642
+ fd = os.open(leaf, flags, dir_fd=parent_fd)
453
643
  except OSError as exc:
454
- fail(f"{label} read failed: {exc}")
644
+ os.close(parent_fd)
645
+ fail(f"{label} read failed: {os_error_detail(exc)}")
455
646
  try:
456
647
  st = os.fstat(fd)
457
648
  if not stat.S_ISREG(st.st_mode):
@@ -461,32 +652,16 @@ def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, i
461
652
  data = os.read(fd, max_bytes + 1)
462
653
  finally:
463
654
  os.close(fd)
655
+ os.close(parent_fd)
464
656
  if len(data) > max_bytes:
465
657
  fail(f"{label} exceeds trusted size cap: > {max_bytes}")
466
658
  return data.decode("utf-8", errors="replace"), len(data)
467
659
 
468
660
 
469
661
  def read_private_json(path: Path, *, max_bytes: int, label: str) -> dict[str, Any]:
470
- if path.is_symlink():
471
- fail(f"{label} must not be a symlink")
472
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
662
+ text, _size = read_private_text(path, max_bytes=max_bytes, label=label)
473
663
  try:
474
- fd = os.open(str(path), flags)
475
- except OSError as exc:
476
- fail(f"{label} read failed: {exc}")
477
- try:
478
- st = os.fstat(fd)
479
- if not stat.S_ISREG(st.st_mode):
480
- fail(f"{label} must be a regular file")
481
- if st.st_size > max_bytes:
482
- fail(f"{label} exceeds trusted size cap: {st.st_size} > {max_bytes}")
483
- data = os.read(fd, max_bytes + 1)
484
- finally:
485
- os.close(fd)
486
- if len(data) > max_bytes:
487
- fail(f"{label} exceeds trusted size cap: > {max_bytes}")
488
- try:
489
- parsed = json.loads(data.decode("utf-8", errors="replace"))
664
+ parsed = json.loads(text)
490
665
  except json.JSONDecodeError as exc:
491
666
  fail(f"{label} is malformed JSON: {exc.msg}")
492
667
  if not isinstance(parsed, dict):
@@ -583,6 +758,86 @@ def selected_tool_record(cand: Candidate, receipt_id: str, budget_left: int, *,
583
758
  return record, 0
584
759
 
585
760
 
761
+ def deferred_tool_record(cand: Candidate, receipt_id: str, *, store_dir: str) -> dict[str, Any]:
762
+ return {
763
+ "name": cand.name,
764
+ "server": cand.server,
765
+ "score": cand.score,
766
+ "rank": cand.rank,
767
+ "description": cand.description,
768
+ "schema_bytes": byte_len_json(cand.schema),
769
+ "reason": "deferred_after_core_top",
770
+ "retrieval": retrieval_command(receipt_id, store_dir=store_dir, tool_name=cand.name),
771
+ }
772
+
773
+
774
+ def namespace_records(
775
+ ranked: list[Candidate],
776
+ core_names: set[str],
777
+ deferred_names: set[str],
778
+ receipt_id: str,
779
+ *,
780
+ store_dir: str,
781
+ namespace_top: int,
782
+ ) -> tuple[list[dict[str, Any]], int]:
783
+ grouped: dict[str, dict[str, Any]] = {}
784
+ for cand in ranked:
785
+ namespace = cand.server or "local"
786
+ item = grouped.setdefault(
787
+ namespace,
788
+ {
789
+ "namespace": namespace,
790
+ "tool_count": 0,
791
+ "core_count": 0,
792
+ "listed_deferred_count": 0,
793
+ "sample_tools": [],
794
+ "retrieval": retrieval_command(receipt_id, store_dir=store_dir),
795
+ },
796
+ )
797
+ item["tool_count"] += 1
798
+ if cand.name in core_names:
799
+ item["core_count"] += 1
800
+ if cand.name in deferred_names:
801
+ item["listed_deferred_count"] += 1
802
+ samples = item["sample_tools"]
803
+ if isinstance(samples, list) and len(samples) < 8:
804
+ samples.append(cand.name)
805
+ records = sorted(grouped.values(), key=lambda item: (-int(item["listed_deferred_count"]), str(item["namespace"])))
806
+ return records[:namespace_top], max(0, len(records) - namespace_top)
807
+
808
+
809
+ def build_receipt_and_payload(ranked: list[Candidate], safe_query: str, total_redactions: int, *, store_dir_arg: str, max_payload_bytes: int, max_receipt_bytes: int) -> tuple[str, dict[str, Any], dict[str, Any], Path, Path, Path, int, int]:
810
+ payload_without_id = build_payload("pending", ranked, safe_query, total_redactions)
811
+ receipt_id = build_receipt_id(payload_without_id)
812
+ payload = build_payload(receipt_id, ranked, safe_query, total_redactions)
813
+ payload_text = json_bytes(payload, indent=2) + "\n"
814
+ payload_bytes = byte_len_text(payload_text)
815
+ if payload_bytes > max_payload_bytes:
816
+ fail(f"payload exceeds --max-payload-bytes: {payload_bytes} > {max_payload_bytes}")
817
+ payload_sha = sha256_text(payload_text.rstrip("\n"))
818
+
819
+ store_dir, receipt_path, payload_path = store_paths(store_dir_arg, receipt_id)
820
+ receipt = {
821
+ "tool": TOOL_NAME,
822
+ "schema_version": SCHEMA_VERSION,
823
+ "receipt_id": receipt_id,
824
+ "created_at_unix": int(time.time()),
825
+ "path": display_path(receipt_path),
826
+ "payload_path": display_path(payload_path),
827
+ "payload_sha256": payload_sha,
828
+ "payload_bytes": payload_bytes,
829
+ "contains": "compact_metadata_plus_sanitized_payload",
830
+ "tool_count": len(ranked),
831
+ "tools": [cand.name for cand in ranked[:50]],
832
+ "tools_truncated": len(ranked) > 50,
833
+ "retrieval_hint": retrieval_command(receipt_id, store_dir=store_dir_arg, tool_name="<name>"),
834
+ }
835
+ receipt_size = byte_len_text(json_bytes(receipt, indent=2) + "\n")
836
+ if receipt_size > max_receipt_bytes:
837
+ fail(f"receipt exceeds --max-receipt-bytes: {receipt_size} > {max_receipt_bytes}")
838
+ return receipt_id, payload, receipt, store_dir, receipt_path, payload_path, payload_bytes, receipt_size
839
+
840
+
586
841
  def shrink_result_for_output(result: dict[str, Any], max_output_bytes: int) -> str:
587
842
  candidate = json_bytes(result, indent=2) + "\n"
588
843
  if byte_len_text(candidate) <= max_output_bytes:
@@ -591,6 +846,7 @@ def shrink_result_for_output(result: dict[str, Any], max_output_bytes: int) -> s
591
846
  result = json.loads(json_bytes(result))
592
847
  omitted = result.get("omitted_tools")
593
848
  while isinstance(omitted, list) and len(omitted) > 0:
849
+ # The list is halved on each pass, so even a one-item list converges.
594
850
  keep = max(0, len(omitted) // 2)
595
851
  result["omitted_tools"] = omitted[:keep]
596
852
  result["omitted_tools_truncated"] = True
@@ -699,6 +955,160 @@ def select_catalog(args: argparse.Namespace) -> str:
699
955
  return rendered
700
956
 
701
957
 
958
+ def defer_report(args: argparse.Namespace) -> str:
959
+ max_catalog_bytes = bounded_int(args.max_catalog_bytes, default=DEFAULT_MAX_CATALOG_BYTES, minimum=1, maximum=100_000_000, name="--max-catalog-bytes")
960
+ max_output_bytes = bounded_int(args.max_output_bytes, default=DEFAULT_MAX_OUTPUT_BYTES, minimum=1, maximum=10_000_000, name="--max-output-bytes")
961
+ max_payload_bytes = bounded_int(args.max_payload_bytes, default=DEFAULT_MAX_PAYLOAD_BYTES, minimum=1, maximum=100_000_000, name="--max-payload-bytes")
962
+ max_receipt_bytes = bounded_int(args.max_receipt_bytes, default=DEFAULT_MAX_RECEIPT_BYTES, minimum=1, maximum=10_000_000, name="--max-receipt-bytes")
963
+ core_top = bounded_int(args.core_top, default=DEFAULT_CORE_TOP, minimum=1, maximum=MAX_TOP, name="--core-top")
964
+ deferred_top = bounded_int(args.deferred_top, default=DEFAULT_DEFERRED_TOP, minimum=0, maximum=MAX_DEFERRED_TOP, name="--deferred-top")
965
+ namespace_top = bounded_int(args.namespace_top, default=DEFAULT_NAMESPACE_TOP, minimum=0, maximum=MAX_NAMESPACE_TOP, name="--namespace-top")
966
+ budget_bytes = bounded_int(args.budget_bytes, default=DEFAULT_BUDGET_BYTES, minimum=0, maximum=100_000_000, name="--budget-bytes")
967
+
968
+ text = read_limited_path(Path(args.catalog), max_catalog_bytes) if args.catalog else read_limited_stdin(max_catalog_bytes)
969
+ raw, redactions = parse_catalog_text(text)
970
+ raw_query = args.query or ""
971
+ safe_query, query_redactions = redact_string(raw_query)
972
+ total_redactions = redactions + query_redactions
973
+ ranked = rank_candidates(normalize_catalog(raw), raw_query)
974
+ (
975
+ receipt_id,
976
+ payload,
977
+ receipt,
978
+ store_dir,
979
+ receipt_path,
980
+ payload_path,
981
+ payload_bytes,
982
+ receipt_size,
983
+ ) = build_receipt_and_payload(
984
+ ranked,
985
+ safe_query,
986
+ total_redactions,
987
+ store_dir_arg=args.store_dir,
988
+ max_payload_bytes=max_payload_bytes,
989
+ max_receipt_bytes=max_receipt_bytes,
990
+ )
991
+
992
+ core_candidates = ranked[:core_top]
993
+ deferred_candidates = ranked[core_top:core_top + deferred_top]
994
+ core_tools: list[dict[str, Any]] = []
995
+ core_schema_bytes = 0
996
+ for cand in core_candidates:
997
+ record, used = selected_tool_record(cand, receipt_id, budget_bytes - core_schema_bytes, store_dir=args.store_dir)
998
+ core_schema_bytes += used
999
+ core_tools.append(record)
1000
+ deferred_tools = [deferred_tool_record(cand, receipt_id, store_dir=args.store_dir) for cand in deferred_candidates]
1001
+ core_names = {cand.name for cand in core_candidates}
1002
+ deferred_names = {cand.name for cand in deferred_candidates}
1003
+ deferred_namespaces, deferred_namespaces_truncated_count = namespace_records(
1004
+ ranked,
1005
+ core_names,
1006
+ deferred_names,
1007
+ receipt_id,
1008
+ store_dir=args.store_dir,
1009
+ namespace_top=namespace_top,
1010
+ )
1011
+ all_schema_bytes = sum(byte_len_json(cand.schema) for cand in ranked)
1012
+ listed_deferred_schema_bytes = sum(byte_len_json(cand.schema) for cand in deferred_candidates)
1013
+ total_deferred_schema_bytes = sum(byte_len_json(cand.schema) for cand in ranked[core_top:])
1014
+ tool_stub_report_bytes = byte_len_json(core_tools) + byte_len_json(deferred_tools)
1015
+ all_schema_tokens = proxy_tokens(all_schema_bytes)
1016
+ inline_core_schema_tokens = proxy_tokens(core_schema_bytes)
1017
+ listed_deferred_schema_tokens = proxy_tokens(listed_deferred_schema_bytes)
1018
+ total_deferred_schema_tokens = proxy_tokens(total_deferred_schema_bytes)
1019
+ tool_stub_report_tokens = proxy_tokens(tool_stub_report_bytes)
1020
+ result = {
1021
+ "tool": TOOL_NAME,
1022
+ "schema_version": DEFER_SCHEMA_VERSION,
1023
+ "mode": "defer-report",
1024
+ "query": safe_query,
1025
+ "core_top": core_top,
1026
+ "deferred_top": deferred_top,
1027
+ "namespace_top": namespace_top,
1028
+ "candidate_count": len(ranked),
1029
+ "native_provider_integration": False,
1030
+ "core_tools": core_tools,
1031
+ "deferred_tools": deferred_tools,
1032
+ "listed_deferred_count": len(deferred_tools),
1033
+ "total_deferred_count": max(0, len(ranked) - core_top),
1034
+ "deferred_tools_truncated_count": max(0, len(ranked) - core_top - len(deferred_tools)),
1035
+ "deferred_namespaces": deferred_namespaces,
1036
+ "deferred_namespaces_truncated_count": deferred_namespaces_truncated_count,
1037
+ "deferred_schema_retrieval_required_before_use": True,
1038
+ "receipt": {
1039
+ **receipt,
1040
+ "bytes": receipt_size,
1041
+ },
1042
+ "token_proxy": {
1043
+ "measurement": "estimated",
1044
+ "method": "char4_proxy",
1045
+ "chars_per_token": TOKEN_PROXY_CHARS_PER_TOKEN,
1046
+ "all_schema_bytes": all_schema_bytes,
1047
+ "inline_core_schema_bytes": core_schema_bytes,
1048
+ "listed_deferred_schema_bytes": listed_deferred_schema_bytes,
1049
+ "total_deferred_schema_bytes": total_deferred_schema_bytes,
1050
+ "tool_stub_report_bytes": tool_stub_report_bytes,
1051
+ "all_schema_tokens_estimated": all_schema_tokens,
1052
+ "inline_core_schema_tokens_estimated": inline_core_schema_tokens,
1053
+ "listed_deferred_schema_tokens_estimated": listed_deferred_schema_tokens,
1054
+ "total_deferred_schema_tokens_estimated": total_deferred_schema_tokens,
1055
+ "tool_stub_report_tokens_estimated": tool_stub_report_tokens,
1056
+ "gross_listed_deferred_schema_tokens_avoided": listed_deferred_schema_tokens,
1057
+ "gross_total_deferred_schema_tokens_avoided": total_deferred_schema_tokens,
1058
+ "net_initial_report_tokens_delta": tool_stub_report_tokens - all_schema_tokens,
1059
+ "net_initial_report_tokens_delta_semantics": "tool_stub_report_tokens_estimated_minus_all_schema_tokens_estimated",
1060
+ "estimated_initial_schema_tokens_avoided": max(0, all_schema_tokens - tool_stub_report_tokens),
1061
+ "estimated_initial_schema_tokens_avoided_semantics": "max(0, all_schema_tokens_estimated - tool_stub_report_tokens_estimated)",
1062
+ "claim_boundary": "proxy_only_not_provider_billed_tokens",
1063
+ },
1064
+ "provider_patterns": [
1065
+ {
1066
+ "provider": "openai",
1067
+ "pattern": "Keep only core tool schemas inline; retrieve deferred schemas through app/tool-search plumbing or the local receipt before invoking a deferred tool.",
1068
+ "native_provider_integration": False,
1069
+ },
1070
+ {
1071
+ "provider": "anthropic",
1072
+ "pattern": "Keep stable, frequently used tool definitions in the cacheable prefix; treat deferred tools as application-managed retrieval, not Claude-native lazy loading.",
1073
+ "native_provider_integration": False,
1074
+ },
1075
+ {
1076
+ "provider": "gemini",
1077
+ "pattern": "Group large tool catalogs by namespace and load only the task-relevant subset before the model call; verify any platform-native tool retrieval separately.",
1078
+ "native_provider_integration": False,
1079
+ },
1080
+ ],
1081
+ "claim_boundary": {
1082
+ "advisory_only": True,
1083
+ "native_provider_integration": False,
1084
+ "provider_tool_search_configured": False,
1085
+ "hosted_api_token_or_cost_savings_claim_allowed": False,
1086
+ "requires_provider_measured_matched_tasks_for_savings_claims": True,
1087
+ "deferred_schema_retrieval_required_before_use": True,
1088
+ },
1089
+ "redaction": {"redacted_values": total_redactions},
1090
+ "caveats": [
1091
+ "Deferred loading is an application strategy report, not a native provider integration.",
1092
+ "Token proxy values are char/4 estimates over sanitized local JSON, not billed provider tokens.",
1093
+ "Deferred schema token fields are initial-prompt proxy accounting; full schemas must be retrieved before deferred tool use.",
1094
+ "Use receipt get commands to retrieve full sanitized schemas before using deferred tools.",
1095
+ ],
1096
+ }
1097
+ rendered = json_bytes(result, indent=2) + "\n"
1098
+ if byte_len_text(rendered) > max_output_bytes:
1099
+ fail(f"defer report exceeds --max-output-bytes: {byte_len_text(rendered)} > {max_output_bytes}")
1100
+
1101
+ # Only write after every size gate has passed, so failures leave no success receipt.
1102
+ ensure_private_dir(store_dir)
1103
+ written_payload_bytes = write_private_json_atomic(payload_path, payload, max_bytes=max_payload_bytes, label="payload")
1104
+ if written_payload_bytes != payload_bytes:
1105
+ fail("payload byte size changed during write")
1106
+ written_receipt_bytes = write_private_json_atomic(receipt_path, receipt, max_bytes=max_receipt_bytes, label="receipt")
1107
+ if written_receipt_bytes != receipt_size:
1108
+ fail("receipt byte size changed during write")
1109
+ return rendered
1110
+
1111
+
702
1112
  def payload_path_from_receipt(store_dir: Path, receipt_id: str, receipt: dict[str, Any]) -> Path:
703
1113
  expected_name = f"{receipt_id}.payload.json"
704
1114
  raw = str(receipt.get("payload_path") or "")
@@ -803,6 +1213,20 @@ def build_parser() -> argparse.ArgumentParser:
803
1213
  select.add_argument("--store-dir", default=DEFAULT_STORE_DIR, help=f"receipt/payload directory (default: {DEFAULT_STORE_DIR})")
804
1214
  select.add_argument("--json", action="store_true", help="emit JSON (default and only stable output contract)")
805
1215
 
1216
+ defer = sub.add_parser("defer-report", help="split a local catalog into core inline tools plus deferred receipt-backed tools")
1217
+ defer.add_argument("--catalog", help="catalog JSON path; stdin is used when omitted")
1218
+ defer.add_argument("--query", default="", help="task query used for lexical ranking")
1219
+ defer.add_argument("--core-top", default=DEFAULT_CORE_TOP, help=f"number of core inline tools (default: {DEFAULT_CORE_TOP})")
1220
+ defer.add_argument("--deferred-top", default=DEFAULT_DEFERRED_TOP, help=f"number of deferred tool stubs to list (default: {DEFAULT_DEFERRED_TOP})")
1221
+ defer.add_argument("--namespace-top", default=DEFAULT_NAMESPACE_TOP, help=f"number of deferred namespace summaries to list (default: {DEFAULT_NAMESPACE_TOP})")
1222
+ defer.add_argument("--budget-bytes", default=DEFAULT_BUDGET_BYTES, help=f"inline core schema byte budget (default: {DEFAULT_BUDGET_BYTES})")
1223
+ defer.add_argument("--max-catalog-bytes", default=DEFAULT_MAX_CATALOG_BYTES, help=f"maximum catalog JSON bytes (default: {DEFAULT_MAX_CATALOG_BYTES})")
1224
+ defer.add_argument("--max-output-bytes", default=DEFAULT_MAX_OUTPUT_BYTES, help=f"maximum rendered defer JSON bytes (default: {DEFAULT_MAX_OUTPUT_BYTES})")
1225
+ defer.add_argument("--max-payload-bytes", default=DEFAULT_MAX_PAYLOAD_BYTES, help=f"maximum sanitized payload bytes (default: {DEFAULT_MAX_PAYLOAD_BYTES})")
1226
+ defer.add_argument("--max-receipt-bytes", default=DEFAULT_MAX_RECEIPT_BYTES, help=f"maximum compact receipt bytes (default: {DEFAULT_MAX_RECEIPT_BYTES})")
1227
+ defer.add_argument("--store-dir", default=DEFAULT_STORE_DIR, help=f"receipt/payload directory (default: {DEFAULT_STORE_DIR})")
1228
+ defer.add_argument("--json", action="store_true", help="emit JSON (default and only stable output contract)")
1229
+
806
1230
  get = sub.add_parser("get", help="retrieve a full sanitized schema from a receipt payload")
807
1231
  get.add_argument("receipt_id", help="receipt id returned by select")
808
1232
  get.add_argument("--tool", help="tool name to retrieve; omit to list available names")
@@ -821,6 +1245,9 @@ def main(argv: list[str] | None = None) -> int:
821
1245
  if args.command == "select":
822
1246
  sys.stdout.write(select_catalog(args))
823
1247
  return 0
1248
+ if args.command == "defer-report":
1249
+ sys.stdout.write(defer_report(args))
1250
+ return 0
824
1251
  if args.command == "get":
825
1252
  sys.stdout.write(get_schema(args))
826
1253
  return 0