cc-transcript 14.18.0__tar.gz → 14.19.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.18.0 → cc_transcript-14.19.0}/PKG-INFO +1 -1
  2. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/activity.py +4 -2
  3. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/query.py +287 -54
  4. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/pyproject.toml +1 -1
  5. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/Cargo.lock +0 -0
  6. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/Cargo.toml +0 -0
  7. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/LICENSE +0 -0
  8. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/README.md +0 -0
  9. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/__init__.py +0 -0
  10. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/__init__.pyi +0 -0
  11. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/_native.pyi +0 -0
  12. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/activity_probe.py +0 -0
  13. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/builders.py +0 -0
  14. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/codex.py +0 -0
  15. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/command.py +0 -0
  16. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/context.py +0 -0
  17. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/corrections.py +0 -0
  18. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/cost.py +0 -0
  19. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/decision_store.py +0 -0
  20. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/decisions.py +0 -0
  21. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/discovery.py +0 -0
  22. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/disktruth.py +0 -0
  23. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/evidence.py +0 -0
  24. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/extract/__init__.py +0 -0
  25. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/extract/correct.py +0 -0
  26. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/facts.py +0 -0
  27. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/filterspec.py +0 -0
  28. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/heartbeats.py +0 -0
  29. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/ids.py +0 -0
  30. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/judge/__init__.py +0 -0
  31. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/judge/llm.py +0 -0
  32. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/judge/similar.py +0 -0
  33. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/judge/verdicts.py +0 -0
  34. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/ledger.py +0 -0
  35. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/literals.py +0 -0
  36. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/__init__.py +0 -0
  37. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/candidates.py +0 -0
  38. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/confidence.py +0 -0
  39. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/engine.py +0 -0
  40. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/formats.py +0 -0
  41. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/sampling.py +0 -0
  42. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/signals.py +0 -0
  43. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/sourcekind.py +0 -0
  44. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/spec.py +0 -0
  45. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/mining/store.py +0 -0
  46. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/models.py +0 -0
  47. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/nlp.py +0 -0
  48. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/notifications.py +0 -0
  49. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/parser.py +0 -0
  50. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/py.typed +0 -0
  51. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/render.py +0 -0
  52. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/sentiment/__init__.py +0 -0
  53. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/sentiment/buckets.py +0 -0
  54. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/sentiment/data/afinn-en-165.tsv +0 -0
  55. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/sentiment/data/domain_overrides.tsv +0 -0
  56. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/sentiment/data/en-ewt.udpipe +0 -0
  57. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/sentiment/engine.py +0 -0
  58. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/sentiment/lexicon.py +0 -0
  59. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/sentiment/scorespec.py +0 -0
  60. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/synthetic.py +0 -0
  61. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/tools.py +0 -0
  62. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/cc_transcript/watch.py +0 -0
  63. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/Cargo.toml +0 -0
  64. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/blame.rs +0 -0
  65. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/corrections.rs +0 -0
  66. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/digest.rs +0 -0
  67. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/facts.rs +0 -0
  68. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/grep.rs +0 -0
  69. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/list.rs +0 -0
  70. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/mod.rs +0 -0
  71. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/scratchpad.rs +0 -0
  72. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/show.rs +0 -0
  73. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/slice.rs +0 -0
  74. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/stats.rs +0 -0
  75. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/commands/watch.rs +0 -0
  76. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/lib.rs +0 -0
  77. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/main.rs +0 -0
  78. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/output.rs +0 -0
  79. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/target.rs +0 -0
  80. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/cli/src/timearg.rs +0 -0
  81. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/Cargo.toml +0 -0
  82. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/benches/parse.rs +0 -0
  83. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/benches/render.rs +0 -0
  84. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/benches/toolcall.rs +0 -0
  85. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/activity.rs +0 -0
  86. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/actor.rs +0 -0
  87. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/blame.rs +0 -0
  88. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/buckets.rs +0 -0
  89. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/codex/discovery.rs +0 -0
  90. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/codex/lower.rs +0 -0
  91. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/codex/mod.rs +0 -0
  92. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/codex/parse.rs +0 -0
  93. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/codex/probe.rs +0 -0
  94. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/codex/protocol.rs +0 -0
  95. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/codex/types.rs +0 -0
  96. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/command.rs +0 -0
  97. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/context.rs +0 -0
  98. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/corrections.rs +0 -0
  99. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/cost.rs +0 -0
  100. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/discovery.rs +0 -0
  101. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/facts.rs +0 -0
  102. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/feedback.rs +0 -0
  103. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/filter.rs +0 -0
  104. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/gateway.rs +0 -0
  105. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/ids.rs +0 -0
  106. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/judge.rs +0 -0
  107. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/lib.rs +0 -0
  108. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/literals/command.rs +0 -0
  109. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/literals/corrections.rs +0 -0
  110. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/literals/feedback.rs +0 -0
  111. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/literals/mining.rs +0 -0
  112. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/literals/mod.rs +0 -0
  113. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/literals/protocol.rs +0 -0
  114. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/notifications.rs +0 -0
  115. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/parse.rs +0 -0
  116. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/protocol.rs +0 -0
  117. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/pystr.rs +0 -0
  118. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/query.rs +0 -0
  119. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/render.rs +0 -0
  120. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/rng.rs +0 -0
  121. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/schema.rs +0 -0
  122. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/sqlite.rs +0 -0
  123. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/toolcall.rs +0 -0
  124. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/types.rs +0 -0
  125. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/value.rs +0 -0
  126. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/core/src/watch.rs +0 -0
  127. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/Cargo.toml +0 -0
  128. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/build.rs +0 -0
  129. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/data/command_prefix_pins.tsv +0 -0
  130. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/actor_bridge.rs +0 -0
  131. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/bin/stub_gen.rs +0 -0
  132. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/codex.rs +0 -0
  133. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/command.rs +0 -0
  134. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/context.rs +0 -0
  135. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/corrections.rs +0 -0
  136. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/discovery.rs +0 -0
  137. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/feedback.rs +0 -0
  138. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/judge.rs +0 -0
  139. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/lexicon.rs +0 -0
  140. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/lib.rs +0 -0
  141. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/mining.rs +0 -0
  142. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/nlp.rs +0 -0
  143. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/python.rs +0 -0
  144. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/render.rs +0 -0
  145. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/score.rs +0 -0
  146. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/sqlite.rs +0 -0
  147. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/toolcall.rs +0 -0
  148. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/attachment.rs +0 -0
  149. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/blocks.rs +0 -0
  150. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/command.rs +0 -0
  151. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/convert.rs +0 -0
  152. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/dunder.rs +0 -0
  153. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/events.rs +0 -0
  154. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/meta.rs +0 -0
  155. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/mod.rs +0 -0
  156. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/print.rs +0 -0
  157. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/store.rs +0 -0
  158. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/system.rs +0 -0
  159. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/toolcall.rs +0 -0
  160. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/toolresult.rs +0 -0
  161. {cc_transcript-14.18.0 → cc_transcript-14.19.0}/rust/crates/py/src/views/transcript.rs +0 -0
  162. {cc_transcript-14.18.0 → cc_transcript-14.19.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.18.0
3
+ Version: 14.19.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -285,8 +285,10 @@ class ActivityLift:
285
285
  re-derives the session.
286
286
 
287
287
  Feed only the events appended since the previous call: nothing checks
288
- provenance, so events fed twice lift twice. A transcript rewritten in
289
- place, rather than appended to, needs a new cursor.
288
+ provenance, so events fed twice lift twice. The contract is append-only:
289
+ a cursor's transcript is assumed never rewritten behind the events it has
290
+ been fed, and a transcript rewritten in place, rather than appended to,
291
+ needs a new cursor. Claude Code and Codex transcripts satisfy it.
290
292
 
291
293
  The classifier is read once, at construction, and must be deterministic
292
294
  and event-only: its answer for an event has to stay the same as later
@@ -10,17 +10,19 @@ predicates compose over progressively narrower windows.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import os
13
14
  import re
14
15
  import threading
15
16
  from collections import OrderedDict
16
17
  from collections.abc import Mapping
17
- from dataclasses import dataclass, field
18
+ from dataclasses import dataclass, field, replace
18
19
  from fnmatch import fnmatch
19
20
  from functools import cached_property
20
21
  from pathlib import PurePath
22
+ from time import monotonic
21
23
  from typing import TYPE_CHECKING, ClassVar
22
24
 
23
- from cc_transcript.activity import SessionActivity, Turn, event_stamps, native_user_classifier
25
+ from cc_transcript.activity import ActivityLift, SessionActivity, Turn, event_stamps, native_user_classifier
24
26
  from cc_transcript.discovery import TranscriptExpiredError, resolve, subagent_paths, subagent_transcripts
25
27
  from cc_transcript.filterspec import event_meta, session_id_of
26
28
  from cc_transcript.ids import SessionId, ToolUseId
@@ -45,45 +47,79 @@ if TYPE_CHECKING:
45
47
  from cc_transcript.activity import ToolUse, UserClassifier
46
48
  from cc_transcript.command import CommandLine
47
49
  from cc_transcript.ids import EventUuid
48
- from cc_transcript.models import TranscriptEvent
50
+ from cc_transcript.models import Transcript, TranscriptEvent
49
51
 
50
52
 
51
53
  DEEP_LIFT_BUDGET = 1024 * 1024 * 1024
54
+ DEEP_LIFT_FENCE = 64
55
+ IDLE_SECONDS_BEFORE_RELEASE = 900
52
56
 
53
- type LiftStamp = tuple[int, int, int]
57
+ type LiftStamp = tuple[int, int, int, int]
54
58
  type LiftKey = tuple[Path, int, ToolUseId | None]
55
59
 
56
60
 
61
+ def stamp_of(stat: os.stat_result) -> LiftStamp:
62
+ return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_ino)
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class DeepLift:
67
+ stamp: LiftStamp
68
+ deep: DeepSession
69
+ lift: ActivityLift
70
+ consumed: int
71
+ fence: bytes
72
+ found: SessionId | None
73
+ provider: str | None
74
+ grown_at: float
75
+
76
+
57
77
  @dataclass(slots=True)
