csift 0.8.2

ripgrep for Claude Code session transcripts: fast regex list/search over ~/.claude/projects/**/*.jsonl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Matcher: regex + raw-byte prefilter + synthesized-text markers (SPEC 7d/7f).

use super::*;

/// A compiled pattern + flags, plus the optional literal prefilter needle.
#[derive(Debug)]
pub struct Matcher {
    /// `None` ⇒ empty pattern (pure filter: every label-eligible record matches).
    pub(crate) regex: Option<BytesRegex>,
    /// A required-literal prefilter derived from the pattern, run against RAW line/file
    /// bytes (§7d stage 2 + the §7f whole-file gate). `None` ⇒ no anchorable literal.
    pub(crate) prefilter: Option<Prefilter>,
    /// SYNTHESIZED-text markers (built only when `prefilter` is `Some`): a record whose
    /// raw line carries one of these can render matchable text that is NOT a verbatim
    /// substring of its raw bytes (an automation label's fabricated kind slug / status,
    /// the AUQ Q+options+answer scaffold, a rejection's `[plan: …]` pointer resolved from
    /// ANOTHER record, the compact-boundary `trigger=…` excerpt, `--resolve-persisted`
    /// external file content). The literal prefilter can only prove absence for
    /// VERBATIM-derived text, so a marker-bearing line/file always passes to the parse +
    /// regex stage. This also FIXES a latent pre-existing gap: the old case-sensitive
    /// `memmem` prefilter silently skipped regex work on exactly these records.
    ///
    /// One `memmem::Finder` per needle - deliberately NOT one Aho-Corasick automaton:
    /// the `"answers"` needle starts with a quote, and in JSON lines a quote is one of
    /// the DENSEST bytes, so AC's start-byte prefilter degenerated into a verification
    /// attempt at nearly every string boundary (`try_find_fwd` became the #1 profile
    /// entry). `memmem` picks a rare byte INSIDE each needle as its SIMD skip anchor,
    /// so per-needle scans stay at memory speed regardless of the leading byte.
    ///
    /// Two tiers, split by whether a marker-bearing line's synthesized text can be
    /// re-rendered SELF-CONTAINED for the §7f stage-2 verification:
    /// - `synth_verifiable` - notification / AUQ-answer / compact-boundary markers: the
    ///   line's every synthesized text derives from the line alone, so the gate can
    ///   parse JUST the marker lines, render via the SHARED engines
    ///   (`record_text_sections` / `auq_exchange` / `record_raw_text`) and regex-check
    ///   them - a big marker-heavy main session no longer forces a whole-file parse.
    /// - `synth_conservative` - a rejection's `[plan: …]` pointer resolves through
    ///   ANOTHER record (`PlanIndex`) and `--resolve-persisted` content lives in an
    ///   external file, so those lines force the full scan (rare).
    pub(crate) synth_verifiable: Vec<memmem::Finder<'static>>,
    pub(crate) synth_conservative: Vec<memmem::Finder<'static>>,
}

/// Raw-byte prefilter for a pattern that IS a plain literal (no regex metachars, no
/// JSON-escaped chars - see [`required_literal`]). Both variants are CONSERVATIVE:
/// they can only prove a haystack CANNOT match (false positives fine, false negatives
/// impossible), so gating on them never drops a genuine hit.
#[derive(Debug)]
pub(crate) enum Prefilter {
    /// Case-sensitive literal: SIMD `memmem` substring search.
    Literal(memmem::Finder<'static>),
    /// Smart-case / `-i` insensitive literal: a `(?i)`-wrapped escaped literal as a
    /// bytes regex - `memmem` has no caseless mode, but the regex engine compiles a
    /// caseless literal to an accelerated (Teddy-class) multi-substring scan, so the
    /// dominant lowercase-smart-case search gets the SAME prefilter power the
    /// case-sensitive path always had.
    CaselessLiteral(BytesRegex),
}

impl Prefilter {
    /// True when `haystack` (a raw jsonl line OR a whole mmapped file) could contain a
    /// match. The JSON-escape safety argument is [`required_literal`]'s: the literal
    /// contains no char that serde/JS JSON-encodes, so it survives verbatim (module
    /// case) in the raw bytes whenever the DECODED text matches.
    pub(crate) fn may_match(&self, haystack: &[u8]) -> bool {
        match self {
            Prefilter::Literal(finder) => finder.find(haystack).is_some(),
            Prefilter::CaselessLiteral(re) => re.is_match(haystack),
        }
    }
}

impl Matcher {
    /// The PURE-FILTER matcher (no regex, no prefilter): every text matches. Used for
    /// `--siblings` rendering and as `csift show`'s fetch matcher (an addressed record
    /// always emits).
    pub(crate) fn pure() -> Matcher {
        Matcher {
            regex: None,
            prefilter: None,
            synth_verifiable: Vec::new(),
            synth_conservative: Vec::new(),
        }
    }

