cc-transcript 13.0.0__tar.gz → 13.2.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 (87) hide show
  1. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/Cargo.lock +186 -0
  2. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/PKG-INFO +1 -1
  3. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/__init__.py +1 -0
  4. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/command.py +134 -2
  5. cc_transcript-13.2.0/cc_transcript/heartbeats.py +117 -0
  6. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/pyproject.toml +1 -1
  7. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/Cargo.toml +10 -0
  8. cc_transcript-13.2.0/rust/benches/parse.rs +77 -0
  9. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/lib.rs +1 -1
  10. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/Cargo.toml +0 -0
  11. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/LICENSE +0 -0
  12. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/README.md +0 -0
  13. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/__main__.py +0 -0
  14. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/_parser_rs.pyi +0 -0
  15. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/activity.py +0 -0
  16. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/activity_probe.py +0 -0
  17. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/backend.py +0 -0
  18. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/builders.py +0 -0
  19. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/cli.py +0 -0
  20. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/context.py +0 -0
  21. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/corrections.py +0 -0
  22. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/corrections_cli.py +0 -0
  23. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/cost.py +0 -0
  24. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/decisions.py +0 -0
  25. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/discovery.py +0 -0
  26. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/disktruth.py +0 -0
  27. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/evidence.py +0 -0
  28. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/extract/__init__.py +0 -0
  29. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/extract/correct.py +0 -0
  30. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/facts.py +0 -0
  31. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/filterspec.py +0 -0
  32. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/ids.py +0 -0
  33. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/judge/__init__.py +0 -0
  34. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/judge/llm.py +0 -0
  35. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/judge/similar.py +0 -0
  36. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/judge/verdicts.py +0 -0
  37. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/ledger.py +0 -0
  38. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/__init__.py +0 -0
  39. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/candidates.py +0 -0
  40. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/confidence.py +0 -0
  41. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/engine.py +0 -0
  42. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/filterspec.py +0 -0
  43. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/formats.py +0 -0
  44. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/sampling.py +0 -0
  45. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/signals.py +0 -0
  46. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/sourcekind.py +0 -0
  47. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/spec.py +0 -0
  48. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/mining/store.py +0 -0
  49. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/models.py +0 -0
  50. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/nlp.py +0 -0
  51. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/notifications.py +0 -0
  52. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/parser.py +0 -0
  53. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/py.typed +0 -0
  54. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/query.py +0 -0
  55. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/render.py +0 -0
  56. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/sentiment/__init__.py +0 -0
  57. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/sentiment/buckets.py +0 -0
  58. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/sentiment/data/afinn-en-165.tsv +0 -0
  59. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/sentiment/data/domain_overrides.tsv +0 -0
  60. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/sentiment/data/en-ewt.udpipe +0 -0
  61. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/sentiment/engine.py +0 -0
  62. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/sentiment/lexicon.py +0 -0
  63. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/sentiment/scorespec.py +0 -0
  64. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/store.py +0 -0
  65. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/tools.py +0 -0
  66. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/cc_transcript/watch.py +0 -0
  67. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/build.rs +0 -0
  68. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/data/command_prefix_pins.tsv +0 -0
  69. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/rustfmt.toml +0 -0
  70. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/activity.rs +0 -0
  71. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/command.rs +0 -0
  72. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/event.rs +0 -0
  73. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/filter.rs +0 -0
  74. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/generated/command.rs +0 -0
  75. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/generated/mining.rs +0 -0
  76. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/generated/mod.rs +0 -0
  77. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/generated/protocol.rs +0 -0
  78. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/lexicon.rs +0 -0
  79. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/mining.rs +0 -0
  80. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/model.rs +0 -0
  81. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/nlp.rs +0 -0
  82. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/parse.rs +0 -0
  83. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/protocol.rs +0 -0
  84. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/python.rs +0 -0
  85. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/score.rs +0 -0
  86. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/types.rs +0 -0
  87. {cc_transcript-13.0.0 → cc_transcript-13.2.0}/rust/src/value.rs +0 -0
@@ -30,6 +30,18 @@ dependencies = [
30
30
  "memchr",
31
31
  ]
32
32
 
33
+ [[package]]
34
+ name = "anes"
35
+ version = "0.1.6"
36
+ source = "registry+https://github.com/rust-lang/crates.io-index"
37
+ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
38
+
39
+ [[package]]
40
+ name = "anstyle"
41
+ version = "1.0.14"
42
+ source = "registry+https://github.com/rust-lang/crates.io-index"
43
+ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
44
+
33
45
  [[package]]
