cc-transcript 14.16.0__tar.gz → 14.17.0__tar.gz

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 (162) hide show
  1. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/PKG-INFO +1 -1
  2. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/__init__.py +1 -0
  3. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/__init__.pyi +2 -0
  4. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/query.py +276 -53
  5. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/pyproject.toml +1 -1
  6. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/Cargo.lock +0 -0
  7. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/Cargo.toml +0 -0
  8. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/LICENSE +0 -0
  9. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/README.md +0 -0
  10. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/_native.pyi +0 -0
  11. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/activity.py +0 -0
  12. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/activity_probe.py +0 -0
  13. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/builders.py +0 -0
  14. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/codex.py +0 -0
  15. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/command.py +0 -0
  16. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/context.py +0 -0
  17. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/corrections.py +0 -0
  18. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/cost.py +0 -0
  19. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/decision_store.py +0 -0
  20. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/decisions.py +0 -0
  21. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/discovery.py +0 -0
  22. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/disktruth.py +0 -0
  23. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/evidence.py +0 -0
  24. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/extract/__init__.py +0 -0
  25. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/extract/correct.py +0 -0
  26. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/facts.py +0 -0
  27. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/filterspec.py +0 -0
  28. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/heartbeats.py +0 -0
  29. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/ids.py +0 -0
  30. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/judge/__init__.py +0 -0
  31. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/judge/llm.py +0 -0
  32. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/judge/similar.py +0 -0
  33. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/judge/verdicts.py +0 -0
  34. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/ledger.py +0 -0
  35. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/literals.py +0 -0
  36. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/__init__.py +0 -0
  37. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/candidates.py +0 -0
  38. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/confidence.py +0 -0
  39. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/engine.py +0 -0
  40. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/formats.py +0 -0
  41. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/sampling.py +0 -0
  42. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/signals.py +0 -0
  43. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/sourcekind.py +0 -0
  44. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/spec.py +0 -0
  45. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/mining/store.py +0 -0
  46. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/models.py +0 -0
  47. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/nlp.py +0 -0
  48. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/notifications.py +0 -0
  49. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/parser.py +0 -0
  50. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/py.typed +0 -0
  51. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/render.py +0 -0
  52. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/sentiment/__init__.py +0 -0
  53. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/sentiment/buckets.py +0 -0
  54. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/sentiment/data/afinn-en-165.tsv +0 -0
  55. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/sentiment/data/domain_overrides.tsv +0 -0
  56. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/sentiment/data/en-ewt.udpipe +0 -0
  57. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/sentiment/engine.py +0 -0
  58. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/sentiment/lexicon.py +0 -0
  59. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/sentiment/scorespec.py +0 -0
  60. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/synthetic.py +0 -0
  61. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/tools.py +0 -0
  62. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/cc_transcript/watch.py +0 -0
  63. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/Cargo.toml +0 -0
  64. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/blame.rs +0 -0
  65. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/corrections.rs +0 -0
  66. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/digest.rs +0 -0
  67. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/facts.rs +0 -0
  68. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/grep.rs +0 -0
  69. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/list.rs +0 -0
  70. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/mod.rs +0 -0
  71. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/scratchpad.rs +0 -0
  72. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/show.rs +0 -0
  73. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/slice.rs +0 -0
  74. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/stats.rs +0 -0
  75. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/commands/watch.rs +0 -0
  76. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/lib.rs +0 -0
  77. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/main.rs +0 -0
  78. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/output.rs +0 -0
  79. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/target.rs +0 -0
  80. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/cli/src/timearg.rs +0 -0
  81. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/Cargo.toml +0 -0
  82. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/benches/parse.rs +0 -0
  83. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/benches/render.rs +0 -0
  84. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/benches/toolcall.rs +0 -0
  85. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/activity.rs +0 -0
  86. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/actor.rs +0 -0
  87. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/blame.rs +0 -0
  88. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/buckets.rs +0 -0
  89. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/codex/discovery.rs +0 -0
  90. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/codex/lower.rs +0 -0
  91. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/codex/mod.rs +0 -0
  92. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/codex/parse.rs +0 -0
  93. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/codex/probe.rs +0 -0
  94. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/codex/protocol.rs +0 -0
  95. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/codex/types.rs +0 -0
  96. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/command.rs +0 -0
  97. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/context.rs +0 -0
  98. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/corrections.rs +0 -0
  99. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/cost.rs +0 -0
  100. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/discovery.rs +0 -0
  101. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/facts.rs +0 -0
  102. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/feedback.rs +0 -0
  103. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/filter.rs +0 -0
  104. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/gateway.rs +0 -0
  105. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/ids.rs +0 -0
  106. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/judge.rs +0 -0
  107. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/lib.rs +0 -0
  108. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/literals/command.rs +0 -0
  109. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/literals/corrections.rs +0 -0
  110. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/literals/feedback.rs +0 -0
  111. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/literals/mining.rs +0 -0
  112. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/literals/mod.rs +0 -0
  113. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/literals/protocol.rs +0 -0
  114. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/notifications.rs +0 -0
  115. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/parse.rs +0 -0
  116. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/protocol.rs +0 -0
  117. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/pystr.rs +0 -0
  118. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/query.rs +0 -0
  119. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/render.rs +0 -0
  120. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/rng.rs +0 -0
  121. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/schema.rs +0 -0
  122. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/sqlite.rs +0 -0
  123. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/toolcall.rs +0 -0
  124. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/types.rs +0 -0
  125. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/value.rs +0 -0
  126. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/core/src/watch.rs +0 -0
  127. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/Cargo.toml +0 -0
  128. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/build.rs +0 -0
  129. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/data/command_prefix_pins.tsv +0 -0
  130. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/actor_bridge.rs +0 -0
  131. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/bin/stub_gen.rs +0 -0
  132. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/codex.rs +0 -0
  133. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/command.rs +0 -0
  134. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/context.rs +0 -0
  135. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/corrections.rs +0 -0
  136. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/discovery.rs +0 -0
  137. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/feedback.rs +0 -0
  138. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/judge.rs +0 -0
  139. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/lexicon.rs +0 -0
  140. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/lib.rs +0 -0
  141. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/mining.rs +0 -0
  142. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/nlp.rs +0 -0
  143. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/python.rs +0 -0
  144. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/render.rs +0 -0
  145. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/score.rs +0 -0
  146. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/sqlite.rs +0 -0
  147. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/toolcall.rs +0 -0
  148. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/attachment.rs +0 -0
  149. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/blocks.rs +0 -0
  150. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/command.rs +0 -0
  151. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/convert.rs +0 -0
  152. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/dunder.rs +0 -0
  153. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/events.rs +0 -0
  154. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/meta.rs +0 -0
  155. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/mod.rs +0 -0
  156. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/print.rs +0 -0
  157. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/store.rs +0 -0
  158. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/system.rs +0 -0
  159. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/toolcall.rs +0 -0
  160. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/toolresult.rs +0 -0
  161. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/views/transcript.rs +0 -0
  162. {cc_transcript-14.16.0 → cc_transcript-14.17.0}/rust/crates/py/src/watch.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-transcript
