cc-transcript 10.7.0__tar.gz → 11.0.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 (81) hide show
  1. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/PKG-INFO +1 -1
  2. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/__init__.py +144 -0
  3. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/activity.py +14 -2
  4. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/activity_probe.py +2 -1
  5. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/discovery.py +20 -1
  6. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/facts.py +6 -2
  7. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/filterspec.py +16 -3
  8. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/judge/verdicts.py +12 -2
  9. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/__init__.py +2 -0
  10. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/signals.py +44 -17
  11. cc_transcript-11.0.0/cc_transcript/models.py +965 -0
  12. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/notifications.py +3 -5
  13. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/parser.py +284 -11
  14. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/render.py +37 -2
  15. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/tools.py +362 -1
  16. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/pyproject.toml +1 -1
  17. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/Cargo.toml +3 -1
  18. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/activity.rs +8 -9
  19. cc_transcript-11.0.0/rust/src/event.rs +546 -0
  20. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/filter.rs +3 -0
  21. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/generated/protocol.rs +2 -0
  22. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/mining.rs +62 -25
  23. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/model.rs +19 -0
  24. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/parse.rs +267 -17
  25. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/protocol.rs +2 -1
  26. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/python.rs +2 -0
  27. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/types.rs +214 -0
  28. cc_transcript-10.7.0/cc_transcript/models.py +0 -501
  29. cc_transcript-10.7.0/rust/src/event.rs +0 -290
  30. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/Cargo.lock +0 -0
  31. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/Cargo.toml +0 -0
  32. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/LICENSE +0 -0
  33. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/README.md +0 -0
  34. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/__main__.py +0 -0
  35. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/_parser_rs.pyi +0 -0
  36. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/backend.py +0 -0
  37. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/builders.py +0 -0
  38. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/cli.py +0 -0
  39. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/command.py +0 -0
  40. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/context.py +0 -0
  41. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/corrections.py +0 -0
  42. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/corrections_cli.py +0 -0
  43. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/cost.py +0 -0
  44. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/decisions.py +0 -0
  45. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/disktruth.py +0 -0
  46. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/evidence.py +0 -0
  47. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/extract/__init__.py +0 -0
  48. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/extract/correct.py +0 -0
  49. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/ids.py +0 -0
  50. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/judge/__init__.py +0 -0
  51. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/judge/llm.py +0 -0
  52. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/judge/similar.py +0 -0
  53. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/ledger.py +0 -0
  54. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/candidates.py +0 -0
  55. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/confidence.py +0 -0
  56. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/engine.py +0 -0
  57. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/filterspec.py +0 -0
  58. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/formats.py +0 -0
  59. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/sourcekind.py +0 -0
  60. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/spec.py +0 -0
  61. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/mining/store.py +0 -0
  62. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/py.typed +0 -0
  63. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/query.py +0 -0
  64. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/rust.py +0 -0
  65. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/sentiment/__init__.py +0 -0
  66. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/sentiment/buckets.py +0 -0
  67. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/sentiment/engine.py +0 -0
  68. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/sentiment/lexicon.py +0 -0
  69. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/sentiment/scorespec.py +0 -0
  70. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/cc_transcript/store.py +0 -0
  71. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/build.rs +0 -0
  72. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/data/afinn-en-165.tsv +0 -0
  73. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/data/domain_overrides.tsv +0 -0
  74. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/command.rs +0 -0
  75. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/generated/command.rs +0 -0
  76. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/generated/mining.rs +0 -0
  77. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/generated/mod.rs +0 -0
  78. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/lexicon.rs +0 -0
  79. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/lib.rs +0 -0
  80. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/score.rs +0 -0
  81. {cc_transcript-10.7.0 → cc_transcript-11.0.0}/rust/src/value.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-transcript
3
- Version: 10.7.0
3
+ Version: 11.0.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -30,8 +30,11 @@ EXPORTS: dict[str, str] = {
30
30
  ),
