Skip to main content

ai_session_search/
analytics.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! CLI adapters for message-classification skills, data-driven repeat mining,
5//! planning-command frequency, and stats.
6//!
7//! The executable correction categories live in the selected skill's `aise-capability.toml`; the
8//! category helpers below remain only as compatibility test oracles. `analytics.planning_commands`
9//! (regexes over the slash-command token) optionally restricts which commands `planning` counts
10//! (empty = all). `vocab` and `repeats` use config-backed defaults for their public scan/output
11//! controls.
12
13#[cfg(test)]
14use std::collections::HashMap;
15use std::collections::{BTreeMap, BTreeSet};
16use std::io::{self, Write};
17
18use anyhow::{anyhow, bail, Result};
19use clap::Args;
20use regex::Regex;
21use serde::Serialize;
22
23use crate::config::{AnalyticsConfig, Config};
24use crate::dates::DateRange;
25use crate::db::Db;
26use crate::message_search::{
27    DetailLevel, FieldViewBudget, MatchViewBudget, DEFAULT_MATCH_EVIDENCE_MAX_CHARS,
28};
29use crate::messages::{CliFieldViewChars, CliMatchViewChars};
30use crate::models::{
31    MessageClassificationMatch, MessageFilters, MessageHit, MessageSearchMode, PlanningCount,
32    Provider, Role,
33};
34use crate::render::{render, OutputFormat, Row};
35use crate::util::truncate_for_display;
36
37const TABLE_CONTENT_CHARS: usize = 100;
38const USER_REQUEST_START: &str = "<USER_REQUEST>";
39const USER_REQUEST_END: &str = "</USER_REQUEST>";
40
41/// Historical built-in correction categories retained as a compatibility test oracle.
42///
43/// The executable source is the selected message-classification skill's `aise-capability.toml`.
44/// These patterns remain narrowed to second-person / imperative / demonstrative forms so tests
45/// can compare the current compiled capability with the pre-skills behavior.
46pub(crate) fn default_correction_patterns() -> Vec<(&'static str, Vec<&'static str>)> {
47    vec![
48        (
49            "regression",
50            vec![
51                r"\byou (deleted|removed|reverted|lost|regressed|undid|rolled back|broke)\b",
52                r"\b(that|this|it) (reverted|deleted|removed|undid|regressed)\b",
53                r"\bbroke the (build|tests?|code|app)\b",
54                r"\bregressed\b",
55            ],
56        ),
57        (
58            "skip_step",
59            vec![
60                r"\byou forgot\b",
61                r"\byou missed\b",
62                r"\byou skipped\b",
63                r"\bdon'?t forget\b",
64                r"\bmissing step\b",
65                r"\byou didn'?t\b",
66            ],
67        ),
68        (
69            "misunderstanding",
70            vec![
71                r"\b(that is|that'?s|it is|it'?s) (actually )?(wrong|incorrect|not correct|not right|not what|a mistake)\b",
72                r"\byou'?re wrong\b",
73                r"\byou (misunderstood|got it wrong|misread)\b",
74                r"\bnono\b",
75                r"\bno,?\s+that'?s\b",
76                r"\bno,?\s+i (meant|asked|said)\b",
77                r"\bwait,?\s+(no|that'?s)\b",
78                r"\bwrong approach\b",
79            ],
80        ),
81        (
82            "incomplete",
83            vec![
84                r"\balso need\b",
85                r"\bmust also\b",
86                r"\bnot done\b",
87                r"\bnot finished\b",
88                r"\bstill need\b",
89                r"\byou should have\b",
90                r"\bbut you\b",
91            ],
92        ),
93        // Catch-all (last). A bare `\bstop\b` was ~98% false positives on real data:
94        // it matched test fixtures ("run this command once and stop"), checkpoint
95        // instructions ("commit and then stop"), and negations ("don't stop"). Restrict
96        // to imperative-stop corrections: a leading "stop" (optionally softened by
97        // ok/no/wait/please/just) or an explicit "stop <doing/that/it/...>" directive.
98        (
99            "other",
100            vec![
101                r"^\s*(?:ok,?\s+|no,?\s+|wait,?\s+|please\s+|just\s+)?stop\b",
102                r"\bjust stop\b",
103                r"\bplease stop\b",
104                r"\bstop doing\b",
105                r"\bstop that\b",
106                r"\bstop it\b",
107                r"\bstop changing\b",
108                r"\bstop making\b",
109                r"\bstop breaking\b",
110            ],
111        ),
112    ]
113}
114
115/// The pre-skills correction compiler, kept as a TEST ORACLE only.
116///
117/// `corrections.rs` replaced this on the live path, so nothing outside tests calls it. It stays
118/// because it is the reference the new resolution is checked against: S17 showed that compiling
119/// one regex per pattern instead of one `(?i)` alternation per category silently changes both
120/// case-sensitivity and which substring is reported, and the only way to keep asserting the new
121/// path produces byte-identical regex sources is to keep the old path around to compare with.
122/// Delete it only together with the tests that compare against it.
123#[cfg(test)]
124fn compile_category_patterns(
125    custom: &[String],
126    builtins: Vec<(&'static str, Vec<&'static str>)>,
127    label: &str,
128) -> Result<Vec<(String, Regex)>> {
129    if custom.is_empty() {
130        return builtins
131            .into_iter()
132            .map(|(category, patterns)| {
133                let re = Regex::new(&format!("(?i){}", patterns.join("|"))).map_err(|err| {
134                    anyhow!("invalid built-in {label} pattern for '{category}': {err}")
135                })?;
136                Ok((category.to_string(), re))
137            })
138            .collect();
139    }
140    let mut order: Vec<String> = Vec::new();
141    let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
142    for spec in custom {
143        let (category, rx) = spec
144            .split_once(':')
145            .ok_or_else(|| anyhow!("invalid {label} pattern '{spec}': expected CATEGORY:REGEX"))?;
146        if !grouped.contains_key(category) {
147            order.push(category.to_string());
148        }
149        grouped
150            .entry(category.to_string())
151            .or_default()
152            .push(rx.to_string());
153    }
154    order
155        .into_iter()
156        .map(|category| {
157            let joined = grouped[&category].join("|");
158            let re = Regex::new(&format!("(?i){joined}"))
159                .map_err(|err| anyhow!("invalid {label} regex for category '{category}': {err}"))?;
160            Ok((category, re))
161        })
162        .collect()
163}
164
165/// Compile the active correction patterns the pre-skills way: config override
166/// (`CATEGORY:REGEX`, same-category ORed, first-seen order) when present, else the built-ins.
167///
168/// Test oracle only — see [`compile_category_patterns`].
169#[cfg(test)]
170pub(crate) fn compile_patterns(_config: &Config) -> Result<Vec<(String, Regex)>> {
171    compile_category_patterns(&[], default_correction_patterns(), "correction")
172}
173
174impl Row for MessageClassificationMatch {
175    fn headers() -> &'static [&'static str] {
176        // `policy` is a column rather than a header line because `--skill` is repeatable: with two
177        // policies selected, "which rules called this a correction" differs per row, and a
178        // caller comparing a candidate policy against the built-in one needs it beside each match.
179        &[
180            "session", "seq", "ts", "policy", "category", "match", "content",
181        ]
182    }
183    fn cells(&self) -> Vec<String> {
184        vec![
185            self.session_id.clone(),
186            self.message_seq.to_string(),
187            self.ts.map(|ts| ts.to_rfc3339()).unwrap_or_default(),
188            self.policy_name.clone(),
189            self.category.clone(),
190            self.matched_text.clone(),
191            truncate_for_display(&self.content, TABLE_CONTENT_CHARS),
192        ]
193    }
194}
195
196impl Row for PlanningCount {
197    fn headers() -> &'static [&'static str] {
198        &["command", "count", "sessions", "projects"]
199    }
200    fn cells(&self) -> Vec<String> {
201        vec![
202            self.command.clone(),
203            self.count.to_string(),
204            self.unique_sessions.to_string(),
205            self.unique_projects.to_string(),
206        ]
207    }
208}
209
210/// One role's message count, for `stats`.
211#[derive(Debug, Clone, Serialize)]
212pub struct RoleStat {
213    pub role: String,
214    pub count: i64,
215}
216impl Row for RoleStat {
217    fn headers() -> &'static [&'static str] {
218        &["role", "count"]
219    }
220    fn cells(&self) -> Vec<String> {
221        vec![self.role.clone(), self.count.to_string()]
222    }
223}
224
225#[derive(Debug, Clone, Args)]
226pub struct CorrectionsArgs {
227    /// JSON object containing direct `categories` for this run. It replaces the primary selected
228    /// skill's adjacent aise-capability.toml rules while preserving that skill's identity and version.
229    #[arg(long, value_name = "JSON")]
230    pub definition_json: Option<String>,
231    /// Exact session id or unique prefix. Use this when chaining from search output.
232    /// Omit to include every session.
233    #[arg(long)]
234    pub session_id: Option<String>,
235    /// Restrict to one indexed session source. Omit to include all nine.
236    #[arg(long, value_enum)]
237    pub provider: Option<Provider>,
238    /// Restrict to sessions whose cwd or repo root is this directory or a descendant of it
239    /// (component boundary).
240    /// Omit to search every allowed root.
241    #[arg(long)]
242    pub path: Option<String>,
243    #[command(flatten)]
244    pub dates: DateRange,
245    /// Max matches. Omit to use `[capabilities.message_classification].limit`. 0 = every match.
246    #[arg(long)]
247    pub limit: Option<usize>,
248    /// Matches to skip before `--limit` applies, newest first. 0 starts at the newest match.
249    #[arg(long, default_value_t = 0)]
250    pub offset: usize,
251    /// Session classes to scan. Omit for user-started sessions only, because a correction is
252    /// something a person told the agent: in a spawned run the `user` rows are the calling
253    /// agent's delegation prompt. Pass `user subagent` for both, as `search` and `list` return
254    /// by default.
255    #[arg(long = "session-kinds", value_enum, num_args = 1..)]
256    pub session_kinds: Vec<crate::models::SessionKind>,
257    /// Additional message-classification skills to evaluate, in argument order after the primary
258    /// selected skill. Each value is a catalog name, skill directory, or exact SKILL.md path.
259    #[arg(long = "skill", value_name = "NAME_OR_PATH")]
260    pub additional_skills: Vec<std::ffi::OsString>,
261    /// Presentation preset for JSON/JSONL results: compact bounds the selected field and match
262    /// view; full keeps the complete field and only the exact match span. Omit to preserve the
263    /// complete typed report. The bounded form keeps the full message recoverable by message_ref.
264    #[arg(
265        long,
266        value_enum,
267        conflicts_with_all = ["field_view_chars", "match_view_chars"]
268    )]
269    pub detail: Option<DetailLevel>,
270    /// Bound the selected classified message field in JSON/JSONL output to this many Unicode
271    /// scalar characters, or use no-char-limit. Omit for the complete field.
272    #[arg(long)]
273    pub field_view_chars: Option<CliFieldViewChars>,
274    /// Bound the match-centered view in JSON/JSONL output to minimal or this many Unicode scalar
275    /// characters. Omit for the standard 220-character match view.
276    #[arg(long)]
277    pub match_view_chars: Option<CliMatchViewChars>,
278    /// Output format.
279    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
280    pub format: OutputFormat,
281}
282
283#[derive(Debug, Args)]
284pub struct PlanningArgs {
285    /// Restrict to one indexed session source. Omit to include all nine.
286    #[arg(long, value_enum)]
287    pub provider: Option<Provider>,
288    /// Exact session id or unique prefix. Use this when chaining from search output. Omit to include every session.
289    #[arg(long)]
290    pub session_id: Option<String>,
291    /// Restrict to sessions whose cwd or repo root is this directory or a descendant of it
292    /// (component boundary).
293    /// Omit to search every allowed root.
294    #[arg(long)]
295    pub path: Option<String>,
296    #[command(flatten)]
297    pub dates: DateRange,
298    /// Keep only slash-command tokens matching this case-insensitive regex.
299    ///
300    /// Regexes match the leading command token; repeat to OR several token regexes. Omit to count
301    /// every slash command.
302    #[arg(long = "commands", alias = "command")]
303    pub command_patterns: Vec<String>,
304    /// Max distinct commands. Omit to use `[analytics].planning_limit`. 0 = every command.
305    #[arg(long)]
306    pub limit: Option<usize>,
307    /// Output format.
308    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
309    pub format: OutputFormat,
310}
311
312#[derive(Debug, Args)]
313pub struct StatsArgs {
314    /// Restrict to one indexed session source. Omit to include all nine.
315    #[arg(long, value_enum)]
316    pub provider: Option<Provider>,
317    /// Exact session id or unique prefix. Use this when chaining from search output. Omit to include every session.
318    #[arg(long)]
319    pub session_id: Option<String>,
320    /// Restrict to sessions whose cwd or repo root is this directory or a descendant of it
321    /// (component boundary).
322    /// Omit to search every allowed root.
323    #[arg(long)]
324    pub path: Option<String>,
325    #[command(flatten)]
326    pub dates: DateRange,
327    /// Output format.
328    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
329    pub format: OutputFormat,
330}
331
332/// Build a [`MessageFilters`] from a session scope, a path prefix, a [`DateRange`], and a
333/// limit. `path` is normalized to an absolute prefix (`~`/relative resolved) by
334/// [`crate::util::normalize_path_prefix`], matching the session- and message-search `--path`.
335fn filters_from(
336    db: &Db,
337    session_id: &Option<String>,
338    provider: Option<Provider>,
339    path: &Option<String>,
340    dates: &DateRange,
341    limit: usize,
342    offset: usize,
343) -> Result<MessageFilters> {
344    let (since, until) = dates.resolve_now()?;
345    let exact_session_id = session_id
346        .as_deref()
347        .map(|id| db.resolve_session_record(id).map(|session| session.id))
348        .transpose()?;
349    Ok(MessageFilters {
350        providers: provider.map(|provider| vec![provider]),
351        session_id: exact_session_id,
352        path_prefix: path.as_deref().map(crate::util::normalize_path_prefix),
353        since,
354        until,
355        limit,
356        offset,
357        ..Default::default()
358    })
359}
360
361pub(crate) fn message_classification_filters(
362    db: &Db,
363    config: &Config,
364    args: &CorrectionsArgs,
365) -> Result<MessageFilters> {
366    let mut filters = filters_from(
367        db,
368        &args.session_id,
369        args.provider,
370        &args.path,
371        &args.dates,
372        args.limit
373            .unwrap_or(config.capabilities.message_classification.limit),
374        args.offset,
375    )?;
376    // Left as `None` when the caller named no class, so `find_corrections` applies its own
377    // user-only default. Setting it here instead would put the "what a correction IS" decision in
378    // the CLI adapter, where the Python and MCP surfaces could not inherit it.
379    if !args.session_kinds.is_empty() {
380        filters.session_kinds = Some(args.session_kinds.clone());
381    }
382    Ok(filters)
383}
384
385pub(crate) fn render_skill_run_report(
386    report: &crate::skill_run::SkillRunReport,
387    config: &Config,
388    args: &CorrectionsArgs,
389) -> Result<()> {
390    match args.format {
391        OutputFormat::Json => {
392            if let Some((field_budget, match_budget)) = skill_presentation_budgets(config, args) {
393                println!(
394                    "{}",
395                    serde_json::to_string_pretty(&bounded_skill_run_value(
396                        report,
397                        field_budget,
398                        match_budget
399                    )?)?
400                );
401            } else {
402                println!("{}", serde_json::to_string_pretty(report)?);
403            }
404            Ok(())
405        }
406        OutputFormat::Jsonl => {
407            if let Some((field_budget, match_budget)) = skill_presentation_budgets(config, args) {
408                println!(
409                    "{}",
410                    serde_json::to_string(&bounded_skill_run_value(
411                        report,
412                        field_budget,
413                        match_budget,
414                    )?)?
415                );
416            } else {
417                println!("{}", serde_json::to_string(report)?);
418            }
419            Ok(())
420        }
421        OutputFormat::Table | OutputFormat::Csv | OutputFormat::Plain => {
422            let crate::skill_run::SkillCapabilityOutput::MessageClassification(result) =
423                &report.output;
424            render_correction_report(&result.report, args.format)
425        }
426    }
427}
428
429fn skill_presentation_budgets(
430    config: &Config,
431    args: &CorrectionsArgs,
432) -> Option<(FieldViewBudget, MatchViewBudget)> {
433    if args.detail.is_none() && args.field_view_chars.is_none() && args.match_view_chars.is_none() {
434        return None;
435    }
436    let field_budget = match args.detail {
437        Some(DetailLevel::Compact) => FieldViewBudget::MaxChars {
438            max_chars: std::num::NonZeroUsize::new(config.cli.evidence_preview_chars)
439                .expect("validated CLI evidence preview chars are positive"),
440        },
441        Some(DetailLevel::Full) => FieldViewBudget::NoCharLimit,
442        None => match args.field_view_chars {
443            Some(CliFieldViewChars::NoCharLimit) | None => FieldViewBudget::NoCharLimit,
444            Some(CliFieldViewChars::MaxChars(max_chars)) => FieldViewBudget::MaxChars { max_chars },
445        },
446    };
447    let match_budget = match args.detail {
448        Some(DetailLevel::Full) => MatchViewBudget::MinimalSpan,
449        Some(DetailLevel::Compact) => MatchViewBudget::MaxChars {
450            max_chars: config
451                .search
452                .message_search
453                .match_evidence_max_chars
454                .unwrap_or_else(|| {
455                    std::num::NonZeroUsize::new(DEFAULT_MATCH_EVIDENCE_MAX_CHARS)
456                        .expect("typed match evidence default is positive")
457                }),
458        },
459        None => match args.match_view_chars {
460            Some(CliMatchViewChars::Minimal) => MatchViewBudget::MinimalSpan,
461            Some(CliMatchViewChars::MaxChars(max_chars)) => MatchViewBudget::MaxChars { max_chars },
462            None => MatchViewBudget::MaxChars {
463                max_chars: config
464                    .search
465                    .message_search
466                    .match_evidence_max_chars
467                    .unwrap_or_else(|| {
468                        std::num::NonZeroUsize::new(DEFAULT_MATCH_EVIDENCE_MAX_CHARS)
469                            .expect("typed match evidence default is positive")
470                    }),
471            },
472        },
473    };
474    Some((field_budget, match_budget))
475}
476
477fn bounded_skill_run_value(
478    report: &crate::skill_run::SkillRunReport,
479    field_budget: FieldViewBudget,
480    match_budget: MatchViewBudget,
481) -> Result<serde_json::Value> {
482    let mut value = serde_json::to_value(report)?;
483    let crate::skill_run::SkillCapabilityOutput::MessageClassification(output) = &report.output;
484    let matches = output
485        .report
486        .matches
487        .iter()
488        .map(|matched| {
489            crate::message_search::classification_presentation_document(
490                matched,
491                field_budget,
492                match_budget,
493            )
494        })
495        .collect::<Result<Vec<_>>>()?;
496    value["output"]["result"]["report"]["matches"] = serde_json::Value::Array(matches);
497    Ok(value)
498}
499
500fn render_correction_report(
501    report: &crate::corrections::MessageClassificationReport,
502    format: OutputFormat,
503) -> Result<()> {
504    // JSON and JSONL return earlier from `render_skill_run_report` with the complete typed
505    // SkillRunReport. Human/table formats intentionally render only match rows.
506    let stdout = io::stdout();
507    let mut out = stdout.lock();
508    crate::render::render_record(report, &report.matches, format, &mut out)?;
509    out.flush()?;
510    Ok(())
511}
512
513fn compile_planning_regex(label: &str, pattern: &str) -> Result<Regex> {
514    Regex::new(&format!("(?i){pattern}"))
515        .map_err(|err| anyhow!("invalid {label} regex '{pattern}': {err}"))
516}
517
518/// Compile config and CLI slash-command filters. Regexes match the command token including its
519/// leading slash; empty input counts every slash command.
520pub(crate) fn compile_planning_filters(
521    config: &Config,
522    cli_patterns: &[String],
523) -> Result<Vec<Regex>> {
524    let mut filters =
525        Vec::with_capacity(config.analytics.planning_commands.len() + cli_patterns.len());
526    for pattern in &config.analytics.planning_commands {
527        filters.push(compile_planning_regex("planning_commands", pattern)?);
528    }
529    for pattern in cli_patterns {
530        filters.push(compile_planning_regex("--commands", pattern)?);
531    }
532    Ok(filters)
533}
534
535pub fn run_planning(db: &Db, config: &Config, args: &PlanningArgs) -> Result<()> {
536    let filters = filters_from(
537        db,
538        &args.session_id,
539        args.provider,
540        &args.path,
541        &args.dates,
542        args.limit.unwrap_or(config.analytics.planning_limit),
543        0,
544    )?;
545    let counts = crate::service::AnalysisService::new(config, db)
546        .planning(&filters, &args.command_patterns)?;
547    emit(&counts, args.format)
548}
549
550pub fn run_stats(db: &Db, config: &Config, args: &StatsArgs) -> Result<()> {
551    let filters = filters_from(
552        db,
553        &args.session_id,
554        args.provider,
555        &args.path,
556        &args.dates,
557        0,
558        0,
559    )?;
560    let rows = crate::service::AnalysisService::new(config, db).role_statistics(&filters)?;
561    emit(&rows, args.format)
562}
563
564/// One vocabulary term and its frequency (rendered by `vocab`).
565#[derive(Debug, Serialize)]
566struct VocabRow {
567    term: String,
568    /// Documents (messages) containing the term.
569    docs: i64,
570    /// Total occurrences across all messages.
571    count: i64,
572}
573
574impl Row for VocabRow {
575    fn headers() -> &'static [&'static str] {
576        &["term", "docs", "count"]
577    }
578    fn cells(&self) -> Vec<String> {
579        vec![
580            self.term.clone(),
581            self.docs.to_string(),
582            self.count.to_string(),
583        ]
584    }
585}
586
587#[derive(Debug, Args)]
588pub struct VocabArgs {
589    /// Report only terms starting with this text, which is how you ask about one term instead of
590    /// reading all of them. Matching is case-insensitive. Omit it, or pass an empty string, for
591    /// every term.
592    #[arg(long)]
593    pub prefix: Option<String>,
594    /// Read the substring (3-gram) index instead of word tokens: terms are 3 characters, spaces
595    /// and punctuation included, and both count columns report messages, never occurrences.
596    #[arg(long)]
597    pub trigram: bool,
598    /// Max terms (most frequent first). Omit to use `[analytics].vocab_limit`. 0 = every term.
599    #[arg(long)]
600    pub limit: Option<usize>,
601    /// Output format.
602    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
603    pub format: OutputFormat,
604}
605
606pub fn run_vocab(db: &Db, config: &AnalyticsConfig, args: &VocabArgs) -> Result<()> {
607    let limit = args.limit.unwrap_or(config.vocab_limit);
608    let rows: Vec<VocabRow> = db
609        .vocabulary(args.trigram, limit, args.prefix.as_deref())?
610        .into_iter()
611        .map(|(term, docs, count)| VocabRow { term, docs, count })
612        .collect();
613    emit(&rows, args.format)
614}
615
616#[derive(Debug, Clone, Serialize)]
617struct RepeatGroupExample {
618    session_id: String,
619    seq: i64,
620    ts: Option<String>,
621    preview: String,
622    context_command: String,
623}
624
625impl RepeatGroupExample {
626    fn from_hit(hit: &MessageHit, context: i64) -> Self {
627        Self {
628            session_id: hit.session_id.clone(),
629            seq: hit.seq,
630            ts: hit.ts.map(|ts| ts.to_rfc3339()),
631            preview: truncate_for_display(repeat_mining_text(&hit.content), TABLE_CONTENT_CHARS),
632            context_command: context_command(&hit.session_id, hit.seq, context),
633        }
634    }
635}
636
637#[derive(Debug, Serialize)]
638struct RepeatGroup {
639    repeat: String,
640    matches: usize,
641    sessions: usize,
642    examples: Vec<RepeatGroupExample>,
643}
644
645impl Row for RepeatGroup {
646    fn headers() -> &'static [&'static str] {
647        &["repeat", "matches", "sessions", "examples", "preview"]
648    }
649    fn cells(&self) -> Vec<String> {
650        let examples = self
651            .examples
652            .iter()
653            .take(3)
654            .map(|m| format!("{}:{}", m.session_id, m.seq))
655            .collect::<Vec<_>>()
656            .join(", ");
657        let preview = self
658            .examples
659            .first()
660            .map(|m| m.preview.clone())
661            .unwrap_or_default();
662        vec![
663            self.repeat.clone(),
664            self.matches.to_string(),
665            self.sessions.to_string(),
666            truncate_for_display(&examples, TABLE_CONTENT_CHARS),
667            preview,
668        ]
669    }
670}
671
672#[derive(Debug, Args)]
673pub struct RepeatsArgs {
674    /// Optional text to narrow candidate messages before repeat mining. Exact literal by default;
675    /// add --regex to interpret it as a Rust regex. A query starting with `-` is parsed as a flag
676    /// here; pass it after `--`, with every other flag before the `--`, e.g. `--regex -- ^/\S+`.
677    pub query: Option<String>,
678    /// Interpret QUERY as a Rust regex before repeat mining.
679    #[arg(long)]
680    pub regex: bool,
681    /// Filter by role: user (source-attributable human text only), assistant, tool
682    /// (calls/results), slash (human-entered commands), or compaction. Omit to mine human text.
683    #[arg(long = "role", value_enum)]
684    pub role: Option<Role>,
685    /// Restrict to one indexed session source. Omit to include all nine.
686    #[arg(long, value_enum)]
687    pub provider: Option<Provider>,
688    /// Exact session id or unique prefix. Use this when chaining from search output. Omit to include every session.
689    #[arg(long)]
690    pub session_id: Option<String>,
691    /// Restrict to sessions whose cwd or repo root is this directory or a descendant of it
692    /// (component boundary).
693    /// Omit to search every allowed root.
694    #[arg(long)]
695    pub path: Option<String>,
696    #[command(flatten)]
697    pub dates: DateRange,
698    /// Neighboring messages before/after each match in generated follow-up commands.
699    #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(i64).range(0..))]
700    pub context: i64,
701    /// Max candidate messages to scan. 0 = every candidate message.
702    #[arg(long, default_value_t = 0)]
703    pub limit: usize,
704    /// Max repeat groups to output. Omit to use `[analytics].repeat_max_groups`. 0 = every group.
705    #[arg(long)]
706    pub max_groups: Option<usize>,
707    /// Representative messages per group. Omit to use
708    /// `[analytics].repeat_max_examples_per_group`. 0 = every matching message. Aggregate
709    /// `matches` and `sessions` counts always cover the full group.
710    #[arg(long)]
711    pub max_examples_per_group: Option<usize>,
712    /// Minimum messages a discovered phrase must appear in. Omit to use
713    /// `[analytics].repeat_min_matches`.
714    #[arg(long)]
715    pub min_matches: Option<usize>,
716    /// Minimum words in a discovered phrase. Omit to use `[analytics].repeat_phrase_min_words`.
717    #[arg(long)]
718    pub phrase_min_words: Option<usize>,
719    /// Maximum words in a discovered phrase. Omit to use `[analytics].repeat_phrase_max_words`.
720    #[arg(long)]
721    pub phrase_max_words: Option<usize>,
722    /// Output format.
723    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
724    pub format: OutputFormat,
725}
726
727pub fn run_repeats(db: &Db, config: &AnalyticsConfig, args: &RepeatsArgs) -> Result<()> {
728    if args.regex && args.query.is_none() {
729        bail!("--regex requires QUERY");
730    }
731    run_repeats_issues(db, config, args)
732}
733
734fn repeat_filters(
735    db: &Db,
736    args: &RepeatsArgs,
737    default_role: Option<Role>,
738) -> Result<MessageFilters> {
739    let (since, until) = args.dates.resolve_now()?;
740    let exact_session_id = args
741        .session_id
742        .as_deref()
743        .map(|id| db.resolve_session_record(id).map(|session| session.id))
744        .transpose()?;
745    Ok(MessageFilters {
746        role: args.role.or(default_role),
747        providers: args.provider.map(|provider| vec![provider]),
748        session_id: exact_session_id,
749        path_prefix: args.path.as_deref().map(crate::util::normalize_path_prefix),
750        since,
751        until,
752        match_mode: if args.regex {
753            MessageSearchMode::Regex
754        } else {
755            MessageSearchMode::Literal
756        },
757        limit: args.limit,
758        ..Default::default()
759    })
760}
761
762/// The text the repeat scan filters messages by, in both literal and regex mode. `--regex` used
763/// to travel inside the filters (`MessageFilters::regex`) while the query text was passed as
764/// `""`; when the two modes were unified under `match_mode` (2026-07-14) the blank stayed, so
765/// `aise repeats --regex PATTERN` mined every message and the pattern was never applied.
766fn repeat_query(args: &RepeatsArgs) -> &str {
767    args.query.as_deref().unwrap_or("")
768}
769
770fn run_repeats_issues(db: &Db, config: &AnalyticsConfig, args: &RepeatsArgs) -> Result<()> {
771    let max_groups = args.max_groups.unwrap_or(config.repeat_max_groups);
772    let max_examples_per_group = args
773        .max_examples_per_group
774        .unwrap_or(config.repeat_max_examples_per_group);
775    let min_matches = args.min_matches.unwrap_or(config.repeat_min_matches);
776    let phrase_min_words = args
777        .phrase_min_words
778        .unwrap_or(config.repeat_phrase_min_words);
779    let phrase_max_words = args
780        .phrase_max_words
781        .unwrap_or(config.repeat_phrase_max_words);
782
783    if phrase_min_words == 0 {
784        bail!("--phrase-min-words must be at least 1");
785    }
786    if min_matches == 0 {
787        bail!("--min-matches must be at least 1");
788    }
789    if phrase_max_words < phrase_min_words {
790        bail!("--phrase-max-words must be >= --phrase-min-words");
791    }
792    let filters = repeat_filters(db, args, Some(Role::User))?;
793    let query = repeat_query(args);
794    // Default/user repeat mining is evidence about what a person wrote, not every provider row
795    // stored with `role=user`. Explicit assistant/tool/slash/compaction analysis retains its
796    // role-based contract because those roles do not claim human authorship.
797    let hits = if args.role.is_none() || args.role == Some(Role::User) {
798        db.search_attributable_human_messages(query, &filters)?
799    } else {
800        db.search_messages(query, &filters)?
801    };
802    let rows = repeat_phrase_groups(
803        &hits,
804        args.context,
805        min_matches,
806        phrase_min_words,
807        phrase_max_words,
808        max_groups,
809        max_examples_per_group,
810    );
811    emit(&rows, args.format)
812}
813
814fn repeat_phrase_groups(
815    hits: &[MessageHit],
816    context: i64,
817    min_matches: usize,
818    min_words: usize,
819    max_words: usize,
820    max_groups: usize,
821    max_examples_per_group: usize,
822) -> Vec<RepeatGroup> {
823    if hits.is_empty() {
824        return Vec::new();
825    }
826
827    let mut phrase_hits: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
828    for (index, hit) in hits.iter().enumerate() {
829        for phrase in phrases_in_message(&hit.content, min_words, max_words) {
830            phrase_hits.entry(phrase).or_default().insert(index);
831        }
832    }
833
834    let mut candidates: Vec<(String, BTreeSet<usize>)> = phrase_hits
835        .into_iter()
836        .filter(|(_, indices)| indices.len() >= min_matches)
837        .collect();
838    candidates.sort_by(|(phrase_a, hits_a), (phrase_b, hits_b)| {
839        hits_b
840            .len()
841            .cmp(&hits_a.len())
842            .then_with(|| phrase_word_count(phrase_b).cmp(&phrase_word_count(phrase_a)))
843            .then_with(|| phrase_a.cmp(phrase_b))
844    });
845
846    remove_equal_support_contained_phrases(&mut candidates);
847    if max_groups > 0 && candidates.len() > max_groups {
848        candidates.truncate(max_groups);
849    }
850
851    candidates
852        .into_iter()
853        .map(|(repeat, indices)| {
854            let matches = indices.len();
855            let sessions = indices
856                .iter()
857                .map(|&index| hits[index].session_id.as_str())
858                .collect::<BTreeSet<_>>()
859                .len();
860            let example_count = if max_examples_per_group == 0 {
861                matches
862            } else {
863                max_examples_per_group.min(matches)
864            };
865            let examples = indices
866                .iter()
867                .take(example_count)
868                .map(|&index| RepeatGroupExample::from_hit(&hits[index], context))
869                .collect();
870            RepeatGroup {
871                repeat,
872                matches,
873                sessions,
874                examples,
875            }
876        })
877        .collect()
878}
879
880fn remove_equal_support_contained_phrases(candidates: &mut Vec<(String, BTreeSet<usize>)>) {
881    let mut kept: Vec<(String, BTreeSet<usize>)> = Vec::with_capacity(candidates.len());
882    for (phrase, indices) in candidates.drain(..) {
883        let is_duplicate = kept.iter().any(|(kept_phrase, kept_indices)| {
884            kept_indices == &indices && phrase_is_contiguous_subphrase_of(&phrase, kept_phrase)
885        });
886        if !is_duplicate {
887            kept.push((phrase, indices));
888        }
889    }
890    *candidates = kept;
891}
892
893fn phrase_is_contiguous_subphrase_of(needle: &str, haystack: &str) -> bool {
894    if needle == haystack {
895        return true;
896    }
897    let needle_words = needle.split_whitespace().collect::<Vec<_>>();
898    let haystack_words = haystack.split_whitespace().collect::<Vec<_>>();
899    needle_words.len() < haystack_words.len()
900        && haystack_words
901            .windows(needle_words.len())
902            .any(|window| window == needle_words)
903}
904
905fn phrases_in_message(content: &str, min_words: usize, max_words: usize) -> BTreeSet<String> {
906    let tokens = normalized_tokens(repeat_mining_text(content));
907    let mut phrases = BTreeSet::new();
908    if tokens.len() < min_words {
909        return phrases;
910    }
911    let max_words = max_words.max(min_words).min(tokens.len());
912    for width in min_words..=max_words {
913        for window in tokens.windows(width) {
914            if informative_phrase(window) {
915                phrases.insert(window.join(" "));
916            }
917        }
918    }
919    phrases
920}
921
922fn repeat_mining_text(content: &str) -> &str {
923    extract_user_request_body(content).unwrap_or(content)
924}
925
926fn extract_user_request_body(content: &str) -> Option<&str> {
927    let start = content.find(USER_REQUEST_START)? + USER_REQUEST_START.len();
928    let end = content[start..].find(USER_REQUEST_END)? + start;
929    Some(content[start..end].trim())
930}
931
932/// Split `content` into caseless alphanumeric word tokens.
933///
934/// Tokens taken from stored text are compared against tokens a user typed, so both sides fold case
935/// the way search does ([`crate::util::fold_caseless_char`]) rather than merely lowercasing: Greek
936/// writes one letter as `Σ`, `σ`, or `ς` by position, and lowercasing keeps the last two apart.
937pub(crate) fn normalized_tokens(content: &str) -> Vec<String> {
938    let mut tokens = Vec::new();
939    let mut current = String::new();
940    for ch in content.chars() {
941        if ch.is_alphanumeric() {
942            current.extend(crate::util::fold_caseless_char(ch));
943        } else if !current.is_empty() {
944            tokens.push(std::mem::take(&mut current));
945        }
946    }
947    if !current.is_empty() {
948        tokens.push(current);
949    }
950    tokens
951}
952
953fn informative_phrase(tokens: &[String]) -> bool {
954    if tokens
955        .iter()
956        .any(|token| token.chars().any(|ch| ch.is_ascii_digit()))
957    {
958        return false;
959    }
960    tokens
961        .first()
962        .is_some_and(|token| !is_repeat_stopword(token))
963        && tokens
964            .last()
965            .is_some_and(|token| !is_repeat_stopword(token))
966        && tokens.iter().any(|token| {
967            token.len() >= 4
968                && !is_repeat_stopword(token)
969                && !token.chars().all(|ch| ch.is_ascii_digit())
970        })
971}
972
973fn is_repeat_stopword(token: &str) -> bool {
974    matches!(
975        token,
976        "a" | "an"
977            | "additional"
978            | "and"
979            | "are"
980            | "as"
981            | "at"
982            | "be"
983            | "but"
984            | "by"
985            | "can"
986            | "does"
987            | "do"
988            | "for"
989            | "from"
990            | "has"
991            | "have"
992            | "i"
993            | "if"
994            | "in"
995            | "is"
996            | "it"
997            | "local"
998            | "metadata"
999            | "not"
1000            | "of"
1001            | "on"
1002            | "or"
1003            | "rather"
1004            | "request"
1005            | "s"
1006            | "so"
1007            | "that"
1008            | "than"
1009            | "the"
1010            | "there"
1011            | "this"
1012            | "time"
1013            | "to"
1014            | "user"
1015            | "was"
1016            | "with"
1017            | "you"
1018            | "your"
1019    )
1020}
1021
1022fn phrase_word_count(phrase: &str) -> usize {
1023    phrase.split_whitespace().count()
1024}
1025
1026fn context_command(session_id: &str, seq: i64, context: i64) -> String {
1027    format!("aise messages get {session_id} --seq {seq} --context {context}")
1028}
1029
1030fn emit<T: Serialize + Row>(rows: &[T], format: OutputFormat) -> Result<()> {
1031    let stdout = io::stdout();
1032    let mut out = stdout.lock();
1033    render(rows, format, &mut out)?;
1034    out.flush()?;
1035    Ok(())
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041
1042    fn patterns() -> Vec<(String, Regex)> {
1043        compile_patterns(&Config::default()).unwrap()
1044    }
1045
1046    /// One Greek word is one token however its sigma is written.
1047    ///
1048    /// Repeat mining and phrase analysis compare a token a user typed against tokens taken from
1049    /// stored text, so the two sides have to agree on what the same word is — the same requirement
1050    /// search has, and the same rule answers it ([`crate::util::fold_caseless_char`]). Lowercasing
1051    /// alone leaves `ς` and `σ` apart, which counted one repeated word as two and let an excluded
1052    /// phrase token pass through unexcluded.
1053    #[test]
1054    fn a_greek_word_normalizes_to_one_token_whichever_sigma_it_is_written_with() {
1055        let expected = vec!["οδοσσ".to_string()];
1056        for spelling in ["ΟΔΟΣΣ", "οδοσς", "οδοσσ", "Οδοσς"] {
1057            assert_eq!(
1058                normalized_tokens(spelling),
1059                expected,
1060                "{spelling:?} is the same token"
1061            );
1062        }
1063    }
1064
1065    fn categorize(text: &str) -> Option<String> {
1066        patterns()
1067            .iter()
1068            .find_map(|(cat, re)| re.is_match(text).then(|| cat.clone()))
1069    }
1070
1071    fn hit(seq: i64, role: Role, content: &str) -> MessageHit {
1072        MessageHit {
1073            session_id: "claude:test".to_string(),
1074            provider: Provider::Claude,
1075            seq,
1076            role,
1077            kind: crate::models::MessageKind::Conversation,
1078            ts: None,
1079            tool_name: None,
1080            tool_call_id: None,
1081            fuzzy_score: None,
1082            content: content.to_string(),
1083        }
1084    }
1085
1086    #[test]
1087    fn phrase_is_contiguous_subphrase_of_requires_contiguity() {
1088        assert!(phrase_is_contiguous_subphrase_of("a b", "a b")); // equal phrases
1089        assert!(phrase_is_contiguous_subphrase_of("a b", "a b c")); // contiguous prefix
1090        assert!(phrase_is_contiguous_subphrase_of("b c", "a b c")); // contiguous suffix
1091        assert!(phrase_is_contiguous_subphrase_of("b", "a b c")); // single word inside
1092        assert!(!phrase_is_contiguous_subphrase_of("a c", "a b c")); // gap: not contiguous
1093        assert!(!phrase_is_contiguous_subphrase_of("a b c", "a b")); // needle longer than haystack
1094        assert!(!phrase_is_contiguous_subphrase_of("x", "a b c")); // absent entirely
1095    }
1096
1097    #[test]
1098    fn informative_phrase_rejects_stopword_ends_and_numeric_noise() {
1099        let toks = |s: &str| s.split(' ').map(String::from).collect::<Vec<_>>();
1100        // A content token of four or more characters at both ends is informative.
1101        assert!(informative_phrase(&toks("deploy service")));
1102        // A stopword at the first or last position disqualifies the phrase.
1103        assert!(!informative_phrase(&toks("and service")));
1104        assert!(!informative_phrase(&toks("deploy and")));
1105        // Any digit-bearing token marks the whole phrase as numeric noise.
1106        assert!(!informative_phrase(&toks("deploy service2")));
1107        // No token reaches the four-character content threshold.
1108        assert!(!informative_phrase(&toks("run ci")));
1109        // The empty phrase is never informative.
1110        assert!(!informative_phrase(&[]));
1111    }
1112
1113    #[test]
1114    fn remove_equal_support_contained_phrases_is_support_and_order_sensitive() {
1115        let set = |xs: &[usize]| xs.iter().copied().collect::<BTreeSet<usize>>();
1116        // Same support and the container comes first: the contained subphrase drops.
1117        let mut c = vec![
1118            ("deploy service".to_string(), set(&[1, 2])),
1119            ("deploy".to_string(), set(&[1, 2])),
1120        ];
1121        remove_equal_support_contained_phrases(&mut c);
1122        assert_eq!(c.len(), 1);
1123        assert_eq!(c[0].0, "deploy service");
1124        // Different support: both survive even though one contains the other.
1125        let mut c = vec![
1126            ("deploy service".to_string(), set(&[1, 2])),
1127            ("deploy".to_string(), set(&[5])),
1128        ];
1129        remove_equal_support_contained_phrases(&mut c);
1130        assert_eq!(c.len(), 2);
1131        // Order matters: a container appearing after its subphrase is not itself a
1132        // subphrase of the kept shorter phrase, so both are kept.
1133        let mut c = vec![
1134            ("deploy".to_string(), set(&[1, 2])),
1135            ("deploy service".to_string(), set(&[1, 2])),
1136        ];
1137        remove_equal_support_contained_phrases(&mut c);
1138        assert_eq!(c.len(), 2);
1139    }
1140
1141    #[test]
1142    fn compile_planning_regex_is_case_insensitive_and_reports_invalid_patterns() {
1143        // The compiler prepends (?i), so matching ignores case.
1144        let re = compile_planning_regex("planning", "/goal").unwrap();
1145        assert!(re.is_match("/GOAL"));
1146        // An invalid pattern fails with the label and offending pattern named.
1147        let err = compile_planning_regex("planning", "[unclosed")
1148            .unwrap_err()
1149            .to_string();
1150        assert!(err.contains("invalid planning regex '[unclosed'"), "{err}");
1151    }
1152
1153    #[test]
1154    fn repeats_mine_only_original_attributable_human_content() {
1155        let dir = tempfile::tempdir().unwrap();
1156        let db = Db::open(&dir.path().join("index.db")).unwrap();
1157        db.execute_batch_for_test(
1158            "insert into sessions (
1159                     id, provider, provider_session_id, preview_text,
1160                     source_path, parse_version, discovery_source
1161                 ) values ('claude:repeat-authorship', 'claude', 'repeat-authorship', '',
1162                           '/repeat-authorship', 'v1', 'test');
1163                 insert into messages (
1164                     id, session_id, provider, seq, role, kind, content, authorship, record_relation
1165                 ) values
1166                   (101, 'claude:repeat-authorship', 'claude', 0, 'user', 'conversation',
1167                    'durable human phrase', 'human', 'original'),
1168                   (102, 'claude:repeat-authorship', 'claude', 1, 'user', 'conversation',
1169                    'durable human phrase', 'human', 'original'),
1170                   (103, 'claude:repeat-authorship', 'claude', 2, 'user', 'conversation',
1171                    'durable agent phrase', 'agent', 'original'),
1172                   (104, 'claude:repeat-authorship', 'claude', 3, 'user', 'harness_notice',
1173                    'durable harness phrase', 'harness', 'original'),
1174                   (105, 'claude:repeat-authorship', 'claude', 4, 'user', 'conversation',
1175                    'durable mirror phrase', 'human', 'mirror'),
1176                   (106, 'claude:repeat-authorship', 'claude', 5, 'user', 'conversation',
1177                    'durable unknown phrase', 'unknown', 'unknown'),
1178                   (107, 'claude:repeat-authorship', 'claude', 6, 'user', 'conversation',
1179                    'mixed human phrase generated repeated phrase', 'mixed', 'original'),
1180                   (108, 'claude:repeat-authorship', 'claude', 7, 'user', 'conversation',
1181                    'mixed human phrase generated repeated phrase', 'mixed', 'original');
1182                 insert into message_content_parts (
1183                     message_id, ordinal, start_char, end_char, authorship, origin
1184                 ) values
1185                   (107, 0, 0, 18, 'human', 'direct_input'),
1186                   (107, 1, 18, 43, 'generated', 'tool_payload'),
1187                   (108, 0, 0, 18, 'human', 'direct_input'),
1188                   (108, 1, 18, 43, 'generated', 'tool_payload');",
1189        )
1190        .unwrap();
1191        let args = RepeatsArgs {
1192            query: None,
1193            regex: false,
1194            role: None,
1195            provider: None,
1196            session_id: None,
1197            path: None,
1198            dates: DateRange::default(),
1199            context: 0,
1200            limit: 0,
1201            max_groups: Some(0),
1202            max_examples_per_group: Some(0),
1203            min_matches: Some(2),
1204            phrase_min_words: Some(2),
1205            phrase_max_words: Some(4),
1206            format: OutputFormat::Json,
1207        };
1208        let filters = repeat_filters(&db, &args, Some(Role::User)).unwrap();
1209        let hits = db.search_attributable_human_messages("", &filters).unwrap();
1210        assert_eq!(
1211            hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
1212            [0, 1, 6, 7]
1213        );
1214        assert_eq!(hits[2].content, "mixed human phrase");
1215        assert_eq!(hits[3].content, "mixed human phrase");
1216        let groups = repeat_phrase_groups(&hits, 0, 2, 2, 4, 0, 0);
1217        assert!(groups
1218            .iter()
1219            .any(|group| group.repeat == "durable human phrase"));
1220        assert!(groups
1221            .iter()
1222            .any(|group| group.repeat == "mixed human phrase"));
1223        assert!(!groups.iter().any(|group| group.repeat.contains("agent")));
1224        assert!(!groups.iter().any(|group| group.repeat.contains("harness")));
1225        assert!(!groups.iter().any(|group| group.repeat.contains("mirror")));
1226        assert!(!groups.iter().any(|group| group.repeat.contains("unknown")));
1227        assert!(!groups
1228            .iter()
1229            .any(|group| group.repeat.contains("generated")));
1230    }
1231
1232    /// `--regex PATTERN` must narrow the mined messages by PATTERN. The pattern was passed as
1233    /// `""` after the match modes were unified, so every message was mined and the documented
1234    /// `--regex -- ^/\S+` example could not work.
1235    #[test]
1236    fn repeats_apply_the_regex_query_to_the_mined_messages() {
1237        let dir = tempfile::tempdir().unwrap();
1238        let db = Db::open(&dir.path().join("index.db")).unwrap();
1239        db.execute_batch_for_test(
1240            "insert into sessions (
1241                     id, provider, provider_session_id, preview_text,
1242                     source_path, parse_version, discovery_source
1243                 ) values ('claude:repeat-regex', 'claude', 'repeat-regex', '',
1244                           '/repeat-regex', 'v1', 'test');
1245                 insert into messages (
1246                     id, session_id, provider, seq, role, kind, content, authorship, record_relation
1247                 ) values
1248                   (201, 'claude:repeat-regex', 'claude', 0, 'user', 'conversation',
1249                    '/plan the login fix', 'human', 'original'),
1250                   (202, 'claude:repeat-regex', 'claude', 1, 'user', 'conversation',
1251                    '/plan the login fix', 'human', 'original'),
1252                   (203, 'claude:repeat-regex', 'claude', 2, 'user', 'conversation',
1253                    'please rerun the login fix', 'human', 'original');",
1254        )
1255        .unwrap();
1256        let regex_args = |pattern: &str| RepeatsArgs {
1257            query: Some(pattern.to_string()),
1258            regex: true,
1259            role: None,
1260            provider: None,
1261            session_id: None,
1262            path: None,
1263            dates: DateRange::default(),
1264            context: 0,
1265            limit: 0,
1266            max_groups: Some(0),
1267            max_examples_per_group: Some(0),
1268            min_matches: Some(2),
1269            phrase_min_words: Some(2),
1270            phrase_max_words: Some(4),
1271            format: OutputFormat::Json,
1272        };
1273        let slash = regex_args(r"^/\S+");
1274        assert_eq!(repeat_query(&slash), r"^/\S+");
1275        let filters = repeat_filters(&db, &slash, Some(Role::User)).unwrap();
1276        assert_eq!(filters.match_mode, MessageSearchMode::Regex);
1277        let hits = db
1278            .search_attributable_human_messages(repeat_query(&slash), &filters)
1279            .unwrap();
1280        assert_eq!(
1281            hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
1282            [0, 1],
1283            "only the slash-command messages match ^/\\S+"
1284        );
1285
1286        let none = regex_args("zzz_no_match");
1287        let filters = repeat_filters(&db, &none, Some(Role::User)).unwrap();
1288        assert!(db
1289            .search_attributable_human_messages(repeat_query(&none), &filters)
1290            .unwrap()
1291            .is_empty());
1292
1293        let invalid = regex_args("[unclosed");
1294        let filters = repeat_filters(&db, &invalid, Some(Role::User)).unwrap();
1295        let error = db
1296            .search_attributable_human_messages(repeat_query(&invalid), &filters)
1297            .unwrap_err()
1298            .to_string();
1299        assert!(error.contains("invalid regex"), "{error}");
1300    }
1301
1302    #[test]
1303    fn repeat_phrase_groups_find_repeated_phrases_without_builtins() {
1304        let hits = vec![
1305            hit(
1306                10,
1307                Role::User,
1308                "remember to avoid magic values and make the timeout configurable",
1309            ),
1310            hit(
1311                20,
1312                Role::User,
1313                "please avoid magic values and keep the limit configurable",
1314            ),
1315            hit(
1316                30,
1317                Role::User,
1318                "please reuse the existing helper instead of duplicate code",
1319            ),
1320        ];
1321
1322        let groups = repeat_phrase_groups(&hits, 3, 2, 2, 4, 0, 0);
1323
1324        let avoid_magic_values = groups
1325            .iter()
1326            .find(|group| group.repeat == "avoid magic values")
1327            .expect("maximal repeated phrase is discovered from the data");
1328        assert!(groups.iter().all(|group| group.repeat != "avoid magic"));
1329        assert!(groups.iter().all(|group| group.repeat != "magic values"));
1330        assert_eq!(avoid_magic_values.matches, 2);
1331        assert_eq!(avoid_magic_values.sessions, 1);
1332        assert_eq!(
1333            avoid_magic_values
1334                .examples
1335                .iter()
1336                .map(|m| m.seq)
1337                .collect::<Vec<_>>(),
1338            vec![10, 20]
1339        );
1340        assert_eq!(
1341            avoid_magic_values.examples[0].context_command,
1342            "aise messages get claude:test --seq 10 --context 3"
1343        );
1344    }
1345
1346    #[test]
1347    fn repeat_phrase_groups_keep_only_maximal_phrase_for_equal_support() {
1348        let hits = vec![
1349            hit(10, Role::User, "avoid magic values everywhere"),
1350            hit(20, Role::User, "avoid magic values everywhere"),
1351        ];
1352
1353        let groups = repeat_phrase_groups(&hits, 0, 2, 2, 4, 0, 0);
1354
1355        assert_eq!(
1356            groups.len(),
1357            1,
1358            "contained fragments add no distinct evidence"
1359        );
1360        assert_eq!(groups[0].repeat, "avoid magic values everywhere");
1361        assert_eq!(groups[0].matches, 2);
1362    }
1363
1364    #[test]
1365    fn repeat_phrase_mining_uses_user_request_body_not_harness_metadata() {
1366        let content = "<USER_REQUEST>\navoid magic values and keep settings configurable\n</USER_REQUEST><ADDITIONAL_METADATA>\nThe current local time is 2026-06-30T06:49:05Z.\n</ADDITIONAL_METADATA>";
1367
1368        let phrases = phrases_in_message(content, 2, 4);
1369
1370        assert!(phrases.contains("magic values"));
1371        assert!(phrases.contains("avoid magic values"));
1372        assert!(!phrases.contains("current local time"));
1373        assert!(!phrases.contains("additional metadata"));
1374
1375        let member = RepeatGroupExample::from_hit(&hit(1, Role::User, content), 0);
1376        assert!(member.preview.starts_with("avoid magic values"));
1377        assert!(!member.preview.contains("USER_REQUEST"));
1378    }
1379
1380    #[test]
1381    fn repeat_phrase_mining_skips_numeric_noise() {
1382        let phrases = phrases_in_message("local time is 2026 06 30 and version v4", 2, 4);
1383
1384        assert!(!phrases.iter().any(|phrase| phrase.contains("2026")));
1385        assert!(!phrases.iter().any(|phrase| phrase.contains("v4")));
1386    }
1387
1388    #[test]
1389    fn repeat_group_examples_are_bounded_without_losing_aggregate_counts() {
1390        let hits = vec![
1391            hit(10, Role::User, "avoid magic values everywhere"),
1392            hit(20, Role::User, "avoid magic values everywhere"),
1393            hit(30, Role::User, "avoid magic values everywhere"),
1394        ];
1395
1396        let groups = repeat_phrase_groups(&hits, 0, 2, 2, 4, 1, 2);
1397
1398        assert_eq!(groups.len(), 1);
1399        assert_eq!(groups[0].matches, 3);
1400        assert_eq!(groups[0].sessions, 1);
1401        assert_eq!(groups[0].examples.len(), 2);
1402        assert_eq!(
1403            groups[0]
1404                .examples
1405                .iter()
1406                .map(|example| example.seq)
1407                .collect::<Vec<_>>(),
1408            vec![10, 20]
1409        );
1410        let serialized = serde_json::to_value(&groups).unwrap();
1411        assert!(serialized[0].get("examples").is_some());
1412        assert!(serialized[0]["examples"][0].get("matched_text").is_none());
1413
1414        let all_examples = repeat_phrase_groups(&hits, 0, 2, 2, 4, 1, 0);
1415        assert_eq!(all_examples[0].examples.len(), 3);
1416    }
1417
1418    #[test]
1419    fn repeat_group_limit_applies_before_example_materialization() {
1420        let hits = vec![
1421            hit(10, Role::User, "alpha bravo first"),
1422            hit(20, Role::User, "alpha bravo second"),
1423            hit(30, Role::User, "charlie delta third"),
1424            hit(40, Role::User, "charlie delta fourth"),
1425        ];
1426
1427        let groups = repeat_phrase_groups(&hits, 0, 2, 2, 2, 1, 1);
1428
1429        assert_eq!(groups.len(), 1);
1430        assert_eq!(groups[0].examples.len(), 1);
1431    }
1432
1433    // The value is `Regex::find(..).as_str()` — the matched substring, not the rule that matched.
1434    // The old `pattern` header and `matched_pattern` field named the rule INPUT while carrying the
1435    // OUTPUT, so a reader comparing the column against their config would never find it. Assert
1436    // the absence of the misleading spelling too: a presence-only test lets it return alongside.
1437    #[test]
1438    fn correction_column_names_the_matched_text_not_the_rule() {
1439        let headers = MessageClassificationMatch::headers();
1440        assert!(
1441            headers.contains(&"match"),
1442            "the column must name the matched text: {headers:?}"
1443        );
1444        assert!(
1445            !headers.contains(&"pattern"),
1446            "`pattern` names the rule input and must not return: {headers:?}"
1447        );
1448    }
1449
1450    #[test]
1451    fn categories_match_expected_keywords() {
1452        assert_eq!(
1453            categorize("you forgot to run the tests").as_deref(),
1454            Some("skip_step")
1455        );
1456        assert_eq!(
1457            categorize("that is actually wrong").as_deref(),
1458            Some("misunderstanding")
1459        );
1460        assert_eq!(
1461            categorize("you must also add a test").as_deref(),
1462            Some("incomplete")
1463        );
1464    }
1465
1466    #[test]
1467    fn first_match_wins_and_other_is_last() {
1468        // "stop" alone -> other (catch-all, last).
1469        assert_eq!(categorize("stop").as_deref(), Some("other"));
1470        // A message with a specific signal is categorized before falling to other.
1471        assert_eq!(
1472            categorize("you removed the function").as_deref(),
1473            Some("regression")
1474        );
1475        // No correction signal -> no category.
1476        assert_eq!(categorize("looks great, thanks"), None);
1477    }
1478
1479    #[test]
1480    fn stop_matches_imperative_corrections_not_workflow_phrasing() {
1481        // Genuine imperative-stop corrections are kept.
1482        assert_eq!(categorize("stop").as_deref(), Some("other"));
1483        assert_eq!(
1484            categorize("stop falsely marking goals as complete").as_deref(),
1485            Some("other")
1486        );
1487        assert_eq!(
1488            categorize("stop incrementing the version so frequently").as_deref(),
1489            Some("other")
1490        );
1491        assert_eq!(
1492            categorize("please stop, that approach is off").as_deref(),
1493            Some("other")
1494        );
1495        // Benign workflow phrasings must NOT be flagged as corrections: a bare
1496        // \bstop\b matched all of these (test fixtures, checkpoint instructions).
1497        assert_eq!(
1498            categorize("Run this bash command once and stop: grep hi /tmp/x"),
1499            None
1500        );
1501        assert_eq!(
1502            categorize("at your next progress point commit and then stop"),
1503            None
1504        );
1505        assert_eq!(
1506            categorize("keep going dont stop for trivial questions"),
1507            None
1508        );
1509        assert_eq!(
1510            categorize("a clear way to start and stop all the tooling"),
1511            None
1512        );
1513    }
1514
1515    #[test]
1516    fn default_patterns_are_precise_on_labeled_corpus() {
1517        // True positives: real corrections (user correcting the assistant) must be flagged.
1518        let positives: &[(&str, &str)] = &[
1519            ("you deleted my helper function", "regression"),
1520            ("you broke the build", "regression"),
1521            ("that reverted my changes", "regression"),
1522            ("you forgot to update the test", "skip_step"),
1523            ("you missed the edge case", "skip_step"),
1524            ("don't forget the migration", "skip_step"),
1525            ("that's wrong, the API returns a list", "misunderstanding"),
1526            ("no, that's not what I asked", "misunderstanding"),
1527            ("you're wrong about the types", "misunderstanding"),
1528            ("you also need to handle the error case", "incomplete"),
1529            ("that's not finished, the tests still fail", "incomplete"),
1530            ("stop changing the config", "other"),
1531            ("please stop", "other"),
1532        ];
1533        for (text, want) in positives {
1534            assert_eq!(
1535                categorize(text).as_deref(),
1536                Some(*want),
1537                "true positive: {text:?}"
1538            );
1539        }
1540        // True negatives: benign developer phrasing must NOT be flagged as a correction.
1541        let negatives: &[&str] = &[
1542            "let's revert to the design doc approach",
1543            "the rollback procedure is documented in the README",
1544            "this broke down into three subtasks",
1545            "I lost track of which branch we're on",
1546            "actually, let's use a HashMap here",
1547            "wait, let me check the logs first",
1548            "what could go wrong here?",
1549            "no thanks, that's all for now",
1550            "we should have access to the API",
1551            "run the command once and stop",
1552            "the incorrect assumption was already fixed",
1553        ];
1554        for text in negatives {
1555            assert_eq!(
1556                categorize(text),
1557                None,
1558                "true negative must not match: {text:?}"
1559            );
1560        }
1561    }
1562
1563    #[test]
1564    fn planning_commands_config_compiles_to_filters() {
1565        let mut config = Config::default();
1566        // Default: no planning_commands → empty filter (count every slash command).
1567        assert!(compile_planning_filters(&config, &[]).unwrap().is_empty());
1568        // Configured: each entry compiles to a case-insensitive regex filter.
1569        config.analytics.planning_commands = vec!["^/cmd-a".to_string(), "review".to_string()];
1570        let filters = compile_planning_filters(&config, &["^/cmd-b$".to_string()]).unwrap();
1571        assert_eq!(filters.len(), 3);
1572        assert!(filters[0].is_match("/cmd-a"));
1573        assert!(filters[0].is_match("/cmd-a-review"));
1574        assert!(filters[1].is_match("/REVIEW"));
1575        assert!(filters[2].is_match("/cmd-b"));
1576        assert!(!filters[2].is_match("/cmd-b-extra"));
1577    }
1578}