58
78
  class LiftCache:
59
- held: OrderedDict[LiftKey, tuple[LiftStamp, DeepSession]] = field(default_factory=OrderedDict)
79
+ held: OrderedDict[LiftKey, DeepLift] = field(default_factory=OrderedDict)
60
80
  size: int = 0
61
81
  guard: threading.Lock = field(default_factory=threading.Lock)
62
82
 
63
- def get(self, key: LiftKey, stamp: LiftStamp) -> DeepSession | None:
83
+ def get(self, key: LiftKey, stamp: LiftStamp) -> DeepLift | None:
64
84
  with self.guard:
65
85
  match self.held.get(key):
66
- case None:
67
- return None
68
- case (held_stamp, deep) if held_stamp == stamp:
86
+ case DeepLift(stamp=held_stamp) as lift if held_stamp == stamp:
69
87
  self.held.move_to_end(key)
70
- return deep
88
+ return lift
71
89
  case _:
72
- self.drop(key)
73
90
  return None
74
91
 
75
- def put(self, key: LiftKey, stamp: LiftStamp, deep: DeepSession) -> DeepSession:
92
+ def idle_release(self, key: LiftKey, stamp: LiftStamp) -> None:
93
+ """Releases ``key``'s cursor once its file has gone :data:`IDLE_SECONDS_BEFORE_RELEASE` unwritten.
94
+
95
+ A held cursor amortizes the next growth of a live sidechain; a finished one earns
96
+ nothing. Expiry is elapsed inactivity, not a hit count, so a burst of predicate
97
+ traffic over a cursor that just grew never releases it — the clock is read from the
98
+ entry's last growth. Any growth replaces the entry, resetting ``grown_at``.
99
+ """
100
+ with self.guard:
101
+ match self.held.get(key):
102
+ case DeepLift(stamp=held_stamp, grown_at=at) if held_stamp == stamp:
103
+ if monotonic() - at >= IDLE_SECONDS_BEFORE_RELEASE:
104
+ self.drop(key)
105
+
106
+ def take(self, key: LiftKey) -> DeepLift | None:
107
+ with self.guard:
108
+ return self.drop(key)
109
+
110
+ def put(self, key: LiftKey, lift: DeepLift) -> DeepLift:
76
111
  with self.guard:
