cc-transcript 14.18.0__tar.gz → 14.20.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.

Potentially problematic release.


This version of cc-transcript might be problematic. Click here for more details.

Files changed (162) hide show
  1. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/PKG-INFO +1 -1
  2. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/_native.pyi +5 -0
  3. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/activity.py +4 -2
  4. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/query.py +304 -55
  5. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/pyproject.toml +1 -1
  6. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/context.rs +13 -4
  7. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/parse.rs +1 -0
  8. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/render.rs +56 -17
  9. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/types.rs +1 -0
  10. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/attachment.rs +9 -1
  11. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/Cargo.lock +0 -0
  12. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/Cargo.toml +0 -0
  13. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/LICENSE +0 -0
  14. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/README.md +0 -0
  15. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/__init__.py +0 -0
  16. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/__init__.pyi +0 -0
  17. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/activity_probe.py +0 -0
  18. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/builders.py +0 -0
  19. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/codex.py +0 -0
  20. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/command.py +0 -0
  21. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/context.py +0 -0
  22. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/corrections.py +0 -0
  23. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/cost.py +0 -0
  24. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/decision_store.py +0 -0
  25. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/decisions.py +0 -0
  26. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/discovery.py +0 -0
  27. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/disktruth.py +0 -0
  28. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/evidence.py +0 -0
  29. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/extract/__init__.py +0 -0
  30. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/extract/correct.py +0 -0
  31. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/facts.py +0 -0
  32. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/filterspec.py +0 -0
  33. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/heartbeats.py +0 -0
  34. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/ids.py +0 -0
  35. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/judge/__init__.py +0 -0
  36. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/judge/llm.py +0 -0
  37. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/judge/similar.py +0 -0
  38. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/judge/verdicts.py +0 -0
  39. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/ledger.py +0 -0
  40. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/literals.py +0 -0
  41. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/__init__.py +0 -0
  42. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/candidates.py +0 -0
  43. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/confidence.py +0 -0
  44. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/engine.py +0 -0
  45. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/formats.py +0 -0
  46. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/sampling.py +0 -0
  47. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/signals.py +0 -0
  48. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/sourcekind.py +0 -0
  49. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/spec.py +0 -0
  50. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/mining/store.py +0 -0
  51. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/models.py +0 -0
  52. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/nlp.py +0 -0
  53. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/notifications.py +0 -0
  54. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/parser.py +0 -0
  55. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/py.typed +0 -0
  56. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/render.py +0 -0
  57. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/sentiment/__init__.py +0 -0
  58. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/sentiment/buckets.py +0 -0
  59. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/sentiment/data/afinn-en-165.tsv +0 -0
  60. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/sentiment/data/domain_overrides.tsv +0 -0
  61. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/sentiment/data/en-ewt.udpipe +0 -0
  62. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/sentiment/engine.py +0 -0
  63. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/sentiment/lexicon.py +0 -0
  64. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/sentiment/scorespec.py +0 -0
  65. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/synthetic.py +0 -0
  66. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/tools.py +0 -0
  67. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/cc_transcript/watch.py +0 -0
  68. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/Cargo.toml +0 -0
  69. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/blame.rs +0 -0
  70. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/corrections.rs +0 -0
  71. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/digest.rs +0 -0
  72. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/facts.rs +0 -0
  73. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/grep.rs +0 -0
  74. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/list.rs +0 -0
  75. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/mod.rs +0 -0
  76. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/scratchpad.rs +0 -0
  77. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/show.rs +0 -0
  78. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/slice.rs +0 -0
  79. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/stats.rs +0 -0
  80. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/commands/watch.rs +0 -0
  81. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/lib.rs +0 -0
  82. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/main.rs +0 -0
  83. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/output.rs +0 -0
  84. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/target.rs +0 -0
  85. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/cli/src/timearg.rs +0 -0
  86. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/Cargo.toml +0 -0
  87. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/benches/parse.rs +0 -0
  88. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/benches/render.rs +0 -0
  89. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/benches/toolcall.rs +0 -0
  90. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/activity.rs +0 -0
  91. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/actor.rs +0 -0
  92. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/blame.rs +0 -0
  93. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/buckets.rs +0 -0
  94. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/codex/discovery.rs +0 -0
  95. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/codex/lower.rs +0 -0
  96. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/codex/mod.rs +0 -0
  97. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/codex/parse.rs +0 -0
  98. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/codex/probe.rs +0 -0
  99. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/codex/protocol.rs +0 -0
  100. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/codex/types.rs +0 -0
  101. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/command.rs +0 -0
  102. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/corrections.rs +0 -0
  103. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/cost.rs +0 -0
  104. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/discovery.rs +0 -0
  105. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/facts.rs +0 -0
  106. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/feedback.rs +0 -0
  107. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/filter.rs +0 -0
  108. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/gateway.rs +0 -0
  109. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/ids.rs +0 -0
  110. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/judge.rs +0 -0
  111. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/lib.rs +0 -0
  112. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/literals/command.rs +0 -0
  113. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/literals/corrections.rs +0 -0
  114. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/literals/feedback.rs +0 -0
  115. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/literals/mining.rs +0 -0
  116. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/literals/mod.rs +0 -0
  117. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/literals/protocol.rs +0 -0
  118. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/notifications.rs +0 -0
  119. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/protocol.rs +0 -0
  120. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/pystr.rs +0 -0
  121. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/query.rs +0 -0
  122. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/rng.rs +0 -0
  123. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/schema.rs +0 -0
  124. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/sqlite.rs +0 -0
  125. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/toolcall.rs +0 -0
  126. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/value.rs +0 -0
  127. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/core/src/watch.rs +0 -0
  128. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/Cargo.toml +0 -0
  129. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/build.rs +0 -0
  130. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/data/command_prefix_pins.tsv +0 -0
  131. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/actor_bridge.rs +0 -0
  132. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/bin/stub_gen.rs +0 -0
  133. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/codex.rs +0 -0
  134. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/command.rs +0 -0
  135. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/context.rs +0 -0
  136. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/corrections.rs +0 -0
  137. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/discovery.rs +0 -0
  138. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/feedback.rs +0 -0
  139. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/judge.rs +0 -0
  140. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/lexicon.rs +0 -0
  141. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/lib.rs +0 -0
  142. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/mining.rs +0 -0
  143. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/nlp.rs +0 -0
  144. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/python.rs +0 -0
  145. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/render.rs +0 -0
  146. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/score.rs +0 -0
  147. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/sqlite.rs +0 -0
  148. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/toolcall.rs +0 -0
  149. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/blocks.rs +0 -0
  150. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/command.rs +0 -0
  151. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/convert.rs +0 -0
  152. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/dunder.rs +0 -0
  153. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/events.rs +0 -0
  154. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/meta.rs +0 -0
  155. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/mod.rs +0 -0
  156. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/print.rs +0 -0
  157. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/store.rs +0 -0
  158. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/system.rs +0 -0
  159. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/toolcall.rs +0 -0
  160. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/toolresult.rs +0 -0
  161. {cc_transcript-14.18.0 → cc_transcript-14.20.0}/rust/crates/py/src/views/transcript.rs +0 -0
  162. {cc_transcript-14.18.0 → cc_transcript-14.20.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.20.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -1498,12 +1498,17 @@ class QueuedCommand:
1498
1498
  plain-string prompt (e.g. an image-paste payload).
1499
1499
  command_mode: How the command was queued, e.g. ``prompt`` or
1500
1500
  ``task-notification``, or None.
1501
+ origin: Who sent a ``prompt``-mode command: ``human`` for the user,
1502
+ ``peer`` for another agent or session, ``channel`` for an MCP
1503
+ channel event; None when the transcript does not record it.
1501
1504
  """
1502
1505
  __match_args__: typing.ClassVar[tuple[str, ...]]
1503
1506
  @property
1504
1507
  def prompt(self) -> typing.Optional[builtins.str]: ...
1505
1508
  @property
1506
1509
  def command_mode(self) -> typing.Optional[builtins.str]: ...
1510
+ @property
1511
+ def origin(self) -> typing.Optional[builtins.str]: ...
1507
1512
 
1508
1513
  @typing.final
1509
1514
  class ReadCall(ToolCallBase):
@@ -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,21 +10,23 @@ 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
27
- from cc_transcript.models import AssistantEvent, SystemEvent, ToolResultBlock, UserEvent
29
+ from cc_transcript.models import AssistantEvent, AttachmentEvent, QueuedCommand, SystemEvent, ToolResultBlock, UserEvent
28
30
  from cc_transcript.notifications import Notifications
29
31
  from cc_transcript.parser import parse
30
32
  from cc_transcript.tools import (
@@ -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
 
@@ -520,6 +642,16 @@ class Session:
520
642
  """The window's last ``n`` events."""
521
643
  return windowed(self, max(len(self) - n, 0), len(self))
522
644
 
645
+ def recent_messages(self, n: int) -> Session:
646
+ """The window reaching back over its last ``n`` messages, for positive ``n``.
647
+
648
+ A message is a user or assistant event or a queued command. The hook,
649
+ reminder, system and mode events between messages ride along without
650
+ counting, so harness noise never crowds the conversation out of the window.
651
+ """
652
+ messages = [index for index, event in enumerate(self.events) if is_message(event)]
653
+ return windowed(self, messages[-n] if n <= len(messages) else 0, len(self))
654
+
523
655
  @property
524
656
  def current_turn(self) -> Session:
525
657
  """The one-turn view of the window's last turn."""
@@ -578,9 +710,11 @@ class Session:
578
710
  def deep_inputs(self) -> Iterator[PredicateInputs]:
579
711
  """This window's :class:`PredicateInputs`, then those of every transcript :meth:`walk` reaches.
580
712
 
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.
713
+ The walk order and dedupe match :meth:`walk`, but a reached transcript stays lifted only
714
+ once it has changed: each one's inputs are held per ``(size, mtime, ctime, inode)`` stamp,
715
+ a transcript is lifted again only once its stamp moves, and from then on one that has only
716
+ grown is extended by its appended lines under the append-only contract :data:`DEEP_LIFTS`
717
+ documents. Prefer it to :meth:`walk` for any predicate these inputs can answer.
584
718
  """
585
719
  yield self.predicate_inputs
586
720
  for path, depth, spawned_by in reachable_transcripts(self):
@@ -836,6 +970,12 @@ class DeepView:
836
970
  return iter(self.sessions)
837
971
 
838
972
 
973
+ def is_message(event: TranscriptEvent) -> bool:
974
+ return isinstance(event, UserEvent | AssistantEvent) or (
975
+ isinstance(event, AttachmentEvent) and isinstance(event.detail, QueuedCommand)
976
+ )
977
+
978
+
839
979
  def windowed(session: Session, start: int, stop: int) -> Session:
840
980
  turns: list[Turn] = []
841
981
  base = 0
@@ -906,17 +1046,18 @@ def sidechain_listing(parent: Path) -> tuple[tuple[Path, Path], ...]:
906
1046
 
907
1047
  def load_predicate_inputs(path: Path, depth: int, spawned_by: ToolUseId | None) -> PredicateInputs | None:
908
1048
  try:
909
- stat = path.stat()
1049
+ stamp = stamp_of(path.stat())
910
1050
  except OSError:
911
1051
  return None
912
- stamp = (stat.st_size, stat.st_mtime_ns, stat.st_ino)
913
1052
  if (held := SIDECHAIN_INPUTS.get(path, stamp)) is not None:
1053
+ DEEP_LIFTS.idle_release((path, depth, spawned_by), stamp)
914
1054
  return held
915
1055
  try:
916
- deep = DEEP_LIFTS.get((path, depth, spawned_by), stamp) or lift_deep_session(path, depth, spawned_by)
1056
+ deep, read, settled = deep_session_at(path, depth, spawned_by, stamp, hold=SIDECHAIN_INPUTS.holds(path))
917
1057
  except OSError:
918
1058
  return None
919
- return SIDECHAIN_INPUTS.put(path, stamp, PredicateInputs.of(deep.session))
1059
+ inputs = PredicateInputs.of(deep.session)
1060
+ return SIDECHAIN_INPUTS.put(path, read, inputs) if settled else inputs
920
1061
 
921
1062
 
922
1063
  def deep_sessions(root: Session) -> Iterator[DeepSession]:
@@ -925,28 +1066,136 @@ def deep_sessions(root: Session) -> Iterator[DeepSession]:
925
1066
  yield deep
926
1067
 
927
1068
 
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
1069
  def load_deep_session(path: Path, depth: int, spawned_by: ToolUseId | None) -> DeepSession | None:
941
1070
  try:
942
- stat = path.stat()
1071
+ return deep_session_at(path, depth, spawned_by, stamp_of(path.stat()), hold=True)[0]
943
1072
  except OSError:
944
1073
  return None
945
- stamp = (stat.st_size, stat.st_mtime_ns, stat.st_ino)
1074
+
1075
+
1076
+ def deep_session_at(
1077
+ path: Path, depth: int, spawned_by: ToolUseId | None, stamp: LiftStamp, *, hold: bool
1078
+ ) -> tuple[DeepSession, LiftStamp, bool]:
946
1079
  key = (path, depth, spawned_by)
947
1080
  if (held := DEEP_LIFTS.get(key, stamp)) is not None:
948
- return held
1081
+ return held.deep, stamp, True
1082
+ if UNREADABLE.has(path, stamp):
1083
+ raise UnparseableTranscript(path, stamp, True)
1084
+ stale = DEEP_LIFTS.take(key)
949
1085
  try:
950
- return DEEP_LIFTS.put(key, stamp, lift_deep_session(path, depth, spawned_by))
951
- except OSError:
1086
+ if stale is not None and (grown := grown_lift(stale, path, stamp)) is not None:
1087
+ return DEEP_LIFTS.put(key, grown).deep, stamp, True
1088
+ lifted, settled = lift_deep_session(path, depth, spawned_by)
1089
+ except UnparseableTranscript as failure:
1090
+ if failure.settled:
1091
+ UNREADABLE.put(path, failure.stamp)
1092
+ raise
1093
+ if settled and (hold or stale is not None):
1094
+ DEEP_LIFTS.put(key, lifted)
1095
+ return lifted.deep, lifted.stamp, settled
1096
+
1097
+
1098
+ def lift_deep_session(path: Path, depth: int, spawned_by: ToolUseId | None) -> tuple[DeepLift, bool]:
1099
+ with path.open("rb") as handle:
1100
+ stamp = stamp_of(before := os.fstat(handle.fileno()))
1101
+ raw = handle.read(before.st_size)
1102
+ settled = stamp_of(os.fstat(handle.fileno())) == stamp
1103
+ events = list((transcript := parsed(path, raw, stamp, settled)).events)
1104
+ session_id = (found := session_id_of(events)) or SessionId(path.stem)
1105
+ cut = raw.rfind(b"\n") + 1
1106
+ committed = len(events) if cut == len(raw) else len(events) - len(parsed(path, raw[cut:], stamp, settled).events)
1107
+ activity = (lift := ActivityLift(session_id)).extend(events[:committed])
1108
+ return DeepLift(
1109
+ stamp=stamp,
1110
+ deep=DeepSession(
1111
+ session=Session.from_activity(
1112
+ activity if committed == len(events) else SessionActivity.from_events(session_id, events), path=path
1113
+ ),
1114
+ path=path,
1115
+ provider=transcript.provider,
1116
+ depth=depth,
1117
+ spawned_by=spawned_by,
1118
+ ),
1119
+ lift=lift,
1120
+ consumed=cut,
1121
+ fence=raw[max(cut - DEEP_LIFT_FENCE, 0) : cut],
1122
+ found=found,
1123
+ provider=transcript.provider if events else None,
1124
+ grown_at=monotonic(),
1125
+ ), settled
1126
+
1127
+
1128
+ def grown_lift(held: DeepLift, path: Path, stamp: LiftStamp) -> DeepLift | None:
1129
+ if (
1130
+ held.provider not in (None, "claude")
1131
+ or stamp[3] != held.stamp[3]
1132
+ or stamp[0] <= held.stamp[0]
1133
+ or stamp[0] < held.consumed
1134
+ ):
952
1135
  return None
1136
+ if (read := appended_bytes(path, held.consumed - len(held.fence), stamp[0], stamp)) is None or not read.startswith(
1137
+ held.fence
1138
+ ):
1139
+ return None
1140
+ appended = read[len(held.fence) :]
1141
+ cut = appended.rfind(b"\n") + 1
1142
+ if (committed_chunk := parsed(path, appended[:cut], stamp, True)).provider != "claude":
1143
+ return None
1144
+ if cut != len(appended) and (tail_chunk := parsed(path, appended[cut:], stamp, True)).provider != "claude":
1145
+ return None
1146
+ committed = list(committed_chunk.events)
1147
+ tail = [] if cut == len(appended) else list(tail_chunk.events)
1148
+ found = held.found or session_id_of([*committed, *tail])
1149
+ if (found or SessionId(path.stem)) != held.lift.session_id:
1150
+ return None
1151
+ activity = held.lift.extend(committed)
1152
+ session = Session.from_activity(
1153
+ activity
1154
+ if not tail
1155
+ else SessionActivity.from_events(
1156
+ held.lift.session_id, [*(event for turn in activity.turns for event in turn.events), *tail]
1157
+ ),
1158
+ path=path,
1159
+ )
1160
+ return DeepLift(
1161
+ stamp=stamp,
1162
+ deep=replace(held.deep, session=session),
1163
+ lift=held.lift,
1164
+ consumed=held.consumed + cut,
1165
+ fence=(held.fence + appended[:cut])[-DEEP_LIFT_FENCE:],
1166
+ found=found,
1167
+ provider=held.provider or ("claude" if committed or tail else None),
1168
+ grown_at=monotonic(),
1169
+ )
1170
+
1171
+
1172
+ def appended_bytes(path: Path, start: int, stop: int, stamp: LiftStamp) -> bytes | None:
1173
+ with path.open("rb") as handle:
1174
+ handle.seek(start)
1175
+ read = handle.read(stop - start)
1176
+ settled = stamp_of(os.fstat(handle.fileno())) == stamp
1177
+ return read if len(read) == stop - start and settled else None
1178
+
1179
+
1180
+ class UnparseableTranscript(OSError):
1181
+ """A transcript a typed parse rejected deterministically, not a filesystem failure.
1182
+
1183
+ Raised only for a line the parser cannot type — schema drift — so a walk can hold the
1184
+ failure by stamp, where a transient ``OSError`` (EACCES, EIO, an ENOENT race) must
1185
+ retry. An ``OSError`` subclass so both still skip the transcript rather than raise.
1186
+ Carries the stamp of the descriptor the rejected bytes were read from and whether that
1187
+ read settled, so the failure is held under the bytes actually parsed, never under the
1188
+ stat that routed the read, and never when the file moved under it.
1189
+ """
1190
+
1191
+ def __init__(self, path: Path, stamp: LiftStamp, settled: bool) -> None:
1192
+ super().__init__(f"unreadable transcript: {path}")
1193
+ self.stamp = stamp
1194
+ self.settled = settled
1195
+
1196
+
1197
+ def parsed(path: Path, raw: bytes, stamp: LiftStamp, settled: bool) -> Transcript:
1198
+ try:
1199
+ return parse(raw)
1200
+ except (KeyError, ValueError):
1201
+ 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.20.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"
@@ -11,7 +11,7 @@ use sonic_rs::{JsonContainerTrait, JsonValueTrait, Value};
11
11
  use crate::activity::{LiftedSession, ToolUse, Turn};
12
12
  use crate::ids::{encode_string, tool_digest, EventRef};
13
13
  use crate::parse::parse_questions;
14
- use crate::render::{clip, render_tool_call, render_turn, Budget};
14
+ use crate::render::{clip, human_queued_prompt, render_tool_call, render_turn, Budget};
15
15
  use crate::toolcall::{parse_tool_call, parse_tool_result, ToolResult};
16
16
  use crate::types::{ContentBlock, Entry, Question, ToolUseBlock};
17
17
  use crate::value::{field, field_bool, field_str};
@@ -19,7 +19,7 @@ use crate::value::{field, field_bool, field_str};
19
19
  pub const SCHEMA: &str = "cc-transcript.context/2";
20
20
  pub const PREVIEW_SCHEMA: &str = "cc-transcript.preview/1";
21
21
  pub const SUMMARY_LABEL: &str = "[summary fidelity — transcript unavailable]";
22
- const ASK_USER_QUESTION: &str = "AskUserQuestion";
22
+ pub(crate) const ASK_USER_QUESTION: &str = "AskUserQuestion";
23
23
 
24
24
  /// One structured preview part of a turn (context.py `Preview`): typed text, a tool
25
25
  /// call reduced to name + digest + summary, or an AskUserQuestion round with its
@@ -549,8 +549,17 @@ fn build_previews(turn: &Turn, budget: &Budget) -> Vec<Preview> {
549
549
  }
550
550
  let mut uses = turn.tool_uses.iter();
551
551
  for &event in &turn.events {
552
- let Entry::Assistant(assistant) = event else {
553
- continue;
552
+ let assistant = match event {
553
+ Entry::Assistant(assistant) => assistant,
554
+ Entry::Attachment(attachment) => {
555
+ if let Some(prompt) = human_queued_prompt(&attachment.detail) {
556
+ parts.push(Preview::Text {
557
+ text: clip(prompt, budget.turn_chars),
558
+ });
559
+ }
560
+ continue;
561
+ }
562
+ _ => continue,
554
563
  };
555
564
  for block in &assistant.blocks {
556
565
  match block {
@@ -478,6 +478,7 @@ fn parse_attachment_detail(data: &Value) -> AttachmentDetail {
478
478
  Some("queued_command") => AttachmentDetail::QueuedCommand(QueuedCommand {
479
479
  prompt: opt_str(att, "prompt"),
480
480
  command_mode: opt_str(att, "commandMode"),
481
+ origin: field(att, "origin").and_then(|origin| opt_str(origin, "kind")),
481
482
  }),
482
483
  Some("deferred_tools_delta") => AttachmentDetail::DeferredToolsDelta(DeferredToolsDelta {
483
484
  added_names: str_array(att, "addedNames"),