3
- Version: 14.16.0
3
+ Version: 14.17.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -240,6 +240,7 @@ EXPORTS: dict[str, str] = {
240
240
  "DeepSession",
241
241
  "DeepView",
242
242
  "FileRef",
243
+ "PredicateInputs",
243
244
  "Session",
244
245
  "SubagentIndex",
245
246
  "SubagentSession",
@@ -207,6 +207,7 @@ from cc_transcript.query import (
207
207
  DeepSession as DeepSession,
208
208
  DeepView as DeepView,
209
209
  FileRef as FileRef,
210
+ PredicateInputs as PredicateInputs,
210
211
  Session as Session,
211
212
  SubagentIndex as SubagentIndex,
212
213
  SubagentSession as SubagentSession,
@@ -390,6 +391,7 @@ __all__ = [
390
391
  "PRICING",
391
392
  "PatchEdit",
392
393
  "Plugin",
394
+ "PredicateInputs",
393
395
  "PreservedMessages",
394
396
  "PreservedSegment",
395
397
  "PrintMessage",
@@ -12,8 +12,9 @@ from __future__ import annotations
12
12
 
13
13
  import re
14
14
  import threading
15
+ from collections import OrderedDict
15
16
  from collections.abc import Mapping
16
- from dataclasses import dataclass
17
+ from dataclasses import dataclass, field
17
18
  from fnmatch import fnmatch
18
19
  from functools import cached_property
19
20
  from pathlib import PurePath
@@ -47,29 +48,117 @@ if TYPE_CHECKING:
47
48
  from cc_transcript.models import TranscriptEvent
48
49
 
49
50
 
50
- DEEP_LIFT_BUDGET = 64 * 1024 * 1024
51
+ DEEP_LIFT_BUDGET = 1024 * 1024 * 1024
51
52
 
52
- DEEP_LIFTS: dict[LiftKey, tuple[LiftStamp, DeepSession]] = {}
53
- """Lifted sidechain transcripts, keyed by tree position and stamped with the file's ``(size, mtime_ns)``.
53
+ type LiftStamp = tuple[int, int, int]
54
+ type LiftKey = tuple[Path, int, ToolUseId | None]
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class LiftCache:
59
+ held: OrderedDict[LiftKey, tuple[LiftStamp, DeepSession]] = field(default_factory=OrderedDict)
60
+ size: int = 0
61
+ guard: threading.Lock = field(default_factory=threading.Lock)
62
+
63
+ def get(self, key: LiftKey, stamp: LiftStamp) -> DeepSession | None:
64
+ with self.guard:
65
+ match self.held.get(key):
66
+ case None:
67
+ return None
68
+ case (held_stamp, deep) if held_stamp == stamp:
69
+ self.held.move_to_end(key)
70
+ return deep
71
+ case _:
72
+ self.drop(key)
73
+ return None
74
+
75
+ def put(self, key: LiftKey, stamp: LiftStamp, deep: DeepSession) -> DeepSession:
76
+ with self.guard:
77
+ self.drop(key)
78
+ self.held[key] = (stamp, deep)
79
+ self.size += stamp[0]
80
+ while self.size > DEEP_LIFT_BUDGET:
81
+ self.drop(next(iter(self.held)))
82
+ return deep
83
+
84
+ def drop(self, key: LiftKey) -> None:
85
+ if (held := self.held.pop(key, None)) is not None:
86
+ self.size -= held[0][0]
87
+
88
+ def clear(self) -> None:
89
+ with self.guard:
90
+ self.held.clear()
91
+ self.size = 0
92
+
93
+ def __len__(self) -> int:
94
+ return len(self.held)
54
95
 
55
- :meth:`Session.walk` re-reads every node of a tree in the same order, so evicting the
56
- least-recently-used entry thrashes on any tree larger than the cache — each walk drops
57
- exactly what the next one asks for first. Admission stops at :data:`DEEP_LIFT_BUDGET`
58
- source bytes instead: what is held stays held, and a tree that outgrows the budget
59
- reparses only its tail. A sidechain that grew replaces its own entry, budget or not.
96
+
97
+ DEEP_LIFTS = LiftCache()
98
+ """Lifted sidechain transcripts, keyed by tree position and stamped with the file's ``(size, mtime_ns, inode)``.
99
+
100
+ A least-recently-used hold bounded at :data:`DEEP_LIFT_BUDGET` source bytes. One resident
101
+ process serves many lead sessions, so the tree walked most recently stays held while an
102
+ idle lead's sidechains age out; a changed stamp drops the stale lift and releases its
103
+ bytes. A lifted session costs about 2.3 times its source bytes in resident memory, so the
104
+ default holds roughly 2.3 GiB. A single tree larger than the budget reparses the part that
105
+ no longer fits on every walk.
60
106
  """
61
107
 
62
- DEEP_LIFT_GUARD = threading.Lock()
63
108
 
64
- type LiftStamp = tuple[int, int]
65
- type LiftKey = tuple[Path, int, ToolUseId | None]
109
+ SIDECHAIN_INPUTS_LIMIT = 16384
110
+
111
+
112
+ @dataclass(slots=True)
113
+ class PredicateInputsCache:
114
+ held: OrderedDict[Path, tuple[LiftStamp, PredicateInputs]] = field(default_factory=OrderedDict)
115
+ guard: threading.Lock = field(default_factory=threading.Lock)
116
+
117
+ def get(self, path: Path, stamp: LiftStamp) -> PredicateInputs | None:
118
+ with self.guard:
119
+ match self.held.get(path):
120
+ case (held_stamp, inputs) if held_stamp == stamp:
121
+ self.held.move_to_end(path)
122
+ return inputs
123
+ case _:
124
+ return None
125
+
126
+ def put(self, path: Path, stamp: LiftStamp, inputs: PredicateInputs) -> PredicateInputs:
127
+ with self.guard:
128
+ self.held[path] = (stamp, inputs)
129
+ self.held.move_to_end(path)
130
+ while len(self.held) > SIDECHAIN_INPUTS_LIMIT:
131
+ self.held.popitem(last=False)
132
+ return inputs
133
+
134
+ def clear(self) -> None:
135
+ with self.guard:
136
+ self.held.clear()
137
+
138
+ def __len__(self) -> int:
139
+ return len(self.held)
140
+
141
+
142
+ SIDECHAIN_INPUTS = PredicateInputsCache()
143
+ """Each sidechain's :class:`PredicateInputs`, keyed by path and stamped like :data:`DEEP_LIFTS`.
144
+
145
+ A lifted session costs about 2.3 times its source bytes, so a tree of a thousand sidechains
146
+ cannot stay lifted; its :class:`PredicateInputs` are a few kilobytes each. A predicate lifts a
147
+ sidechain only when its stamp moved, keeps those inputs, and lets the lift go, so a resident
148
+ process re-answering the same predicates per event neither reparses nor holds the tree.
149
+ """
66
150
 
67
151
 
68
- def hold_lift(key: LiftKey, stamp: LiftStamp, deep: DeepSession) -> DeepSession:
69
- with DEEP_LIFT_GUARD:
70
- if key in DEEP_LIFTS or sum(size for (size, _), _ in DEEP_LIFTS.values()) < DEEP_LIFT_BUDGET:
71
- DEEP_LIFTS[key] = (stamp, deep)
72
- return deep
152
+ SIDECHAIN_LISTINGS: OrderedDict[Path, tuple[tuple[int, int], tuple[tuple[Path, bool], ...]]] = OrderedDict()
153
+ """Each non-empty ``subagents`` listing and whether each file is a symlink, stamped ``(inode, mtime_ns)``.
154
+
155
+ Adding, removing, renaming, or replacing a sidechain moves the directory's mtime, so an unchanged
156
+ stamp proves the listing current. A walk then skips the directory read and resolves the directory
157
+ once rather than every file; only a symlinked sidechain is resolved on every walk. An empty listing
158
+ is never held, since the native lister reports an unreadable directory as empty.
159
+ """
160
+
161
+ SIDECHAIN_LISTINGS_GUARD = threading.Lock()
73
162
 
74
163
 
75
164
  def is_failure(use: ToolUse) -> bool:
@@ -222,8 +311,12 @@ class ToolCallQuery:
222
311
  callable predicate, or a value compared for equality.
223
312
  """
224
313
  return self.where(
225
- lambda use: isinstance(use.call.raw, Mapping)
226
- and all(key in use.call.raw and input_rule_matches(rule, use.call.raw[key]) for key, rule in rules.items())
314
+ lambda use: (
315
+ isinstance(use.call.raw, Mapping)
316
+ and all(
317
+ key in use.call.raw and input_rule_matches(rule, use.call.raw[key]) for key, rule in rules.items()
318
+ )
319
+ )
227
320
  )
228
321
 
229
322
  def count(self) -> int:
@@ -454,9 +547,7 @@ class Session:
454
547
 
455
548
  def has_tool(self, name: str, *, subagents: bool = True) -> bool:
456
549
  """Whether any call in the window matches the pipe spec ``name``."""
457
- return self.tool_calls.named(name).any() or (
458
- subagents and any(deep.session.has_tool(name, subagents=False) for deep in self.walk())
459
- )
550
+ return any_inputs(self, lambda inputs: inputs.has_tool(name), subagents=subagents)
460
551
 
461
552
  def has_command(self, *argv: str, subagents: bool = True) -> bool:
462
553
  """Whether any Bash command in the window runs ``argv``.
@@ -465,28 +556,36 @@ class Session:
465
556
  unwrapped argv, so ``has_command("git", "push")`` matches
466
557
  ``sudo git push -f`` and ``cd x && git push`` but not ``echo "git push"``.
467
558
  """
468
- return any(cmd.runs(*argv) for line in self.command_lines() for cmd in line) or (
469
- subagents and any(deep.session.has_command(*argv, subagents=False) for deep in self.walk())
470
- )
559
+ return any_inputs(self, lambda inputs: inputs.has_command(argv), subagents=subagents)
471
560
 
472
561
  def has_edit_to(self, *globs: str, subagents: bool = True) -> bool:
473
562
  """Whether any edit-shaped call in the window targets a file matching any glob."""
474
- return any(file.matches(*globs) for file in self.edited_files) or (
475
- subagents and any(deep.session.has_edit_to(*globs, subagents=False) for deep in self.walk())
476
- )
563
+ return any_inputs(self, lambda inputs: inputs.has_edit_to(globs), subagents=subagents)
477
564
 
478
565
  def has_read(self, pattern: str, *, subagents: bool = True) -> bool:
479
566
  """Whether any Read in the window targets a path containing ``pattern``."""
480
- return any(pattern in str(file) for file in self.tool_calls.named("Read").files()) or (
481
- subagents and any(deep.session.has_read(pattern, subagents=False) for deep in self.walk())
482
- )
567
+ return any_inputs(self, lambda inputs: inputs.has_read(pattern), subagents=subagents)
483
568
 
484
569
  def has_skill(self, *names: str, subagents: bool = True) -> bool:
485
570
  """Whether any Skill invocation in the window names one of ``names``."""
486
- return any(
487
- isinstance(call := use.call, SkillCall) and call.skill in names
488
- for use in self.tool_calls.named("Skill")
489
- ) or (subagents and any(deep.session.has_skill(*names, subagents=False) for deep in self.walk()))
571
+ return any_inputs(self, lambda inputs: inputs.has_skill(names), subagents=subagents)
572
+
573
+ @cached_property
574
+ def predicate_inputs(self) -> PredicateInputs:
575
+ """What the ``has_*`` predicates read from this window."""
576
+ return PredicateInputs.of(self)
577
+
578
+ def deep_inputs(self) -> Iterator[PredicateInputs]:
579
+ """This window's :class:`PredicateInputs`, then those of every transcript :meth:`walk` reaches.
580
+
581
+ The walk order and dedupe match :meth:`walk`, but no reached transcript stays lifted: each
582
+ one's inputs are held per ``(size, mtime, inode)`` stamp, and a transcript is reparsed only
583
+ once its stamp moves. Prefer it to :meth:`walk` for any predicate these inputs can answer.
584
+ """
585
+ yield self.predicate_inputs
586
+ for path, depth, spawned_by in reachable_transcripts(self):
587
+ if (inputs := load_predicate_inputs(path, depth, spawned_by)) is not None:
588
+ yield inputs
490
589
 
491
590
  def has_override(self, token: str, *, invalidated_by: Sequence[str] = ("Edit", "Write")) -> bool:
492
591
  """Whether ``token`` appears in the window without a later invalidating call.
@@ -536,9 +635,7 @@ class Session:
536
635
 
537
636
  @cached_property
538
637
  def _command_lines(self) -> tuple[CommandLine, ...]:
539
- from cc_transcript.command import parse_command_line
540
-
541
- return tuple(parse_command_line(command) for command in self._commands)
638
+ return self.predicate_inputs.command_lines
542
639
 
543
640
  def __len__(self) -> int:
544
641
  return sum(len(turn.events) for turn in self.turns)
@@ -619,6 +716,83 @@ class DeepSession:
619
716
  spawned_by: ToolUseId | None
620
717
 
621
718
 
719
+ @dataclass(frozen=True) # non-slots: the cached_property below needs __dict__
720
+ class PredicateInputs:
721
+ """The slice of one transcript window that the ``has_*`` predicates read.
722
+
723
+ Small enough to hold for every transcript in a tree of thousands, where a lifted
724
+ :class:`Session` is not. Calls whose result errored are left out, as
725
+ :class:`ToolCallQuery` hides them by default. Answers that do not depend on the MCP
726
+ tool registry are memoized, up to :attr:`MAX_ANSWERS` per instance.
727
+
728
+ Attributes:
729
+ calls: Each call's tool name and the file paths it targets, in order.
730
+ commands: The command string of every Bash call.
731
+ edited_files: The files modified by edit-shaped calls, one entry per edited file.
732
+ skills: The skill named by every Skill call.
733
+ """
734
+
735
+ calls: tuple[tuple[str, tuple[str, ...]], ...]
736
+ commands: tuple[str, ...]
737
+ edited_files: tuple[FileRef, ...]
738
+ skills: tuple[str, ...]
739
+ answers: dict[tuple[str, object], bool] = field(default_factory=dict, compare=False, repr=False)
740
+
741
+ MAX_ANSWERS: ClassVar[int] = 256
742
+
743
+ @classmethod
744
+ def of(cls, session: Session) -> PredicateInputs:
745
+ """Extracts the predicate inputs from ``session``'s window."""
746
+ calls = session.tool_calls
747
+ return cls(
748
+ calls=tuple((use.call.name, tuple(file_paths_of(use.call))) for use in calls.items),
749
+ commands=session.commands(),
750
+ edited_files=session.edited_files,
751
+ skills=tuple(call.skill for use in calls.named("Skill") if isinstance(call := use.call, SkillCall)),
752
+ )
753
+
754
+ @cached_property
755
+ def command_lines(self) -> tuple[CommandLine, ...]:
756
+ """:attr:`commands` parsed into :class:`~cc_transcript.command.CommandLine` objects."""
757
+ from cc_transcript.command import parse_command_line
758
+
759
+ return tuple(parse_command_line(command) for command in self.commands)
760
+
761
+ @cached_property
762
+ def tool_names(self) -> frozenset[str]:
763
+ """The distinct names in :attr:`calls`."""
764
+ return frozenset(name for name, _ in self.calls)
765
+
766
+ def files(self, spec: str) -> tuple[FileRef, ...]:
767
+ """The files targeted by calls matching the pipe spec ``spec``, one entry per path."""
768
+ matching = {name for name in self.tool_names if tool_name_matches(name, spec)}
769
+ return tuple(FileRef(path) for name, paths in self.calls if name in matching for path in paths)
770
+
771
+ def answer(self, query: tuple[str, object], compute: Callable[[], bool]) -> bool:
772
+ if (known := self.answers.get(query)) is None:
773
+ if len(self.answers) >= self.MAX_ANSWERS:
774
+ self.answers.clear()
775
+ known = self.answers[query] = compute()
776
+ return known
777
+
778
+ def has_tool(self, name: str) -> bool:
779
+ return any(tool_name_matches(tool, name) for tool in self.tool_names)
780
+
781
+ def has_command(self, argv: tuple[str, ...]) -> bool:
782
+ return self.answer(
783
+ ("command", argv), lambda: any(cmd.runs(*argv) for line in self.command_lines for cmd in line)
784
+ )
785
+
786
+ def has_edit_to(self, globs: tuple[str, ...]) -> bool:
787
+ return self.answer(("edit", globs), lambda: any(file.matches(*globs) for file in self.edited_files))
788
+
789
+ def has_read(self, pattern: str) -> bool:
790
+ return any(pattern in str(file) for file in self.files("Read"))
791
+
792
+ def has_skill(self, names: tuple[str, ...]) -> bool:
793
+ return self.answer(("skill", names), lambda: any(skill in names for skill in self.skills))
794
+
795
+
622
796
  @dataclass(frozen=True) # non-slots: the cached_property below needs __dict__
623
797
  class DeepView:
624
798
  """The recursive union of a session and every transcript reachable from it.
@@ -680,26 +854,75 @@ def sidechain_sessions(path: Path | None) -> tuple[Session, ...]:
680
854
  return tuple(Session.from_path(entry) for entry in subagent_paths(path))
681
855
 
682
856
 
683
- def deep_sessions(root: Session) -> Iterator[DeepSession]:
857
+ def any_inputs(session: Session, predicate: Callable[[PredicateInputs], bool], *, subagents: bool) -> bool:
858
+ return any(map(predicate, session.deep_inputs() if subagents else (session.predicate_inputs,)))
859
+
860
+
861
+ def reachable_transcripts(root: Session) -> Iterator[tuple[Path, int, ToolUseId | None]]:
684
862
  seen: set[Path] = {root.path.resolve()} if root.path is not None else set()
685
863
  if root.path is not None:
686
- yield from descend_sidechains(root.path, 1, seen)
864
+ yield from reachable_sidechains(root.path, 1, seen)
687
865
  for attachment in root.attachments:
688
- yield from visit_transcript(attachment, 1, None, seen)
866
+ yield from reachable_from(attachment, attachment.resolve(), 1, None, seen)
689
867
 
690
868
 
691
- def descend_sidechains(parent: Path, depth: int, seen: set[Path]) -> Iterator[DeepSession]:
692
- for child in subagent_paths(parent):
693
- yield from visit_transcript(child, depth, ToolUseId(child.stem.removeprefix("agent-")), seen)
869
+ def reachable_sidechains(parent: Path, depth: int, seen: set[Path]) -> Iterator[tuple[Path, int, ToolUseId | None]]:
870
+ for child, resolved in sidechain_listing(parent):
871
+ yield from reachable_from(child, resolved, depth, ToolUseId(child.stem.removeprefix("agent-")), seen)
694
872
 
695
873
 
696
- def visit_transcript(path: Path, depth: int, spawned_by: ToolUseId | None, seen: set[Path]) -> Iterator[DeepSession]:
697
- if (resolved := path.resolve()) in seen:
874
+ def reachable_from(
875
+ path: Path, resolved: Path, depth: int, spawned_by: ToolUseId | None, seen: set[Path]
876
+ ) -> Iterator[tuple[Path, int, ToolUseId | None]]:
877
+ if resolved in seen:
698
878
  return
699
879
  seen.add(resolved)
700
- if (deep := load_deep_session(path, depth, spawned_by)) is not None:
701
- yield deep
702
- yield from descend_sidechains(path, depth + 1, seen)
880
+ yield path, depth, spawned_by
881
+ yield from reachable_sidechains(path, depth + 1, seen)
882
+
883
+
884
+ def sidechain_listing(parent: Path) -> tuple[tuple[Path, Path], ...]:
885
+ directory = parent.parent / parent.stem / "subagents"
886
+ try:
887
+ stat = directory.stat()
888
+ except OSError:
889
+ return ()
890
+ stamp = (stat.st_ino, stat.st_mtime_ns)
891
+ with SIDECHAIN_LISTINGS_GUARD:
892
+ held = SIDECHAIN_LISTINGS.get(directory)
893
+ if held is not None and held[0] == stamp:
894
+ children = held[1]
895
+ else:
896
+ children = tuple((child, child.is_symlink()) for child in subagent_paths(parent))
897
+ if children:
898
+ with SIDECHAIN_LISTINGS_GUARD:
899
+ SIDECHAIN_LISTINGS[directory] = (stamp, children)
900
+ SIDECHAIN_LISTINGS.move_to_end(directory)
901
+ while len(SIDECHAIN_LISTINGS) > SIDECHAIN_INPUTS_LIMIT:
902
+ SIDECHAIN_LISTINGS.popitem(last=False)
903
+ real = directory.resolve()
904
+ return tuple((child, child.resolve() if link else real / child.name) for child, link in children)
905
+
906
+
907
+ def load_predicate_inputs(path: Path, depth: int, spawned_by: ToolUseId | None) -> PredicateInputs | None:
908
+ try:
909
+ stat = path.stat()
910
+ except OSError:
911
+ return None
912
+ stamp = (stat.st_size, stat.st_mtime_ns, stat.st_ino)
913
+ if (held := SIDECHAIN_INPUTS.get(path, stamp)) is not None:
914
+ return held
915
+ try:
916
+ deep = DEEP_LIFTS.get((path, depth, spawned_by), stamp) or lift_deep_session(path, depth, spawned_by)
917
+ except OSError:
918
+ return None
919
+ return SIDECHAIN_INPUTS.put(path, stamp, PredicateInputs.of(deep.session))
920
+
921
+
922
+ def deep_sessions(root: Session) -> Iterator[DeepSession]:
923
+ for path, depth, spawned_by in reachable_transcripts(root):
924
+ if (deep := load_deep_session(path, depth, spawned_by)) is not None:
925
+ yield deep
703
926
 
704
927
 
705
928
  def lift_deep_session(path: Path, depth: int, spawned_by: ToolUseId | None) -> DeepSession:
@@ -719,11 +942,11 @@ def load_deep_session(path: Path, depth: int, spawned_by: ToolUseId | None) -> D
719
942
  stat = path.stat()
720
943
  except OSError:
721
944
  return None
722
- stamp = (stat.st_size, stat.st_mtime_ns)
945
+ stamp = (stat.st_size, stat.st_mtime_ns, stat.st_ino)
723
946
  key = (path, depth, spawned_by)
724
- if (held := DEEP_LIFTS.get(key)) is not None and held[0] == stamp:
725
- return held[1]
947
+ if (held := DEEP_LIFTS.get(key, stamp)) is not None:
948
+ return held
726
949
  try:
727
- return hold_lift(key, stamp, lift_deep_session(path, depth, spawned_by))
950
+ return DEEP_LIFTS.put(key, stamp, lift_deep_session(path, depth, spawned_by))
728
951
  except OSError:
729
952
  return None
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cc-transcript"
3
- version = "14.16.0" # informational — releases stamp the version from the git tag
3
+ version = "14.17.0" # informational — releases stamp the version from the git tag
4
4
  description = "Grep every Claude Code session you've ever run."
5
5
  readme = "README.md"
6
6
  license = "PolyForm-Noncommercial-1.0.0"
File without changes