31
31
  "cc_transcript.tools": (
32
32
  "TOOL_ALIASES",
33
+ "AskUserQuestionResult",
33
34
  "BashCall",
35
+ "BashResult",
34
36
  "EditCall",
37
+ "EditResult",
35
38
  "EditSpan",
36
39
  "ExitPlanModeCall",
37
40
  "GlobCall",
@@ -40,16 +43,27 @@ EXPORTS: dict[str, str] = {
40
43
  "MultiEditCall",
41
44
  "NotebookEditCall",
42
45
  "OtherCall",
46
+ "OtherResult",
47
+ "QuestionAnnotation",
43
48
  "ReadCall",
49
+ "ReadResult",
44
50
  "SkillCall",
51
+ "SkillResult",
45
52
  "TaskCall",
46
53
  "TaskCreateCall",
54
+ "TaskLaunchResult",
55
+ "TaskResult",
47
56
  "TaskUpdateCall",
57
+ "TextResult",
48
58
  "ToolCall",
49
59
  "ToolCallBase",
50
60
  "ToolInputError",
61
+ "ToolResult",
62
+ "ToolResultBase",
63
+ "ToolResultError",
51
64
  "WorkflowCall",
52
65
  "WriteCall",
66
+ "WriteResult",
53
67
  "expand_tool_names",
54
68
  "file_path_of",
55
69
  "hunks_of",
@@ -57,6 +71,7 @@ EXPORTS: dict[str, str] = {
57
71
  "mcp_access",
58
72
  "mcp_parts",
59
73
  "parse_tool_call",
74
+ "parse_tool_result",
60
75
  "tool_name_matches",
61
76
  ),
62
77
  "cc_transcript.command": (
@@ -68,29 +83,50 @@ EXPORTS: dict[str, str] = {
68
83
  "parse_command_line",
69
84
  ),
70
85
  "cc_transcript.models": (
86
+ "ApiError",
71
87
  "AssistantEvent",
88
+ "AsyncHookResponse",
89
+ "AttachmentDetail",
90
+ "AttachmentEvent",
91
+ "Attribution",
72
92
  "CacheCreation",
73
93
  "CcVersion",
94
+ "CompactBoundary",
74
95
  "ContentBlock",
75
96
  "EntryMeta",
76
97
  "FallbackBlock",
98
+ "HookAdditionalContext",
99
+ "HookBlockingError",
100
+ "HookCancelled",
101
+ "HookInfo",
102
+ "HookNonBlockingError",
103
+ "HookSuccess",
77
104
  "InitInfo",
78
105
  "McpServer",
106
+ "ModelRefusalFallback",
79
107
  "ModeEvent",
80
108
  "ModelUsage",
109
+ "OtherAttachment",
81
110
  "OtherBlock",
82
111
  "OtherEvent",
112
+ "OtherSystemDetail",
83
113
  "Plugin",
114
+ "PreservedMessages",
115
+ "PreservedSegment",
84
116
  "PrintMessage",
85
117
  "PrintResult",
86
118
  "Question",
119
+ "QueuedCommand",
87
120
  "ServerToolUse",
121
+ "StopHookSummary",
122
+ "SystemDetail",
88
123
  "SystemEvent",
89
124
  "TextBlock",
90
125
  "ThinkingBlock",
91
126
  "ToolResultBlock",
92
127
  "ToolUseBlock",
93
128
  "TranscriptEvent",
129
+ "TurnDuration",
94
130
  "Usage",
95
131
  "UserEvent",
96
132
  "thinking_chars",
@@ -478,15 +514,33 @@ if TYPE_CHECKING:
478
514
  from cc_transcript.ids import (
479
515
  tool_digest as tool_digest,
480
516
  )
517
+ from cc_transcript.models import (
518
+ ApiError as ApiError,
519
+ )
481
520
  from cc_transcript.models import (
482
521
  AssistantEvent as AssistantEvent,
483
522
  )
523
+ from cc_transcript.models import (
524
+ AsyncHookResponse as AsyncHookResponse,
525
+ )
526
+ from cc_transcript.models import (
527
+ AttachmentDetail as AttachmentDetail,
528
+ )
529
+ from cc_transcript.models import (
530
+ AttachmentEvent as AttachmentEvent,
531
+ )
532
+ from cc_transcript.models import (
533
+ Attribution as Attribution,
534
+ )
484
535
  from cc_transcript.models import (
485
536
  CacheCreation as CacheCreation,
486
537
  )
487
538
  from cc_transcript.models import (
488
539
  CcVersion as CcVersion,
489
540
  )
541
+ from cc_transcript.models import (
542
+ CompactBoundary as CompactBoundary,
543
+ )
490
544
  from cc_transcript.models import (
491
545
  ContentBlock as ContentBlock,
492
546
  )
@@ -496,6 +550,24 @@ if TYPE_CHECKING:
496
550
  from cc_transcript.models import (
497
551
  FallbackBlock as FallbackBlock,
498
552
  )
553
+ from cc_transcript.models import (
554
+ HookAdditionalContext as HookAdditionalContext,
555
+ )
556
+ from cc_transcript.models import (
557
+ HookBlockingError as HookBlockingError,
558
+ )
559
+ from cc_transcript.models import (
560
+ HookCancelled as HookCancelled,
561
+ )
562
+ from cc_transcript.models import (
563
+ HookInfo as HookInfo,
564
+ )
565
+ from cc_transcript.models import (
566
+ HookNonBlockingError as HookNonBlockingError,
567
+ )
568
+ from cc_transcript.models import (
569
+ HookSuccess as HookSuccess,
570
+ )
499
571
  from cc_transcript.models import (
500
572
  InitInfo as InitInfo,
501
573
  )
@@ -505,18 +577,33 @@ if TYPE_CHECKING:
505
577
  from cc_transcript.models import (
506
578
  ModeEvent as ModeEvent,
507
579
  )
580
+ from cc_transcript.models import (
581
+ ModelRefusalFallback as ModelRefusalFallback,
582
+ )
508
583
  from cc_transcript.models import (
509
584
  ModelUsage as ModelUsage,
510
585
  )
586
+ from cc_transcript.models import (
587
+ OtherAttachment as OtherAttachment,
588
+ )
511
589
  from cc_transcript.models import (
512
590
  OtherBlock as OtherBlock,
513
591
  )
514
592
  from cc_transcript.models import (
515
593
  OtherEvent as OtherEvent,
516
594
  )
595
+ from cc_transcript.models import (
596
+ OtherSystemDetail as OtherSystemDetail,
597
+ )
517
598
  from cc_transcript.models import (
518
599
  Plugin as Plugin,
519
600
  )
601
+ from cc_transcript.models import (
602
+ PreservedMessages as PreservedMessages,
603
+ )
604
+ from cc_transcript.models import (
605
+ PreservedSegment as PreservedSegment,
606
+ )
520
607
  from cc_transcript.models import (
521
608
  PrintMessage as PrintMessage,
522
609
  )
@@ -526,9 +613,18 @@ if TYPE_CHECKING:
526
613
  from cc_transcript.models import (
527
614
  Question as Question,
528
615
  )
616
+ from cc_transcript.models import (
617
+ QueuedCommand as QueuedCommand,
618
+ )
529
619
  from cc_transcript.models import (
530
620
  ServerToolUse as ServerToolUse,
531
621
  )
622
+ from cc_transcript.models import (
623
+ StopHookSummary as StopHookSummary,
624
+ )
625
+ from cc_transcript.models import (
626
+ SystemDetail as SystemDetail,
627
+ )
532
628
  from cc_transcript.models import (
533
629
  SystemEvent as SystemEvent,
534
630
  )
@@ -547,6 +643,9 @@ if TYPE_CHECKING:
547
643
  from cc_transcript.models import (
548
644
  TranscriptEvent as TranscriptEvent,
549
645
  )
646
+ from cc_transcript.models import (
647
+ TurnDuration as TurnDuration,
648
+ )
550
649
  from cc_transcript.models import (
551
650
  Usage as Usage,
552
651
  )
@@ -606,12 +705,21 @@ if TYPE_CHECKING:
606
705
  from cc_transcript.tools import (
607
706
  TOOL_ALIASES as TOOL_ALIASES,
608
707
  )
708
+ from cc_transcript.tools import (
709
+ AskUserQuestionResult as AskUserQuestionResult,
710
+ )
609
711
  from cc_transcript.tools import (
610
712
  BashCall as BashCall,
611
713
  )
714
+ from cc_transcript.tools import (
715
+ BashResult as BashResult,
716
+ )
612
717
  from cc_transcript.tools import (
613
718
  EditCall as EditCall,
614
719
  )
720
+ from cc_transcript.tools import (
721
+ EditResult as EditResult,
722
+ )
615
723
  from cc_transcript.tools import (
616
724
  EditSpan as EditSpan,
617
725
  )
@@ -636,21 +744,42 @@ if TYPE_CHECKING:
636
744
  from cc_transcript.tools import (
637
745
  OtherCall as OtherCall,
638
746
  )
747
+ from cc_transcript.tools import (
748
+ OtherResult as OtherResult,
749
+ )
750
+ from cc_transcript.tools import (
751
+ QuestionAnnotation as QuestionAnnotation,
752
+ )
639
753
  from cc_transcript.tools import (
640
754
  ReadCall as ReadCall,
641
755
  )
756
+ from cc_transcript.tools import (
757
+ ReadResult as ReadResult,
758
+ )
642
759
  from cc_transcript.tools import (
643
760
  SkillCall as SkillCall,
644
761
  )
762
+ from cc_transcript.tools import (
763
+ SkillResult as SkillResult,
764
+ )
645
765
  from cc_transcript.tools import (
646
766
  TaskCall as TaskCall,
647
767
  )
648
768
  from cc_transcript.tools import (
649
769
  TaskCreateCall as TaskCreateCall,
650
770
  )
771
+ from cc_transcript.tools import (
772
+ TaskLaunchResult as TaskLaunchResult,
773
+ )
774
+ from cc_transcript.tools import (
775
+ TaskResult as TaskResult,
776
+ )
651
777
  from cc_transcript.tools import (
652
778
  TaskUpdateCall as TaskUpdateCall,
653
779
  )
780
+ from cc_transcript.tools import (
781
+ TextResult as TextResult,
782
+ )
654
783
  from cc_transcript.tools import (
655
784
  ToolCall as ToolCall,
656
785
  )
@@ -660,12 +789,24 @@ if TYPE_CHECKING:
660
789
  from cc_transcript.tools import (
661
790
  ToolInputError as ToolInputError,
662
791
  )
792
+ from cc_transcript.tools import (
793
+ ToolResult as ToolResult,
794
+ )
795
+ from cc_transcript.tools import (
796
+ ToolResultBase as ToolResultBase,
797
+ )
798
+ from cc_transcript.tools import (
799
+ ToolResultError as ToolResultError,
800
+ )
663
801
  from cc_transcript.tools import (
664
802
  WorkflowCall as WorkflowCall,
665
803
  )
666
804
  from cc_transcript.tools import (
667
805
  WriteCall as WriteCall,
668
806
  )
807
+ from cc_transcript.tools import (
808
+ WriteResult as WriteResult,
809
+ )
669
810
  from cc_transcript.tools import (
670
811
  expand_tool_names as expand_tool_names,
671
812
  )
@@ -687,6 +828,9 @@ if TYPE_CHECKING:
687
828
  from cc_transcript.tools import (
688
829
  parse_tool_call as parse_tool_call,
689
830
  )
831
+ from cc_transcript.tools import (
832
+ parse_tool_result as parse_tool_result,
833
+ )
690
834
  from cc_transcript.tools import (
691
835
  tool_name_matches as tool_name_matches,
692
836
  )
@@ -21,7 +21,7 @@ from cc_transcript.filterspec import event_meta
21
21
  from cc_transcript.ids import EventRef
22
22
  from cc_transcript.models import AssistantEvent, ToolResultBlock, ToolUseBlock, UserEvent
23
23
  from cc_transcript.parser import TranscriptParser
24
- from cc_transcript.tools import file_path_of, hunks_of, parse_tool_call
24
+ from cc_transcript.tools import file_path_of, hunks_of, parse_tool_call, parse_tool_result
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from collections.abc import Mapping, Sequence
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
30
30
 
31
31
  from cc_transcript.ids import SessionId, ToolUseId
32
32
  from cc_transcript.models import TranscriptEvent
33
- from cc_transcript.tools import Hunk, ToolCall
33
+ from cc_transcript.tools import Hunk, ToolCall, ToolResult
34
34
 
35
35
  UserClassifier = Callable[["UserEvent"], bool]
36
36
  """Decides which :class:`~cc_transcript.models.UserEvent` objects open a turn."""
@@ -78,6 +78,18 @@ class ToolUse:
78
78
  """Milliseconds from the call to its result, or None without a result."""
79
79
  return None if self.result_ts is None else round((self.result_ts - self.ts).total_seconds() * 1000)
80
80
 
81
+ @property
82
+ def typed_result(self) -> ToolResult | None:
83
+ """The result parsed into the typed result hierarchy, or None without a result.
84
+
85
+ The join point for :func:`~cc_transcript.tools.parse_tool_result`: pairs
86
+ this use's tool name with its result block's ``tool_use_result`` payload,
87
+ degrading to :class:`~cc_transcript.tools.OtherResult` on any shape drift.
88
+ """
89
+ if self.result is None:
90
+ return None
91
+ return parse_tool_result(self.call.name, self.result.tool_use_result, on_error="other")
92
+
81
93
 
82
94
  @dataclass(frozen=True, slots=True)
83
95
  class Edit:
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Literal
23
23
  from cc_transcript.activity import native_user_classifier
24
24
  from cc_transcript.models import (
25
25
  AssistantEvent,
26
+ AttachmentEvent,
26
27
  SystemEvent,
27
28
  ToolResultBlock,
28
29
  ToolUseBlock,
@@ -170,7 +171,7 @@ def probe_events(
170
171
  (
171
172
  int(event.meta.timestamp.timestamp())
172
173
  for event in events
173
- if isinstance(event, UserEvent | AssistantEvent | SystemEvent)
174
+ if isinstance(event, UserEvent | AssistantEvent | SystemEvent | AttachmentEvent)
174
175
  ),
175
176
  default=None,
176
177
  ),
@@ -14,6 +14,15 @@ if TYPE_CHECKING:
14
14
 
15
15
  CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
16
16
 
17
+ TRANSCRIPT_MEMO: dict[tuple[SessionId, Path], Path] = {}
18
+ """Positive-hit memo for :func:`find_transcript_sync`, keyed by ``(session_id, resolved root)``.
19
+
20
+ Only successful lookups are cached: a miss may become a hit once Claude Code
21
+ writes the file, so ``None`` is never stored. A cached hit is revalidated with
22
+ ``Path.exists`` before it is reused — a transcript file can be pruned from disk —
23
+ and a stale entry falls through to a fresh scan.
24
+ """
25
+
17
26
 
18
27
  class TranscriptExpiredError(RuntimeError):
19
28
  """A session's transcript file is gone from disk.
@@ -106,10 +115,18 @@ def find_transcript_sync(session_id: SessionId, *, root: Path | None = None) ->
106
115
  :data:`CLAUDE_PROJECTS_DIR`), resolving symlinks — cc-pool gives one
107
116
  transcript several path spellings — and deduping by resolved real path.
108
117
 
118
+ A successful lookup is memoized in ``TRANSCRIPT_MEMO`` under
119
+ ``(session_id, resolved root)`` and revalidated on the next hit, so a
120
+ repeated probe skips the recursive glob while a pruned transcript still
121
+ forces a fresh scan.
122
+
109
123
  Returns:
110
124
  The newest-mtime real path, or None when no transcript exists.
111
125
  """
112
126
  base = root or CLAUDE_PROJECTS_DIR
127
+ key = (session_id, base.resolve())
128
+ if (cached := TRANSCRIPT_MEMO.get(key)) is not None and cached.exists():
129
+ return cached
113
130
  if not base.exists():
114
131
  return None
115
132
  candidates: dict[Path, float] = {}
@@ -120,7 +137,9 @@ def find_transcript_sync(session_id: SessionId, *, root: Path | None = None) ->
120
137
  candidates[real] = real.stat().st_mtime
121
138
  except OSError:
122
139
  continue
123
- return max(candidates, key=candidates.__getitem__, default=None)
140
+ if (hit := max(candidates, key=candidates.__getitem__, default=None)) is not None:
141
+ TRANSCRIPT_MEMO[key] = hit
142
+ return hit
124
143
 
125
144
 
126
145
  async def find_transcript(session_id: SessionId, *, root: Path | None = None) -> Path | None:
@@ -16,7 +16,7 @@ from dataclasses import dataclass
16
16
  from typing import TYPE_CHECKING, Literal
17
17
 
18
18
  from cc_transcript.activity import SessionActivity
19
- from cc_transcript.filterspec import DENIAL_PREFIX, embedded_user_text, session_id_of
19
+ from cc_transcript.filterspec import DENIAL_KIND_USER_REJECTED, embedded_user_text, session_id_of
20
20
  from cc_transcript.tools import BashCall, file_path_of, mcp_access, mcp_parts
21
21
 
22
22
  if TYPE_CHECKING:
@@ -31,7 +31,7 @@ if TYPE_CHECKING:
31
31
 
32
32
 
33
33
  def is_denial(block: ToolResultBlock) -> bool:
34
- return block.is_error and block.content.startswith(DENIAL_PREFIX)
34
+ return block.denial_kind == DENIAL_KIND_USER_REJECTED
35
35
 
36
36
 
37
37
  def denial_fields(result: ToolResultBlock | None) -> tuple[bool, str | None]:
@@ -67,6 +67,8 @@ class ToolFact:
67
67
  file_path: The file the call targets, when it targets one.
68
68
  is_error: Whether the call's result reported a failure.
69
69
  denied: Whether the result is a user rejection of the tool use.
70
+ denial_kind: The structured tool-denial kind (``user-rejected`` for a human
71
+ rejection, ``permission-rule`` for a hook/guard block), or None.
70
72
  user_said: The user's verbatim instruction embedded in a denial, else None.
71
73
  duration_ms: Milliseconds from the call to its result, or None without one.
72
74
  """
@@ -84,6 +86,7 @@ class ToolFact:
84
86
  file_path: str | None
85
87
  is_error: bool
86
88
  denied: bool
89
+ denial_kind: str | None
87
90
  user_said: str | None
88
91
  duration_ms: int | None
89
92
 
@@ -106,6 +109,7 @@ def fact_of(use: ToolUse, session_id: SessionId, path: Path, prefixes: tuple[str
106
109
  file_path=file_path_of(call),
107
110
  is_error=use.result.is_error if use.result is not None else False,
108
111
  denied=denied,
112
+ denial_kind=use.result.denial_kind if use.result is not None else None,
109
113
  user_said=user_said,
110
114
  duration_ms=use.duration_ms,
111
115
  )
@@ -27,7 +27,15 @@ from typing import TYPE_CHECKING, Literal
27
27
 
28
28
  import orjson
29
29
 
30
- from cc_transcript.models import AssistantEvent, ModeEvent, OtherEvent, SystemEvent, ToolUseBlock, UserEvent
30
+ from cc_transcript.models import (
31
+ AssistantEvent,
32
+ AttachmentEvent,
33
+ ModeEvent,
34
+ OtherEvent,
35
+ SystemEvent,
36
+ ToolUseBlock,
37
+ UserEvent,
38
+ )
31
39
 
32
40
  if TYPE_CHECKING:
33
41
  from collections.abc import Iterable, Iterator, Sequence
@@ -35,7 +43,7 @@ if TYPE_CHECKING:
35
43
 
36
44
  from cc_transcript.models import EntryMeta, SessionId, ToolUseId, TranscriptEvent
37
45
 
38
- EventKind = Literal["user", "assistant", "system", "mode", "other"]
46
+ EventKind = Literal["user", "assistant", "system", "mode", "other", "attachment"]
39
47
  MetaFlagName = Literal["is_sidechain", "is_meta", "is_compact_summary", "is_visible_in_transcript_only"]
40
48
 
41
49
  TRAILING_PUNCT = ".!?…,;:"
@@ -77,6 +85,9 @@ STOP_HOOK_GROUPS: tuple[tuple[str, str], ...] = (("stop_hook", r"Stop hook feedb
77
85
 
78
86
  # Raw CC-injected protocol strings carried in tool-result content, not user-authored text.
79
87
  DENIAL_PREFIX = "The user doesn't want to proceed with this tool use. The tool use was rejected"
88
+ # Record-level ``toolDenialKind`` values: a human rejection versus a hook/guard block.
89
+ DENIAL_KIND_USER_REJECTED = "user-rejected"
90
+ DENIAL_KIND_PERMISSION_RULE = "permission-rule"
80
91
  USER_SAID_MARKER = "To tell you how to proceed, the user said:\n"
81
92
  USER_SAID_TRAILER = "Note: The user's next message"
82
93
  ANSWERED_PREFIX = "Your questions have been answered: "
@@ -371,6 +382,8 @@ def event_kind(event: TranscriptEvent) -> EventKind:
371
382
  return "mode"
372
383
  case OtherEvent():
373
384
  return "other"
385
+ case AttachmentEvent():
386
+ return "attachment"
374
387
 
375
388
 
376
389
  def event_text(event: TranscriptEvent) -> str:
@@ -383,7 +396,7 @@ def event_text(event: TranscriptEvent) -> str:
383
396
 
384
397
  def event_meta(event: TranscriptEvent) -> EntryMeta | None:
385
398
  match event:
386
- case UserEvent(meta=meta) | AssistantEvent(meta=meta) | SystemEvent(meta=meta):
399
+ case UserEvent(meta=meta) | AssistantEvent(meta=meta) | SystemEvent(meta=meta) | AttachmentEvent(meta=meta):
387
400
  return meta
388
401
  case _:
389
402
  return None
@@ -282,6 +282,7 @@ class VerdictStoreMixin:
282
282
  prompt_version: int,
283
283
  limit: int | None = None,
284
284
  refresh_summary: bool = False,
285
+ probe_hydration: bool = True,
285
286
  ) -> list[dict[str, object]]:
286
287
  """Returns events lacking a verdict for ``(role, prompt_version)``, unjudged first.
287
288
 
@@ -298,7 +299,15 @@ class VerdictStoreMixin:
298
299
  full fidelity once their windows hydrate again. A summary row
299
300
  whose context window no longer hydrates — its transcript expired
300
301
  or a ref was compacted away — is dropped, so a dead transcript
301
- stops burning the cap.
302
+ stops burning the cap (unless ``probe_hydration`` is False).
303
+ probe_hydration: When True (default), each summary-refresh row is
304
+ probed with a transcript-discovery hydration check and dropped
305
+ when its window no longer hydrates. When False, that per-row
306
+ probe — and the recursive transcript scan behind it — is skipped
307
+ entirely: every summary-refresh row is returned unprobed, so some
308
+ may name a transcript that is no longer discoverable. Only the
309
+ ``refresh_summary`` path probes, so this flag is a no-op
310
+ otherwise. The default preserves the probing behavior exactly.
302
311
 
303
312
  Returns:
304
313
  One dict per event with the columns needed to build its prompt.
@@ -327,7 +336,8 @@ class VerdictStoreMixin:
327
336
  ) as cur:
328
337
  async for raw in cur:
329
338
  row = dict(raw)
330
- if row.pop("verdict_id") is None or await hydratable(str(row["context_json"])):
339
+ fresh = row.pop("verdict_id") is None
340
+ if not probe_hydration or fresh or await hydratable(str(row["context_json"])):
331
341
  kept.append(row)
332
342
  if limit is not None and len(kept) >= limit:
333
343
  break
@@ -24,6 +24,8 @@ from __future__ import annotations
24
24
  from cc_transcript.filterspec import (
25
25
  ANSWERED_PREFIX,
26
26
  ANSWERED_TRAILER,
27
+ DENIAL_KIND_PERMISSION_RULE,
28
+ DENIAL_KIND_USER_REJECTED,
27
29
  DENIAL_PREFIX,
28
30
  USER_SAID_MARKER,
29
31
  USER_SAID_TRAILER,
@@ -20,6 +20,7 @@ and reasons instead of re-deriving them.
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ from collections.abc import Mapping
23
24
  from dataclasses import dataclass, field
24
25
  from typing import TYPE_CHECKING, NamedTuple
25
26
 
@@ -61,12 +62,19 @@ from cc_transcript.mining.spec import (
61
62
  regex_review_comments,
62
63
  score_user_message,
63
64
  )
64
- from cc_transcript.models import AssistantEvent, ModeEvent, ToolResultBlock, ToolUseBlock, UserEvent
65
- from cc_transcript.tools import matches_names
65
+ from cc_transcript.models import (
66
+ AssistantEvent,
67
+ ModeEvent,
68
+ ToolResultBlock,
69
+ ToolUseBlock,
70
+ UserEvent,
71
+ parse_questions,
72
+ )
73
+ from cc_transcript.tools import AskUserQuestionResult, matches_names
66
74
 
67
75
  if TYPE_CHECKING:
68
76
  import re
69
- from collections.abc import Iterator, Mapping, Sequence
77
+ from collections.abc import Iterator, Sequence
70
78
  from datetime import datetime
71
79
  from typing import Any
72
80
 
@@ -416,16 +424,6 @@ class AnsweredPair(NamedTuple):
416
424
  notes: str | None
417
425
 
418
426
 
419
- def answered_question_results(event: UserEvent) -> Iterator[ToolResultBlock]:
420
- return (
421
- block
422
- for block in event.blocks
423
- if isinstance(block, ToolResultBlock)
424
- if not block.is_error
425
- if block.content.startswith(ANSWERED_PREFIX)
426
- )
427
-
428
-
429
427
  def split_answer_segment(segment: str) -> tuple[str, str | None, str | None]:
430
428
  if (at := segment.find(ANSWER_PREVIEW_SEP)) != -1:
431
429
  rest = segment[at + len(ANSWER_PREVIEW_SEP) :]
@@ -534,18 +532,47 @@ def question_answer_signal(
534
532
  )
535
533
 
536
534
 
535
+ def structured_answered_pairs(payload: Mapping[str, Any], use: ToolUseBlock) -> Iterator[AnsweredPair]:
536
+ result = AskUserQuestionResult.from_raw("AskUserQuestion", payload)
537
+ questions = parse_questions(payload.get("questions"))
538
+ for question in use.questions or () if questions is None else questions:
539
+ annotation = result.annotations.get(question.question)
540
+ yield AnsweredPair(
541
+ question,
542
+ result.answers.get(question.question),
543
+ annotation.preview if annotation is not None else None,
544
+ annotation.notes if annotation is not None else None,
545
+ )
546
+
547
+
548
+ def banner_answered_pairs(block: ToolResultBlock, use: ToolUseBlock) -> Iterator[AnsweredPair]:
549
+ if (
550
+ block.is_error
551
+ or not block.content.startswith(ANSWERED_PREFIX)
552
+ or not block.content.endswith(ANSWERED_TRAILER)
553
+ or (questions := use.questions) is None
554
+ ):
555
+ return
556
+ yield from answered_pairs(block.content[len(ANSWERED_PREFIX) : -len(ANSWERED_TRAILER)], questions)
557
+
558
+
559
+ def question_pairs(block: ToolResultBlock, use: ToolUseBlock) -> Iterator[AnsweredPair]:
560
+ if not block.is_error and isinstance(payload := block.tool_use_result, Mapping) and "answers" in payload:
561
+ return structured_answered_pairs(payload, use)
562
+ return banner_answered_pairs(block, use)
563
+
564
+
537
565
  def iter_ask_user_question_signals(events: Sequence[TranscriptEvent], spec: MiningSpec) -> Iterator[MiningSignal]:
538
566
  uses = tool_uses(events)
539
567
  return (
540
568
  signal
541
569
  for index, event in enumerate(events)
542
570
  if isinstance(event, UserEvent)
543
- for block in answered_question_results(event)
571
+ for block in event.blocks
572
+ if isinstance(block, ToolResultBlock)
544
573
  if (use := uses.get(block.tool_use_id)) is not None
545
574
  if use.name == "AskUserQuestion"
546
- if block.content.endswith(ANSWERED_TRAILER)
547
- if (questions := use.questions) is not None
548
- for pair in answered_pairs(block.content[len(ANSWERED_PREFIX) : -len(ANSWERED_TRAILER)], questions)
575
+ for pair in question_pairs(block, use)
549
576
  if (signal := question_answer_signal(events, event, index, pair, spec)) is not None
550
577
  )
551
578