    /// True when the pattern is empty (pure filter - matches any text).
    pub(crate) fn is_pure_filter(&self) -> bool {
        self.regex.is_none()
    }

    /// True if `text` matches the pattern (always true for the pure filter).
    /// Test-only since production hits go through [`Matcher::locate`] (which also
    /// yields the span for excerpt-centering); kept as a clean bool API for tests.
    #[cfg(test)]
    pub(crate) fn is_match(&self, text: &str) -> bool {
        match &self.regex {
            None => true,
            Some(re) => re.is_match(text.as_bytes()),
        }
    }

    /// Locate the FIRST match, so the excerpt can be CENTERED on it instead of
    /// always showing the message head. Returns:
    /// - `None` - no match (the record is not a hit);
    /// - `Some(None)` - matches with no specific span (the pure filter matches every
    ///   record, so there is no offset to center on → excerpt shows the head);
    /// - `Some(Some((start, end)))` - matches at this BYTE range.
    pub(crate) fn locate(&self, text: &str) -> Option<Option<(usize, usize)>> {
        match &self.regex {
            None => Some(None),
            Some(re) => re.find(text.as_bytes()).map(|m| Some((m.start(), m.end()))),
        }
    }

    /// Cheap raw-line prefilter (§7d stage 2): if a required literal exists and the
    /// line lacks it, the line cannot match - drop it pre-JSON. With no literal (or
    /// pure filter) we cannot prove absence, so the line passes to the parse stage.
    pub(crate) fn line_may_match(&self, line: &[u8]) -> bool {
        match &self.prefilter {
            Some(pf) => pf.may_match(line) || self.synth_may_match(line),
            None => true,
        }
    }

    /// The literal-prefilter check ALONE (no synth-marker OR) - the §7f pre-scan needs
    /// the two signals separately (a literal hit forces the full scan; a marker hit
    /// routes to its tier). `false` is only possible when a prefilter is anchored.
    pub(crate) fn line_prefilter_hits(&self, line: &[u8]) -> bool {
        match &self.prefilter {
            Some(pf) => pf.may_match(line),
            None => true,
        }
    }

    /// True when a prefilter is anchored - the precondition for the §7f whole-file gate
    /// (without one nothing is provably a miss, so the gate pre-scan would be waste).
    pub(crate) fn has_prefilter(&self) -> bool {
        self.prefilter.is_some()
    }

    /// Whole-slice version of [`Matcher::line_may_match`] - test-only: production gates
    /// per LINE inside the parallel pre-scan (a serial whole-mmap pass would bottleneck
    /// the single-giant-file case); tests use this to pin the miss/hit semantics.
    #[cfg(test)]
    pub(crate) fn file_may_match(&self, bytes: &[u8]) -> bool {
        match &self.prefilter {
            Some(pf) => pf.may_match(bytes) || self.synth_may_match(bytes),
            None => true,
        }
    }

    /// True when the haystack carries ANY synthesized-text marker (either tier).
    pub(crate) fn synth_may_match(&self, haystack: &[u8]) -> bool {
        self.synth_verifiable
            .iter()
            .chain(self.synth_conservative.iter())
            .any(|f| f.find(haystack).is_some())
    }