77
112
  self.drop(key)
78
- self.held[key] = (stamp, deep)
79
- self.size += stamp[0]
113
+ self.held[key] = lift
114
+ self.size += lift.stamp[0]
80
115
  while self.size > DEEP_LIFT_BUDGET:
81
116
  self.drop(next(iter(self.held)))
82
- return deep
117
+ return lift
83
118
 
84
- def drop(self, key: LiftKey) -> None:
85
- if (held := self.held.pop(key, None)) is not None:
86
- self.size -= held[0][0]
119
+ def drop(self, key: LiftKey) -> DeepLift | None:
120
+ if (lift := self.held.pop(key, None)) is not None:
121
+ self.size -= lift.stamp[0]
122
+ return lift
87
123
 
88
124
  def clear(self) -> None:
89
125
  with self.guard:
@@ -95,14 +131,51 @@ class LiftCache:
95
131
 
96
132
 
97
133
  DEEP_LIFTS = LiftCache()
98
- """Lifted sidechain transcripts, keyed by tree position and stamped with the file's ``(size, mtime_ns, inode)``.
134
+ """Lifted sidechain transcripts, keyed by tree position and stamped ``(size, mtime_ns, ctime_ns, inode)``.
99
135
 
100
136
  A least-recently-used hold bounded at :data:`DEEP_LIFT_BUDGET` source bytes. One resident