34
46
  name = "autocfg"
35
47
  version = "1.5.1"
@@ -60,6 +72,12 @@ version = "1.11.1"
60
72
  source = "registry+https://github.com/rust-lang/crates.io-index"
61
73
  checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
62
74
 
75
+ [[package]]
76
+ name = "cast"
77
+ version = "0.3.0"
78
+ source = "registry+https://github.com/rust-lang/crates.io-index"
79
+ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
80
+
63
81
  [[package]]
64
82
  name = "cc"
65
83
  version = "1.2.63"
@@ -75,6 +93,7 @@ name = "cc_transcript_parser"
75
93
  version = "0.9.0"
76
94
  dependencies = [
77
95
  "chrono",
96
+ "criterion",
78
97
  "crossbeam-channel",
79
98
  "memchr",
80
99
  "num_cpus",
@@ -113,6 +132,58 @@ dependencies = [
113
132
  "num-traits",
114
133
  ]
115
134
 
135
+ [[package]]
136
+ name = "ciborium"
137
+ version = "0.2.2"
138
+ source = "registry+https://github.com/rust-lang/crates.io-index"
139
+ checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
140
+ dependencies = [
141
+ "ciborium-io",
142
+ "ciborium-ll",
143
+ "serde",
144
+ ]
145
+
146
+ [[package]]
147
+ name = "ciborium-io"
148
+ version = "0.2.2"
149
+ source = "registry+https://github.com/rust-lang/crates.io-index"
150
+ checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
151
+
152
+ [[package]]
153
+ name = "ciborium-ll"
154
+ version = "0.2.2"
155
+ source = "registry+https://github.com/rust-lang/crates.io-index"
156
+ checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
157
+ dependencies = [
158
+ "ciborium-io",
159
+ "half",
160
+ ]
161
+
162
+ [[package]]
163
+ name = "clap"
164
+ version = "4.6.1"
165
+ source = "registry+https://github.com/rust-lang/crates.io-index"
166
+ checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
167
+ dependencies = [
168
+ "clap_builder",
169
+ ]
170
+
171
+ [[package]]
172
+ name = "clap_builder"
173
+ version = "4.6.0"
174
+ source = "registry+https://github.com/rust-lang/crates.io-index"
175
+ checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
176
+ dependencies = [
177
+ "anstyle",
178
+ "clap_lex",
179
+ ]
180
+
181
+ [[package]]
182
+ name = "clap_lex"
183
+ version = "1.1.0"
184
+ source = "registry+https://github.com/rust-lang/crates.io-index"
185
+ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
186
+
116
187
  [[package]]
117
188
  name = "crc32fast"
118
189
  version = "1.5.0"
@@ -122,6 +193,40 @@ dependencies = [
122
193
  "cfg-if",
123
194
  ]
124
195
 
196
+ [[package]]
197
+ name = "criterion"
198
+ version = "0.5.1"
199
+ source = "registry+https://github.com/rust-lang/crates.io-index"
200
+ checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
201
+ dependencies = [
202
+ "anes",
203
+ "cast",
204
+ "ciborium",
205
+ "clap",
206
+ "criterion-plot",
207
+ "is-terminal",
208
+ "itertools",
209
+ "num-traits",
210
+ "once_cell",
211
+ "oorandom",
212
+ "regex",
213
+ "serde",
214
+ "serde_derive",
215
+ "serde_json",
216
+ "tinytemplate",
217
+ "walkdir",
218
+ ]
219
+
220
+ [[package]]
221
+ name = "criterion-plot"
222
+ version = "0.5.0"
223
+ source = "registry+https://github.com/rust-lang/crates.io-index"
224
+ checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
225
+ dependencies = [
226
+ "cast",
227
+ "itertools",
228
+ ]
229
+
125
230
  [[package]]
126
231
  name = "crossbeam-channel"
127
232
  version = "0.5.15"
@@ -156,6 +261,12 @@ version = "0.8.21"
156
261
  source = "registry+https://github.com/rust-lang/crates.io-index"
157
262
  checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
158
263
 
264
+ [[package]]
265
+ name = "crunchy"
266
+ version = "0.2.4"
267
+ source = "registry+https://github.com/rust-lang/crates.io-index"
268
+ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
269
+
159
270
  [[package]]
160
271
  name = "either"
161
272
  version = "1.16.0"
@@ -259,6 +370,17 @@ dependencies = [
259
370
  "wasip2",
260
371
  ]
261
372
 
373
+ [[package]]
374
+ name = "half"
375
+ version = "2.7.1"
376
+ source = "registry+https://github.com/rust-lang/crates.io-index"
377
+ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
378
+ dependencies = [
379
+ "cfg-if",
380
+ "crunchy",
381
+ "zerocopy",
382
+ ]
383
+
262
384
  [[package]]
263
385
  name = "hashbrown"
264
386
  version = "0.17.1"
@@ -303,6 +425,26 @@ dependencies = [
303
425
  "hashbrown",
304
426
  ]
305
427
 
428
+ [[package]]
429
+ name = "is-terminal"
430
+ version = "0.4.17"
431
+ source = "registry+https://github.com/rust-lang/crates.io-index"
432
+ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
433
+ dependencies = [
434
+ "hermit-abi",
435
+ "libc",
436
+ "windows-sys",
437
+ ]
438
+
439
+ [[package]]
440
+ name = "itertools"
441
+ version = "0.10.5"
442
+ source = "registry+https://github.com/rust-lang/crates.io-index"
443
+ checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
444
+ dependencies = [
445
+ "either",
446
+ ]
447
+
306
448
  [[package]]
307
449
  name = "itoa"
308
450
  version = "1.0.18"
@@ -400,6 +542,12 @@ version = "1.21.4"
400
542
  source = "registry+https://github.com/rust-lang/crates.io-index"
401
543
  checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
402
544
 
545
+ [[package]]
546
+ name = "oorandom"
547
+ version = "11.1.5"
548
+ source = "registry+https://github.com/rust-lang/crates.io-index"
549
+ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
550
+
403
551
  [[package]]
404
552
  name = "percent-encoding"
405
553
  version = "2.3.2"
@@ -702,6 +850,15 @@ version = "1.0.22"
702
850
  source = "registry+https://github.com/rust-lang/crates.io-index"
703
851
  checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
704
852
 
853
+ [[package]]
854
+ name = "same-file"
855
+ version = "1.0.6"
856
+ source = "registry+https://github.com/rust-lang/crates.io-index"
857
+ checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
858
+ dependencies = [
859
+ "winapi-util",
860
+ ]
861
+
705
862
  [[package]]
706
863
  name = "serde"
707
864
  version = "1.0.228"
@@ -927,6 +1084,16 @@ dependencies = [
927
1084
  "syn 2.0.117",
928
1085
  ]
929
1086
 
1087
+ [[package]]
1088
+ name = "tinytemplate"
1089
+ version = "1.2.1"
1090
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1091
+ checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
1092
+ dependencies = [
1093
+ "serde",
1094
+ "serde_json",
1095
+ ]
1096
+
930
1097
  [[package]]
931
1098
  name = "tinyvec"
932
1099
  version = "1.11.0"
@@ -1045,6 +1212,16 @@ version = "0.9.5"
1045
1212
  source = "registry+https://github.com/rust-lang/crates.io-index"
1046
1213
  checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1047
1214
 
1215
+ [[package]]
1216
+ name = "walkdir"
1217
+ version = "2.5.0"
1218
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1219
+ checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
1220
+ dependencies = [
1221
+ "same-file",
1222
+ "winapi-util",
1223
+ ]
1224
+
1048
1225
  [[package]]
1049
1226
  name = "wasi"
1050
1227
  version = "0.11.1+wasi-snapshot-preview1"
@@ -1114,6 +1291,15 @@ dependencies = [
1114
1291
  "rustls-pki-types",
1115
1292
  ]
1116
1293
 
1294
+ [[package]]
1295
+ name = "winapi-util"
1296
+ version = "0.1.11"
1297
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1298
+ checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1299
+ dependencies = [
1300
+ "windows-sys",
1301
+ ]
1302
+
1117
1303
  [[package]]
1118
1304
  name = "windows-sys"
1119
1305
  version = "0.52.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-transcript
3
- Version: 13.0.0
3
+ Version: 13.2.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -78,6 +78,7 @@ EXPORTS: dict[str, str] = {
78
78
  "Command",
79
79
  "CommandLine",
80
80
  "CommandLineQuery",
81
+ "Occurrence",
81
82
  "Redirect",
82
83
  "command_prefixes",
83
84
  "parse_command_line",
@@ -10,7 +10,7 @@ prefix (``"git commit"``, ``"docker compose"``).
10
10
  from __future__ import annotations
11
11
 
12
12
  import re
13
- from dataclasses import dataclass
13
+ from dataclasses import dataclass, field
14
14
  from functools import cached_property, lru_cache
15
15
  from itertools import dropwhile
16
16
  from typing import TYPE_CHECKING
@@ -19,7 +19,7 @@ import tree_sitter_bash as tsbash
19
19
  from tree_sitter import Language, Node, Parser
20
20
 
21
21
  if TYPE_CHECKING:
22
- from collections.abc import Callable, Iterator, Sequence
22
+ from collections.abc import Callable, Iterator, Mapping, Sequence
23
23
 
24
24
  BASH_PARSER = Parser(Language(tsbash.language()))
25
25
 
@@ -52,6 +52,10 @@ WRAPPER_COMMANDS = frozenset({"sudo", "env", "time", "timeout", "nice", "nohup",
52
52
 
53
53
  ASSIGNMENT_RE = re.compile(r"^\w+=")
54
54
 
55
+ PIPE_GAP_RE = re.compile(r"\s*\|&?\s*")
56
+
57
+ REDIRECT_OP_TYPES = frozenset({"file_descriptor", ">", ">>", "<", "<<", ">&", "<&", ">|"})
58
+
55
59
 
56
60
  @dataclass(frozen=True)
57
61
  class Redirect:
@@ -74,6 +78,7 @@ class Command:
74
78
  args: tuple[str, ...]
75
79
  env: tuple[tuple[str, str], ...] = ()
76
80
  redirects: tuple[Redirect, ...] = ()
81
+ span: tuple[int, int] | None = field(default=None, compare=False, repr=False)
77
82
 
78
83
  @classmethod
79
84
  def parse(cls, raw: str) -> Command | None:
@@ -131,6 +136,7 @@ class Command:
131
136
  args=argv[1:],
132
137
  env=self.env,
133
138
  redirects=self.redirects,
139
+ span=self.span,
134
140
  )
135
141
 
136
142
  @property
@@ -252,6 +258,56 @@ class CommandLine:
252
258
  def q(self) -> CommandLineQuery:
253
259
  return CommandLineQuery(self)
254
260
 
261
+ @cached_property
262
+ def occurrences(self) -> tuple[Occurrence, ...]:
263
+ """One ``Occurrence`` per part, in line order."""
264
+ return tuple(Occurrence(self, index) for index in range(len(self.parts)))
265
+
266
+ def splice(self, replacements: Mapping[int, str]) -> str:
267
+ """Swap each indexed command's byte span for its replacement, preserving every other byte.
268
+
269
+ Args:
270
+ replacements: Command index → replacement text. Operates on
271
+ ``raw.encode()``; untouched bytes (operators, redirects,
272
+ heredoc bodies, comments) pass through verbatim.
273
+
274
+ Returns:
275
+ The rewritten line, decoded once at the end.
276
+
277
+ Raises:
278
+ ValueError: If an indexed command has no ``span``, or the spans are
279
+ out of order or overlap.
280
+ """
281
+ source = self.raw.encode()
282
+ out = bytearray()
283
+ cursor = 0
284
+ for index in sorted(replacements):
285
+ span = self.parts[index][0].span
286
+ if span is None:
287
+ raise ValueError(f"command at index {index} has no span")
288
+ start, end = span
289
+ if start < cursor:
290
+ raise ValueError(f"span {span} at index {index} overlaps or precedes cursor {cursor}")
291
+ out += source[cursor:start]
292
+ out += replacements[index].encode()
293
+ cursor = end
294
+ out += source[cursor:]
295
+ return out.decode()
296
+
297
+ def rewrite_occurrences(self, to: Callable[[Occurrence], str | None]) -> str | None:
298
+ """Map ``to`` over each occurrence and splice in its non-``None`` results.
299
+
300
+ Args:
301
+ to: Maps an ``Occurrence`` to replacement text, or ``None`` to leave
302
+ that command untouched.
303
+
304
+ Returns:
305
+ The spliced line, or ``None`` when ``to`` returned ``None`` for every
306
+ occurrence.
307
+ """
308
+ replacements = {occ.index: text for occ in self.occurrences if (text := to(occ)) is not None}
309
+ return self.splice(replacements) if replacements else None
310
+
255
311
  @staticmethod
256
312
  def node_text(node: Node) -> str:
257
313
  return node.text.decode() if node.text else ""
@@ -330,12 +386,16 @@ class CommandLine:
330
386
  case _:
331
387
  pass
332
388
 
389
+ content = [c for c in node.children if c.type != "file_redirect"]
390
+ span = (content[0].start_byte, content[-1].end_byte) if content else (node.start_byte, node.end_byte)
391
+
333
392
  return Command(
334
393
  raw=CommandLine.node_text(node),
335
394
  executable=executable,
336
395
  args=tuple(args),
337
396
  env=tuple(env),
338
397
  redirects=tuple(redirects),
398
+ span=span,
339
399
  )
340
400
 
341
401
  @staticmethod
@@ -352,13 +412,26 @@ class CommandLine:
352
412
  parts.extend(sub)
353
413
  return parts
354
414
 
415
+ @staticmethod
416
+ def redirect_absorbed_word(node: Node) -> bool:
417
+ """Whether a ``file_redirect`` swallowed a command word past its target.
418
+
419
+ Tree-sitter-bash folds a word that trails a redirect (the ``b`` in
420
+ ``echo a >out b``) into the ``file_redirect`` node beside the target, so
421
+ a redirect carrying more than one target word split the command's
422
+ arguments — leaving it no contiguous byte span to splice.
423
+ """
424
+ return sum(c.type not in REDIRECT_OP_TYPES for c in node.children) > 1
425
+
355
426
  @staticmethod
356
427
  def walk_redirected(node: Node) -> list[tuple[Command, str | None]]:
357
428
  redirects: list[Redirect] = []
358
429
  inner_parts: list[tuple[Command, str | None]] = []
430
+ broken = False
359
431
  for child in node.children:
360
432
  if child.type == "file_redirect":
361
433
  redirects.append(CommandLine.extract_redirect(child))
434
+ broken = broken or CommandLine.redirect_absorbed_word(child)
362
435
  else:
363
436
  inner_parts.extend(CommandLine.walk_node(child))
364
437
  if redirects and inner_parts:
@@ -370,6 +443,7 @@ class CommandLine:
370
443
  args=cmd.args,
371
444
  env=cmd.env,
372
445
  redirects=(*cmd.redirects, *redirects),
446
+ span=None if broken else cmd.span,
373
447
  ),
374
448
  op,
375
449
  )
@@ -382,6 +456,7 @@ class CommandLine:
382
456
  executable="",
383
457
  args=(),
384
458
  redirects=tuple(redirects),
459
+ span=(node.start_byte, node.end_byte),
385
460
  ),