    /// True when the haystack carries a CONSERVATIVE marker (must full-scan).
    pub(crate) fn synth_conservative_hits(&self, haystack: &[u8]) -> bool {
        self.synth_conservative
            .iter()
            .any(|f| f.find(haystack).is_some())
    }

    /// True when the haystack carries a VERIFIABLE marker (stage-2 re-render + check).
    pub(crate) fn synth_verifiable_hits(&self, haystack: &[u8]) -> bool {
        self.synth_verifiable
            .iter()
            .any(|f| f.find(haystack).is_some())
    }

    /// §7f stage-2: could this parsed marker-line record's SYNTHESIZED texts match?
    /// Renders through the same shared engines the hit collector uses (no drift):
    /// notification section labels + normalized `<result>` bodies
    /// (`record_text_sections` - direction/owner do not affect the TEXT, so the neutral
    /// ctx is exact), the answered-AUQ reconstruction (`auq_exchange`), and the
    /// compact-boundary content + metadata excerpt (`record_raw_text`). Any VERBATIM
    /// text these return is already covered by the literal scan, so a miss here plus a
    /// literal miss proves the record cannot hit.
    pub(crate) fn synth_texts_match(&self, rec: &Record) -> bool {
        let ctx = crate::model::ClassifyCtx::top_level();
        if rec
            .record_text_sections(&ctx)
            .iter()
            .any(|sec| self.locate(&sec.text).is_some())
        {
            return true;
        }
        if let Some(t) = rec.auq_exchange() {
            if self.locate(&t).is_some() {
                return true;
            }
        }
        if let Some(t) = record_raw_text(rec) {
            if self.locate(&t).is_some() {
                return true;
            }
        }
        false
    }
}

/// Compile the user pattern honoring smart-case / `-i` / `--multiline`.
///
/// Smart-case: case-insensitive iff the pattern has NO uppercase letter; `-i`
/// forces insensitive regardless (and wins on conflict). `--multiline` sets
/// `.dot_matches_new_line(true)` + multi-line mode. An empty pattern compiles to
/// the pure-filter matcher (no regex, no prefilter).
pub fn build_matcher(args: &SearchArgs) -> Result<Matcher> {
    if args.pattern.is_empty() {
        return Ok(Matcher {
            regex: None,
            prefilter: None,
            synth_verifiable: Vec::new(),
            synth_conservative: Vec::new(),
        });
    }

    let has_uppercase = args.pattern.chars().any(|c| c.is_uppercase());
    let case_insensitive = args.ignore_case || !has_uppercase;

    let regex = BytesRegex::new(&apply_builder(
        &args.pattern,
        case_insensitive,
        args.multiline,
    )?)
    .with_context(|| format!("invalid regex pattern: {:?}", args.pattern))?;

    // Extract a required literal for the cheap raw-byte prefilter (line + whole-file).
    // Case-sensitive → byte-exact `memmem`. Case-insensitive (the smart-case DEFAULT
    // for a lowercase pattern) → a `(?i)`-wrapped ESCAPED literal compiled as its own
    // bytes regex: `memmem` has no caseless mode, but the regex engine lowers a
    // caseless literal to an accelerated multi-substring scan, so the dominant
    // lowercase search is no longer forced to parse every candidate line.
    let prefilter = match required_literal(&args.pattern) {
        None => None,
        Some(_) if case_insensitive => {
            // `lit` == the whole pattern (no metachars by construction); escape anyway
            // so this stays correct if `required_literal` ever loosens.
            let src = format!("(?i){}", regex::escape(&args.pattern));
            let re = BytesRegex::new(&src)
                .with_context(|| format!("invalid caseless prefilter for {:?}", args.pattern))?;
            Some(Prefilter::CaselessLiteral(re))
        }
        Some(lit) => Some(Prefilter::Literal(memmem::Finder::new(&lit).into_owned())),
    };

    // The synthesized-text escape hatch is only needed when a prefilter can prune.
    let (synth_verifiable, synth_conservative) = if prefilter.is_some() {
        synth_marker_finders(args)
    } else {
        (Vec::new(), Vec::new())
    };

    Ok(Matcher {
        regex: Some(regex),
        prefilter,
        synth_verifiable,
        synth_conservative,
    })
}

/// Build the final regex source string with the requested flags applied via an
/// inline flag group `(?ims)`, so we keep `regex::bytes` (needed for raw-byte
/// matching) while honoring case/multiline.
pub(crate) fn apply_builder(
    pattern: &str,
    case_insensitive: bool,
    multiline: bool,
) -> Result<String> {
    let mut flags = String::new();
    if case_insensitive {
        flags.push('i');
    }
    if multiline {
        // `s` = dot matches newline; `m` = `^`/`$` match line boundaries.
        flags.push('s');
        flags.push('m');
    }
    // Validate the bare pattern early for a clean error before we wrap it.
    regex::bytes::Regex::new(pattern)
        .with_context(|| format!("invalid regex pattern: {pattern:?}"))?;
    if flags.is_empty() {
        Ok(pattern.to_string())
    } else {
        Ok(format!("(?{flags}){pattern}"))
    }
}

/// Extract a longest plain-literal run that MUST appear in any match, for the
/// `memmem` prefilter. Conservative: returns a literal only when the whole pattern
/// is plain (no regex metacharacters), so we never drop a line that could match.
/// (A richer HIR analysis is possible but this captures the common keyword case -
/// `csift search "carry"` - with zero false negatives.)
///
/// **JSON-escape safety (load-bearing - SPEC §0 "no silent truncation").** The
/// prefilter runs the literal against the RAW JSON line bytes, where string content
/// is JSON-encoded: `"` is stored as `\"`, `\` as `\\`, and every control char
/// (`< 0x20`) plus DEL (`0x7f`) as a `\uXXXX`/`\n`/`\t`/… escape. A literal
/// containing any such char therefore can NOT appear verbatim in the raw line - a
/// `memmem` for it would falsely report "absent" and silently drop a line whose
/// DECODED text actually matches (e.g. searching `Say"Xello`). So we refuse to emit
/// a literal prefilter whenever the pattern contains a JSON-escaped character; the
/// match then falls back to running the regex on the raw bytes (still pre-JSON, just
/// without the cheap literal short-circuit). Non-ASCII (`>= 0x80`) is emitted
/// verbatim as UTF-8 by serde_json (multi-byte searches confirm this), so it stays
/// prefilter-eligible.
pub(crate) fn required_literal(pattern: &str) -> Option<Vec<u8>> {
    const META: &[char] = &[
        '.', '*', '+', '?', '(', ')', '[', ']', '{', '}', '|', '^', '$', '\\',
    ];
    if pattern.is_empty() || pattern.chars().any(|c| META.contains(&c)) {
        return None;
    }
    // A char that JSON escapes inside a string does not survive verbatim in the raw
    // line bytes the prefilter scans - emitting a literal for it causes false
    // negatives. `\` is already excluded via META above; guard `"`, control chars,
    // and DEL here.
    if pattern.chars().any(json_escapes_in_string) {
        return None;
    }
    // WHITESPACE-safety (the render-normalization mirror of the JSON-escape rule):
    // several render paths rewrite whitespace before matching - `normalize_line`
    // collapses runs to a single space (genuine-user text via `flatten_content_text`,
    // peer bodies, notification reports) and multi-part texts are joined with `' '` /
    // `'\n'` seams. A literal CONTAINING whitespace can therefore match rendered text
    // (`hello world`) whose raw bytes hold `hello\nworld` - a `memmem` for it would
    // falsely prove absence. A whitespace-FREE literal always sits inside one
    // unrewritten non-whitespace run, which survives verbatim in the raw bytes, so
    // only those stay prefilter-eligible. (This also closes a latent gap the old
    // case-sensitive prefilter had for space-carrying patterns.)
    if pattern.chars().any(char::is_whitespace) {
        return None;
    }
    Some(pattern.as_bytes().to_vec())
}

/// Build the SYNTHESIZED-text marker finders for [`Matcher::synth`] (one SIMD
/// `memmem` scan per needle, per line - see the field doc for why not Aho-Corasick).
///
/// Rationale: the literal prefilter proves absence only for matchable text that is a
/// VERBATIM substring of the record's raw line bytes. A small, closed set of render
/// paths synthesizes text from other sources; each is detectable by a raw marker its
/// carrier record ALWAYS contains:
/// - `<task-notification>` - `automation_label` fabricates the kind slug
///   (`subagent`/`background-command`/…), a `completed` status fallback, and `[…]`
///   scaffolding; the G1 inbox view normalizes the `<result>` body.
/// - `"answers"` + the two synthesized answer markers - the ANSWER carrier's
///   `auq_exchange` render fabricates the `[AskUserQuestion · N question(s)]` scaffold,
///   `Q1/A1` labels and option lists that appear verbatim nowhere in the raw line.
///   (The QUESTION-side `tool_use` needs no needle: its matchable text is
///   `render_tool_use` = the verbatim `name` + the re-serialized `input`, and the name
///   bytes sit in the raw line - a bare `AskUserQuestion` needle would disable the gate
///   for the ~29% of files whose injected context merely MENTIONS the tool.)
/// - `To tell you how to proceed` - the rejection reconstruction appends a
///   `[plan: <path>]` pointer whose path lives on a DIFFERENT record.
/// - `compact_boundary` (only when the `-t` selection can reach
///   `harness.compaction.boundary` - otherwise the boundary line is not even a scan
///   candidate, so its synthesized excerpt is unreachable) - `trigger=…`/`preTokens=…`
///   key=value text is fabricated from `compactMetadata`.
/// - Under `--resolve-persisted`: `persistedOutputPath` / `Full output saved to:` -
///   the matched text is EXTERNAL file content, absent from the transcript bytes by
///   definition.
///
/// False positives only cost speed (the line/file falls back to the full parse +
/// regex pipeline); false negatives are what the set is built to make impossible.
pub(crate) fn synth_marker_finders(
    args: &SearchArgs,
) -> (Vec<memmem::Finder<'static>>, Vec<memmem::Finder<'static>>) {
    // VERIFIABLE (stage-2 re-renderable from the line alone; see `Matcher::synth_*`).
    let mut verifiable: Vec<&[u8]> = vec![
        b"<task-notification>",
        br#""answers""#,
        b"User has answered your questions",
        b"Your questions have been answered",
    ];
    if args
        .label_filter()
        .selected(Class::CompactionBoundary.path())
    {
        verifiable.push(b"compact_boundary");
    }
    // CONSERVATIVE (needs cross-record / external data - force the full scan).
    let mut conservative: Vec<&[u8]> = vec![b"To tell you how to proceed"];
    if args.resolve_persisted {
        conservative.push(b"persistedOutputPath");
        conservative.push(b"Full output saved to:");
    }
    let mk = |ns: Vec<&[u8]>| {
        ns.into_iter()
            .map(|n| memmem::Finder::new(n).into_owned())
            .collect()
    };
    (mk(verifiable), mk(conservative))
}

/// True when `c` is escaped inside a JSON string literal (so it never appears
/// verbatim in the raw line bytes): `"`, any C0 control char (`< 0x20`), or DEL.
/// (`\` is handled separately as a regex metacharacter.)
pub(crate) fn json_escapes_in_string(c: char) -> bool {
    c == '"' || (c as u32) < 0x20 || c == '\u{7f}'
}

/// Entry point for `csift search`.
/// Record-address selectors (`--line` / `--uuid`) parsed into membership sets - the "fetch
/// THESE records" filter that turns `search` into the in-permission message-getter. Active when
/// either set is non-empty; a record is addressed when its physical line OR uuid is in range.
pub(crate) struct AddressSet {
    pub(crate) lines: BTreeSet<usize>,
    pub(crate) uuids: BTreeSet<String>,
}