101
137
  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.
138
+ idle lead's sidechains age out. A lifted session costs about 2.3 times its source bytes in
139
+ resident memory, so the default holds roughly 2.3 GiB. A single tree larger than the budget
140
+ reparses the part that no longer fits on every walk.
141
+
142
+ Each entry keeps the :class:`~cc_transcript.activity.ActivityLift` cursor behind its lift and
143
+ the byte offset it has consumed, which always ends on a newline. A Claude transcript that has
144
+ only grown — same inode, larger, its last held bytes unchanged — is extended by its appended
145
+ lines alone: the tail past the last newline is parsed for the session but never fed to the
146
+ cursor. Only Claude is extended incrementally, because its parse is line-compositional; a
147
+ Codex rollout lowers against whole-session context (echo sets, the model timeline), so its
148
+ provider is pinned on the cold parse and any growth cold-relifts, as does a growth whose
149
+ appended chunk parses as a different provider than the file was pinned to. An empty file pins
150
+ no provider until a line lands, extending while its appended lines parse as Claude and
151
+ cold-relifting the moment a Codex line completes. A shrink, a replaced or rewritten file, a
152
+ short read, or a session id that surfaces only in the appended lines lifts the file afresh.
153
+
154
+ The contract is append-only: a held transcript is assumed never rewritten behind its consumed
155
+ offset. Growth is recognized by the same inode, a larger size, and the last
156
+ :data:`DEEP_LIFT_FENCE` consumed bytes still in place, with the descriptor's stat rechecked
157
+ around the read; a shrink, a replaced inode, a same-size rewrite, or a fence mismatch triggers
158
+ a cold relift. An in-place rewrite of earlier bytes that keeps the fence and grows the file is
159
+ not detected and is folded in as growth. Claude Code and Codex transcripts satisfy the contract.
160
+ Every read validates the open descriptor's stat after reading against the stat that routed it
161
+ — a replacement between the routing stat and the read moves the inode and ctime — so a growth
162
+ that reads spliced bytes falls to a cold relift, and a cold read whose descriptor moved under
163
+ it answers the request but is published to no cache — not here, not :data:`SIDECHAIN_INPUTS`,
164
+ not :data:`UNREADABLE` — and every publication is keyed by the descriptor stamp the read
165
+ verified rather than the routing stat. The cursor is mutable, so a stamp miss takes the
166
+ entry out of the hold before extending it and puts the grown entry back: a concurrent reader
167
+ never sees a cursor mid-extension, and two threads that miss together each produce a correct
168
+ entry, the later put replacing the earlier. A dropped entry releases its cursor with it.
169
+
170
+ :meth:`Session.walk` holds every transcript it reaches; the ``has_*`` predicates hold only a
171
+ transcript they have had to lift twice, since a sidechain that changed once is a running
172
+ subagent whose next growth would otherwise cost a whole relift, while a finished one is
173
+ lifted once and its :class:`PredicateInputs` kept instead. A held cursor is released once its
174
+ file has gone :data:`IDLE_SECONDS_BEFORE_RELEASE` unwritten — elapsed inactivity since its
175
+ last growth, not a count of walks, so aggregate predicate traffic from many readers never
176
+ expires a cursor that just grew — so resident cursors track the set of sidechains still being
177
+ written rather than every one that ever grew; a released file that grows again pays one relift
178
+ and is re-admitted. :data:`DEEP_LIFT_BUDGET` stays the upper bound over that live set.
106
179
  """
107
180
 
108
181
 
@@ -131,6 +204,37 @@ class PredicateInputsCache:
131
204
  self.held.popitem(last=False)
132
205
  return inputs
133
206
 
207
+ def holds(self, path: Path) -> bool:
208
+ with self.guard:
209
+ return path in self.held
210
+
211
+ def clear(self) -> None:
212
+ with self.guard:
213
+ self.held.clear()
214
+
215
+ def __len__(self) -> int:
216
+ return len(self.held)
217
+
218
+
219
+ @dataclass(slots=True)
220
+ class StampSet:
221
+ held: OrderedDict[Path, LiftStamp] = field(default_factory=OrderedDict)
222
+ guard: threading.Lock = field(default_factory=threading.Lock)
223
+
224
+ def has(self, path: Path, stamp: LiftStamp) -> bool:
225
+ with self.guard:
226
+ if self.held.get(path) != stamp:
227
+ return False
228
+ self.held.move_to_end(path)
229
+ return True
230
+
231
+ def put(self, path: Path, stamp: LiftStamp) -> None:
232
+ with self.guard:
233
+ self.held[path] = stamp
234
+ self.held.move_to_end(path)
235
+ while len(self.held) > SIDECHAIN_INPUTS_LIMIT:
236
+ self.held.popitem(last=False)
237
+
134
238
  def clear(self) -> None:
135
239
  with self.guard:
136
240
  self.held.clear()
@@ -149,6 +253,21 @@ process re-answering the same predicates per event neither reparses nor holds th
149
253
  """