386
461
  None,
387
462
  )
@@ -407,6 +482,63 @@ class CommandLine:
407
482
  return parts
408
483
 
409
484
 
485
+ @dataclass(frozen=True)
486
+ class Occurrence:
487
+ """One command of a ``CommandLine`` with its position and joining context.
488
+
489
+ Pairs the ``line`` with this command's ``index`` into ``line.parts``,
490
+ exposing the command, the operators on either side, and whether it sits in a
491
+ pipeline. Hook packs map over ``CommandLine.occurrences`` and return
492
+ replacement text per occurrence to rewrite individual commands in place.
493
+ """
494
+
495
+ line: CommandLine
496
+ index: int
497
+
498
+ @property
499
+ def command(self) -> Command:
500
+ """The command at this occurrence's index."""
501
+ return self.line.parts[self.index][0]
502
+
503
+ @property
504
+ def prev_op(self) -> str | None:
505
+ """The operator joining the previous command to this one; ``None`` at index 0."""
506
+ return self.line.parts[self.index - 1][1] if self.index > 0 else None
507
+
508
+ @property
509
+ def next_op(self) -> str | None:
510
+ """The operator joining this command to the next; ``None`` for the final command."""
511
+ return self.line.parts[self.index][1]
512
+
513
+ @cached_property
514
+ def piped(self) -> bool:
515
+ """Whether this command sits on either side of a pipe.
516
+
517
+ ``True`` when a neighboring operator is ``|``. Tree-sitter records no
518
+ operator token for ``|&``, and newline-separated statements also carry a
519
+ ``None`` operator, so a ``None`` neighbor falls back to inspecting the
520
+ decoded source gap toward that neighbor: it counts as piped only when the
521
+ gap is exactly one pipe operator token (``|`` or ``|&``) surrounded by
522
+ whitespace. Anything else in the gap — a comment, a redirect target, a
523
+ heredoc body, ``||``, an intervening statement — is not a pipe.
524
+ """
525
+ if self.prev_op == "|" or self.next_op == "|":
526
+ return True
527
+ span = self.command.span
528
+ if span is None:
529
+ return False
530
+ source = self.line.raw.encode()
531
+ if self.next_op is None and self.index + 1 < len(self.line.parts):
532
+ nxt = self.line.parts[self.index + 1][0].span
533
+ if nxt is not None and PIPE_GAP_RE.fullmatch(source[span[1] : nxt[0]].decode()):
534
+ return True
535
+ if self.prev_op is None and self.index > 0:
536
+ prev = self.line.parts[self.index - 1][0].span
537
+ if prev is not None and PIPE_GAP_RE.fullmatch(source[prev[1] : span[0]].decode()):
538
+ return True
539
+ return False
540
+
541
+
410
542
  @dataclass(frozen=True)
411
543
  class CommandLineQuery:
412
544
  """Predicate helpers for inspecting a parsed ``CommandLine``.
@@ -0,0 +1,117 @@
1
+ """Per-(session, event) dispatch heartbeats — proof a hook event reached dispatch at all.
2
+
3
+ The decision ledger records only hooks that *fired*, so it cannot distinguish "this event
4
+ was never dispatched" from "it dispatched and matched nothing". A heartbeat is written
5
+ unconditionally at dispatch entry, before matching, keyed by ``(session_id, event)`` and
6
+ counting hits — so a missing beat for an event a session should emit is an unambiguous
7
+ wiring gap. It shares the ``decisions.db`` file with
8
+ :class:`~cc_transcript.decisions.DecisionLog` in a separate ``dispatch_heartbeats`` table,
9
+ so the vendored, Go-byte-compared ``decisions`` schema is untouched. The upsert keeps at
10
+ most one row per ``(session, event)``, so the table stays small however hot the path.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sqlite3
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Self
19
+
20
+ if TYPE_CHECKING:
21
+ from cc_transcript.ids import SessionId
22
+
23
+ HEARTBEATS_DDL = """\
24
+ CREATE TABLE IF NOT EXISTS dispatch_heartbeats (
25
+ session_id TEXT NOT NULL,
26
+ event TEXT NOT NULL,
27
+ first_ts_ms INTEGER NOT NULL,
28
+ last_ts_ms INTEGER NOT NULL,
29
+ count INTEGER NOT NULL DEFAULT 1,
30
+ PRIMARY KEY (session_id, event)
31
+ );
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_heartbeats_session ON dispatch_heartbeats (session_id);
34
+ """
35
+
36
+ FILENAME = "decisions.db"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class Heartbeat:
41
+ """One ``(session, event)`` dispatch heartbeat.
42
+
43
+ Attributes:
44
+ session_id: The Claude session the event dispatched in.
45
+ event: The Claude Code event name, e.g. ``PreToolUse`` or ``Stop``.
46
+ first_ts_ms: Millisecond timestamp of the first dispatch this session.
47
+ last_ts_ms: Millisecond timestamp of the most recent dispatch.
48
+ count: How many times the event has dispatched this session.
49
+ """
50
+
51
+ session_id: SessionId
52
+ event: str
53
+ first_ts_ms: int
54
+ last_ts_ms: int
55
+ count: int
56
+
57
+
58
+ class HeartbeatLog:
59
+ """The ``dispatch_heartbeats`` table in ``~/.cc-transcript/decisions.db``.
60
+
61
+ Opened in WAL mode with a busy timeout, matching the decision ledger it shares a file
62
+ with. ``beat`` upserts one ``(session, event)`` row per dispatch, so a session holds at
63
+ most one row per event. Requires a local disk — WAL does not work over NFS.
64
+
65
+ Example:
66
+ >>> log = HeartbeatLog.open()
67
+ >>> log.beat(session_id, "PreToolUse", ts_ms)
68
+ >>> log.for_session(session_id)
69
+ """
70
+
71
+ def __init__(self, conn: sqlite3.Connection) -> None:
72
+ self.conn = conn
73
+
74
+ @classmethod
75
+ def open(cls, path: Path | None = None) -> Self:
76
+ """Opens (creating if needed) the heartbeat table at ``path`` (defaults to ``decisions.db``)."""
77
+ path = path or Path.home() / ".cc-transcript" / FILENAME
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ conn = sqlite3.connect(path, autocommit=True)
80
+ conn.row_factory = sqlite3.Row
81
+ conn.execute("PRAGMA journal_mode = WAL")
82
+ conn.execute("PRAGMA busy_timeout = 2000")
83
+ conn.executescript(HEARTBEATS_DDL)
84
+ return cls(conn)
85
+
86
+ def beat(self, session_id: SessionId, event: str, ts_ms: int) -> None:
87
+ """Records one dispatch of ``event`` in ``session_id`` at ``ts_ms`` (upsert; ``count += 1``)."""
88
+ self.conn.execute(
89
+ "INSERT INTO dispatch_heartbeats (session_id, event, first_ts_ms, last_ts_ms, count) "
90
+ "VALUES (?, ?, ?, ?, 1) "
91
+ "ON CONFLICT (session_id, event) DO UPDATE SET last_ts_ms = excluded.last_ts_ms, count = count + 1",
92
+ (session_id, event, ts_ms, ts_ms),
93
+ )
94
+
95
+ def for_session(self, session_id: SessionId) -> tuple[Heartbeat, ...]:
96
+ """Every ``(session, event)`` beat for ``session_id``, ordered by first dispatch."""
97
+ return tuple(
98
+ Heartbeat(
99
+ session_id=row["session_id"],
100
+ event=row["event"],
101
+ first_ts_ms=row["first_ts_ms"],
102
+ last_ts_ms=row["last_ts_ms"],
103
+ count=row["count"],
104
+ )
105
+ for row in self.conn.execute(
106
+ "SELECT * FROM dispatch_heartbeats WHERE session_id = ? ORDER BY first_ts_ms, event", (session_id,)
107
+ )
108
+ )
109
+
110
+ def events_seen(self, session_id: SessionId) -> frozenset[str]:
111
+ """The set of event names that have dispatched at least once in ``session_id``."""
112
+ return frozenset(
113
+ row["event"]
114
+ for row in self.conn.execute(
115
+ "SELECT event FROM dispatch_heartbeats WHERE session_id = ?", (session_id,)
116
+ )
117
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cc-transcript"
3
- version = "13.0.0"
3
+ version = "13.2.0"
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"
@@ -46,3 +46,13 @@ udpipe-rs = { version = "0.2", optional = true }
46
46
 
47
47
  [build-dependencies]
48
48
  pyo3-build-config = { version = "0.28", features = ["resolve-config"], optional = true }
49
+
50
+ [dev-dependencies]
51
+ # Criterion for the v14 P0 parse/filter/activity baseline (rust/benches/parse.rs).
52
+ # default-features off: skip the plotters/HTML report machinery; estimates.json
53
+ # (the medians BASELINE.json reads) is written by the core regardless.
54
+ criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] }
55
+
56
+ [[bench]]
57
+ name = "parse"
58
+ harness = false
@@ -0,0 +1,77 @@
1
+ //! v14 P0 baseline benches: parse, parse+filter, session-activity over the corpus at
2
+ //! `CC_BENCH_CORPUS` (default `../.fixtures/corpus`; build via scripts/gen_corpus.py).
3
+
4
+ use std::fs;
5
+ use std::path::{Path, PathBuf};
6
+
7
+ use criterion::{criterion_group, criterion_main, Criterion, Throughput};
8
+
9
+ use _parser_rs::activity::{session_activity, ActivityOpts};
10
+ use _parser_rs::filter::{compile_spec, spec_keep};
11
+ use _parser_rs::parse::parse_bytes;
12
+
13
+ // spec_to_json of cli.py SIGNAL_SPEC (cc_transcript/builders.py): keep_only + drop_junk + drop_synthetic + drop_sidechain + drop_empty x2.
14
+ const FILTER_SPEC_JSON: &str = r#"{"clauses":[{"predicate":{"kind":"KindIs","kinds":["assistant","user"]},"action":"drop","applies_to":[],"negate":true,"label":null},{"predicate":{"kind":"TextMatchesAny","groups":[["xml_tags","<(?:system[_-](?:instruction|reminder)|local-command-(?:stdout|stderr|caveat)|command-(?:name|message|args)|task-notification|persisted-output|output-file)\\b"],["local_command_caveat","Caveat: The messages below were generated by the user while running local commands\\."],["remaining_tasks_ack","REMAINING_TASKS_ACKNOWLEDGED"],["angle_token","<<[a-z][a-z0-9-]*>>"],["skill_base_dir","^Base directory for this skill:"],["hash_banner","(?:###\\s+[\\w][\\w \\-]{0,30}\\s+){3,}###"],["xml_tags_extra","\\A\\s*<(?:teammate-message|scheduled-task)(?:[\\s/>]|$)"],["augment_agent","\\A\\s*# Augment Agent(?:\\s|$)"],["role_reminder","\\A\\s*\\[Role Rem(?-i:[Ii])nder(?:[\\s:\\]]|$)"],["command_echo","\\A\\s*<bash-(?:input|stdout|stderr)\\b"]],"ignore_case":true},"action":"drop","applies_to":["user"],"negate":false,"label":null},{"predicate":{"kind":"ModelIs","models":["<synthetic>"]},"action":"drop","applies_to":["assistant"],"negate":false,"label":null},{"predicate":{"kind":"MetaFlag","flag":"is_sidechain"},"action":"drop","applies_to":[],"negate":false,"label":null},{"predicate":{"kind":"TextEmpty","consider_tool_use":false},"action":"drop","applies_to":["user"],"negate":false,"label":null},{"predicate":{"kind":"TextEmpty","consider_tool_use":true},"action":"drop","applies_to":["assistant"],"negate":false,"label":null}]}"#;
15
+
16
+ fn corpus_dir() -> PathBuf {
17
+ std::env::var_os("CC_BENCH_CORPUS")
18
+ .map(PathBuf::from)
19
+ .unwrap_or_else(|| PathBuf::from("../.fixtures/corpus"))
20
+ }
21
+
22
+ fn collect_jsonl(dir: &Path, out: &mut Vec<PathBuf>) {
23
+ let Ok(entries) = fs::read_dir(dir) else { return };
24
+ for entry in entries.flatten() {
25
+ let path = entry.path();
26
+ if path.is_dir() {
27
+ collect_jsonl(&path, out);
28
+ } else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
29
+ out.push(path);
30
+ }
31
+ }
32
+ }
33
+
34
+ fn largest_corpus_file() -> Vec<u8> {
35
+ let dir = corpus_dir();
36
+ let mut files = Vec::new();
37
+ collect_jsonl(&dir, &mut files);
38
+ let largest = files
39
+ .into_iter()
40
+ .max_by_key(|p| fs::metadata(p).map(|m| m.len()).unwrap_or(0))
41
+ .unwrap_or_else(|| {
42
+ panic!(
43
+ "no .jsonl transcripts under {} — run: uv run --no-sync python scripts/gen_corpus.py",
44
+ dir.display()
45
+ )
46
+ });
47
+ fs::read(&largest).unwrap_or_else(|e| panic!("failed to read corpus file {}: {e}", largest.display()))
48
+ }
49
+
50
+ fn bench_parse(c: &mut Criterion) {
51
+ let bytes = largest_corpus_file();
52
+ let spec = compile_spec(FILTER_SPEC_JSON).expect("filter spec compiles");
53
+ let mut group = c.benchmark_group("parse");
54
+ group.sample_size(20);
55
+ group.throughput(Throughput::Bytes(bytes.len() as u64));
56
+ group.bench_function("parse_corpus", |b| {
57
+ b.iter(|| parse_bytes(&bytes, |_| true).expect("corpus parses"))
58
+ });
59
+ group.bench_function("filter_spec", |b| {
60
+ b.iter(|| parse_bytes(&bytes, |entry| spec_keep(&spec, entry)).expect("corpus parses"))
61
+ });
62
+ group.finish();
63
+ }
64
+
65
+ fn bench_activity(c: &mut Criterion) {
66
+ let bytes = largest_corpus_file();
67
+ let entries = parse_bytes(&bytes, |_| true).expect("corpus parses");
68
+ let opts = ActivityOpts::default();
69
+ let mut group = c.benchmark_group("activity");
70
+ group.sample_size(20);
71
+ group.throughput(Throughput::Elements(entries.len() as u64));
72
+ group.bench_function("session_activity", |b| b.iter(|| session_activity(&entries, &opts)));
73
+ group.finish();
74
+ }
75
+
76
+ criterion_group!(benches, bench_parse, bench_activity);
77
+ criterion_main!(benches);
@@ -11,7 +11,7 @@ mod command;
11
11
  #[cfg(feature = "python")]
12
12
  mod event;
13
13
  #[cfg(feature = "python")]
14
- mod filter;
14
+ pub mod filter;
15
15
  #[cfg(feature = "python")]
16
16
  mod lexicon;
17
17
  #[cfg(feature = "python")]
File without changes
File without changes