150
254
 
151
255
 
256
+ UNREADABLE = StampSet()
257
+ """Transcripts whose typed parse failed, keyed by path and stamped like :data:`DEEP_LIFTS`.
258
+
259
+ A sidechain carrying one line the typed parser rejects — schema drift Claude Code has not
260
+ caught up to — cannot be lifted, so a walk skips it. Without this a resident process re-read
261
+ and re-parsed the whole file on every walk forever. Only a deterministic parse failure
262
+ (:class:`UnparseableTranscript`) is held, never a transient filesystem error, which must
263
+ retry. The failure is held by full stamp — the stamp of the descriptor the rejected bytes were
264
+ read from, never the stat that routed the read, and only when that read settled — so an
265
+ unchanged bad file is skipped at stamp-check cost, any change — a growth that completes the
266
+ line, a rewrite — clears the miss and retries, and a file that was swapped under the read is
267
+ never held against the file the path names next time.
268
+ """
269
+
270
+
152
271
  SIDECHAIN_LISTINGS: OrderedDict[Path, tuple[tuple[int, int], tuple[tuple[Path, bool], ...]]] = OrderedDict()
153
272
  """Each non-empty ``subagents`` listing and whether each file is a symlink, stamped ``(inode, mtime_ns)``.
154
273
 
@@ -465,13 +584,16 @@ class Session:
465
584
  never this session itself. A resolved-path seen-set (seeded with
466
585
  :attr:`path`) dedupes: the first occurrence of a path wins, so a
467
586
  tree-discovered sidechain outranks an equal attachment, and symlink
468
- cycles terminate. An unreadable transcript is skipped but its children
469
- are still walked; a structurally malformed line raises, as
470
- :attr:`subagents` does.
471
-
472
- Each reached transcript is parsed and lifted once per ``(size, mtime)``
473
- stamp and memoized across walks, so a predicate that walks repeatedly —
474
- or a resident process that re-walks per event — reparses only what grew.
587
+ cycles terminate. An unreadable or unparseable transcript is skipped
588
+ but its children are still walked.
589
+
590
+ Each reached transcript is parsed and lifted once per ``(size, mtime,
591
+ ctime, inode)`` stamp and memoized across walks, and one that has only
592
+ grown is extended by its appended lines, so a predicate that walks
593
+ repeatedly — or a resident process that re-walks per event — parses
594
+ only what was appended. Transcripts are assumed append-only: a
595
+ rewrite of earlier bytes that keeps the file's consumed tail and
596
+ grows it is read as growth, as :data:`DEEP_LIFTS` documents.
475
597
  """
476
598
  return deep_sessions(self)
477
599
 
@@ -578,9 +700,11 @@ class Session:
578
700
  def deep_inputs(self) -> Iterator[PredicateInputs]:
579
701
  """This window's :class:`PredicateInputs`, then those of every transcript :meth:`walk` reaches.
580
702
 
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.
703
+ The walk order and dedupe match :meth:`walk`, but a reached transcript stays lifted only
704
+ once it has changed: each one's inputs are held per ``(size, mtime, ctime, inode)`` stamp,
705
+ a transcript is lifted again only once its stamp moves, and from then on one that has only
706
+ grown is extended by its appended lines under the append-only contract :data:`DEEP_LIFTS`
707
+ documents. Prefer it to :meth:`walk` for any predicate these inputs can answer.
584
708
  """
585
709
  yield self.predicate_inputs
586
710
  for path, depth, spawned_by in reachable_transcripts(self):
@@ -906,17 +1030,18 @@ def sidechain_listing(parent: Path) -> tuple[tuple[Path, Path], ...]:
906
1030
 
907
1031
  def load_predicate_inputs(path: Path, depth: int, spawned_by: ToolUseId | None) -> PredicateInputs | None:
908
1032
  try:
909
- stat = path.stat()
1033
+ stamp = stamp_of(path.stat())
910
1034
  except OSError:
911
1035
  return None
912
- stamp = (stat.st_size, stat.st_mtime_ns, stat.st_ino)
913
1036
  if (held := SIDECHAIN_INPUTS.get(path, stamp)) is not None:
1037
+ DEEP_LIFTS.idle_release((path, depth, spawned_by), stamp)
914
1038
  return held
915
1039
  try:
916
- deep = DEEP_LIFTS.get((path, depth, spawned_by), stamp) or lift_deep_session(path, depth, spawned_by)
1040
+ deep, read, settled = deep_session_at(path, depth, spawned_by, stamp, hold=SIDECHAIN_INPUTS.holds(path))
917
1041
  except OSError:
918
1042
  return None
919
- return SIDECHAIN_INPUTS.put(path, stamp, PredicateInputs.of(deep.session))
1043
+ inputs = PredicateInputs.of(deep.session)
1044
+ return SIDECHAIN_INPUTS.put(path, read, inputs) if settled else inputs
920
1045
 
921
1046
 
922
1047
  def deep_sessions(root: Session) -> Iterator[DeepSession]:
@@ -925,28 +1050,136 @@ def deep_sessions(root: Session) -> Iterator[DeepSession]:
925
1050
  yield deep
926
1051
 
927
1052
 
928
- def lift_deep_session(path: Path, depth: int, spawned_by: ToolUseId | None) -> DeepSession:
929
- transcript = parse(path)
930
- session_id = session_id_of(transcript.events) or SessionId(path.stem)
931
- return DeepSession(
932
- session=Session.from_activity(SessionActivity.from_events(session_id, transcript.events), path=path),
933
- path=path,
934
- provider=transcript.provider,
935
- depth=depth,
936
- spawned_by=spawned_by,
937
- )
938
-
939
-
940
1053
  def load_deep_session(path: Path, depth: int, spawned_by: ToolUseId | None) -> DeepSession | None:
941
1054
  try:
942
- stat = path.stat()
1055
+ return deep_session_at(path, depth, spawned_by, stamp_of(path.stat()), hold=True)[0]
943
1056
  except OSError:
944
1057
  return None
945
- stamp = (stat.st_size, stat.st_mtime_ns, stat.st_ino)
1058
+
1059
+
1060
+ def deep_session_at(
1061
+ path: Path, depth: int, spawned_by: ToolUseId | None, stamp: LiftStamp, *, hold: bool
1062
+ ) -> tuple[DeepSession, LiftStamp, bool]:
946
1063
  key = (path, depth, spawned_by)
947
1064
  if (held := DEEP_LIFTS.get(key, stamp)) is not None:
948
- return held
1065
+ return held.deep, stamp, True
1066
+ if UNREADABLE.has(path, stamp):
1067
+ raise UnparseableTranscript(path, stamp, True)
1068
+ stale = DEEP_LIFTS.take(key)
949
1069
  try:
950
- return DEEP_LIFTS.put(key, stamp, lift_deep_session(path, depth, spawned_by))
951
- except OSError:
1070
+ if stale is not None and (grown := grown_lift(stale, path, stamp)) is not None:
1071
+ return DEEP_LIFTS.put(key, grown).deep, stamp, True
1072
+ lifted, settled = lift_deep_session(path, depth, spawned_by)
1073
+ except UnparseableTranscript as failure:
1074
+ if failure.settled:
1075
+ UNREADABLE.put(path, failure.stamp)
1076
+ raise
1077
+ if settled and (hold or stale is not None):
1078
+ DEEP_LIFTS.put(key, lifted)
1079
+ return lifted.deep, lifted.stamp, settled
1080
+
1081
+
1082
+ def lift_deep_session(path: Path, depth: int, spawned_by: ToolUseId | None) -> tuple[DeepLift, bool]:
1083
+ with path.open("rb") as handle:
1084
+ stamp = stamp_of(before := os.fstat(handle.fileno()))
1085
+ raw = handle.read(before.st_size)
1086
+ settled = stamp_of(os.fstat(handle.fileno())) == stamp
1087
+ events = list((transcript := parsed(path, raw, stamp, settled)).events)
1088
+ session_id = (found := session_id_of(events)) or SessionId(path.stem)
1089
+ cut = raw.rfind(b"\n") + 1
1090
+ committed = len(events) if cut == len(raw) else len(events) - len(parsed(path, raw[cut:], stamp, settled).events)
1091
+ activity = (lift := ActivityLift(session_id)).extend(events[:committed])
1092
+ return DeepLift(
1093
+ stamp=stamp,
1094
+ deep=DeepSession(
1095
+ session=Session.from_activity(
1096
+ activity if committed == len(events) else SessionActivity.from_events(session_id, events), path=path
1097
+ ),
1098
+ path=path,
1099
+ provider=transcript.provider,
1100
+ depth=depth,
1101
+ spawned_by=spawned_by,
1102
+ ),
1103
+ lift=lift,
1104
+ consumed=cut,
1105
+ fence=raw[max(cut - DEEP_LIFT_FENCE, 0) : cut],
1106
+ found=found,
1107
+ provider=transcript.provider if events else None,
1108
+ grown_at=monotonic(),
1109
+ ), settled
1110
+
1111
+
1112
+ def grown_lift(held: DeepLift, path: Path, stamp: LiftStamp) -> DeepLift | None:
1113
+ if (
1114
+ held.provider not in (None, "claude")
1115
+ or stamp[3] != held.stamp[3]
1116
+ or stamp[0] <= held.stamp[0]
1117
+ or stamp[0] < held.consumed
1118
+ ):
1119
+ return None
1120
+ if (read := appended_bytes(path, held.consumed - len(held.fence), stamp[0], stamp)) is None or not read.startswith(
1121
+ held.fence
1122
+ ):
952
1123
  return None
1124
+ appended = read[len(held.fence) :]
1125
+ cut = appended.rfind(b"\n") + 1
1126
+ if (committed_chunk := parsed(path, appended[:cut], stamp, True)).provider != "claude":
1127
+ return None
1128
+ if cut != len(appended) and (tail_chunk := parsed(path, appended[cut:], stamp, True)).provider != "claude":
1129
+ return None
1130
+ committed = list(committed_chunk.events)
1131
+ tail = [] if cut == len(appended) else list(tail_chunk.events)
1132
+ found = held.found or session_id_of([*committed, *tail])
1133
+ if (found or SessionId(path.stem)) != held.lift.session_id:
1134
+ return None
1135
+ activity = held.lift.extend(committed)
1136
+ session = Session.from_activity(
1137
+ activity
1138
+ if not tail
1139
+ else SessionActivity.from_events(
1140
+ held.lift.session_id, [*(event for turn in activity.turns for event in turn.events), *tail]
1141
+ ),
1142
+ path=path,
1143
+ )
1144
+ return DeepLift(
1145
+ stamp=stamp,
1146
+ deep=replace(held.deep, session=session),
1147
+ lift=held.lift,
1148
+ consumed=held.consumed + cut,
1149
+ fence=(held.fence + appended[:cut])[-DEEP_LIFT_FENCE:],
1150
+ found=found,
1151
+ provider=held.provider or ("claude" if committed or tail else None),
1152
+ grown_at=monotonic(),
1153
+ )
1154
+
1155
+
1156
+ def appended_bytes(path: Path, start: int, stop: int, stamp: LiftStamp) -> bytes | None:
1157
+ with path.open("rb") as handle:
1158
+ handle.seek(start)
1159
+ read = handle.read(stop - start)
1160
+ settled = stamp_of(os.fstat(handle.fileno())) == stamp
1161
+ return read if len(read) == stop - start and settled else None
1162
+
1163
+
1164
+ class UnparseableTranscript(OSError):
1165
+ """A transcript a typed parse rejected deterministically, not a filesystem failure.
1166
+
1167
+ Raised only for a line the parser cannot type — schema drift — so a walk can hold the
1168
+ failure by stamp, where a transient ``OSError`` (EACCES, EIO, an ENOENT race) must
1169
+ retry. An ``OSError`` subclass so both still skip the transcript rather than raise.
1170
+ Carries the stamp of the descriptor the rejected bytes were read from and whether that
1171
+ read settled, so the failure is held under the bytes actually parsed, never under the
1172
+ stat that routed the read, and never when the file moved under it.
1173
+ """
1174
+
1175
+ def __init__(self, path: Path, stamp: LiftStamp, settled: bool) -> None:
1176
+ super().__init__(f"unreadable transcript: {path}")
1177
+ self.stamp = stamp
1178
+ self.settled = settled
1179
+
1180
+
1181
+ def parsed(path: Path, raw: bytes, stamp: LiftStamp, settled: bool) -> Transcript:
1182
+ try:
1183
+ return parse(raw)
1184
+ except (KeyError, ValueError):
1185
+ raise UnparseableTranscript(path, stamp, settled) from None
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cc-transcript"
3
- version = "14.18.0" # informational — releases stamp the version from the git tag
3
+ version = "14.19.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