Skip to main content

aicx_parser/
skill_collapse.rs

1//! Cross-entry skill-template + content-hash collapse.
2//!
3//! Detects repeated message bodies (e.g. `/loop` fire'ing the same skill
4//! template, repeated tool outputs, fixture reposts) and collapses every
5//! occurrence after the first into a stub reference. Operates message-level
6//! across an entry sequence, complementing line-level [`crate::noise`].
7//!
8//! Originating signal: extracting a `/loop`-driven session yielded ~16k
9//! lines, ~60% of which were verbatim re-pastes of the same skill prompt
10//! (`vc-ownership` ~290 lines per fire × 6 fires). The line-level noise
11//! filter could not catch this — the lines themselves are semantic prose,
12//! they're just identical across messages. Collapse acts at message level
13//! across the sequence: keep the first full body, replace later identical
14//! bodies with a one-line stub pointing at the first occurrence.
15
16use crate::timeline::TimelineEntry;
17use std::collections::HashMap;
18
19/// Minimum line count for a message to be eligible for collapse.
20///
21/// Short messages (`< 8` lines) repeat naturally in conversations — `"ok"`,
22/// `"yes"`, brief acknowledgements, terse `assistant` replies — and must
23/// never be collapsed. The threshold is empirical: skill templates and
24/// tool boilerplates start at ~20+ lines, while the longest legitimate
25/// short replies stay under 8.
26pub const DEFAULT_THRESHOLD_LINES: usize = 8;
27
28/// Aggregate statistics from a single [`collapse_repeats`] pass.
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
30pub struct CollapseStats {
31    /// Number of messages that were replaced with a stub.
32    pub messages_collapsed: usize,
33    /// Total bytes saved across all collapsed messages
34    /// (`sum(original_len - stub_len)`).
35    pub bytes_saved: usize,
36}
37
38impl CollapseStats {
39    /// Merge another stats record into `self` (additive).
40    pub fn merge(&mut self, other: &Self) {
41        self.messages_collapsed += other.messages_collapsed;
42        self.bytes_saved += other.bytes_saved;
43    }
44}
45
46/// Collapse repeated message bodies in-place across an entry sequence.
47///
48/// Strategy: first occurrence of each unique message body (with `>=
49/// threshold_lines` lines) is kept verbatim; later identical bodies are
50/// replaced with a single-line stub:
51///
52/// - `<skill-ref: <name> | first-seen: entry #<idx>>` when the body
53///   matches a `Base directory for this skill: <path>` marker
54///   (see [`detect_skill_marker`]).
55/// - `<dedup-ref: hash:<8hex> | first-seen: entry #<idx>>` otherwise.
56///
57/// Returns the (possibly modified) entry vec and a [`CollapseStats`]
58/// record for observability.
59pub fn collapse_repeats(
60    mut entries: Vec<TimelineEntry>,
61    threshold_lines: usize,
62) -> (Vec<TimelineEntry>, CollapseStats) {
63    let mut seen: HashMap<u64, usize> = HashMap::new();
64    let mut stats = CollapseStats::default();
65
66    for (idx, entry) in entries.iter_mut().enumerate() {
67        let line_count = entry.message.lines().count();
68        if line_count < threshold_lines {
69            continue;
70        }
71        let h = hash_message(&entry.message);
72        if let Some(&first_idx) = seen.get(&h) {
73            let original_len = entry.message.len();
74            let stub = match detect_skill_marker(&entry.message) {
75                Some(name) => format!("<skill-ref: {} | first-seen: entry #{}>", name, first_idx),
76                None => format!(
77                    "<dedup-ref: hash:{:08x} | first-seen: entry #{}>",
78                    h as u32, first_idx
79                ),
80            };
81            stats.messages_collapsed += 1;
82            stats.bytes_saved += original_len.saturating_sub(stub.len());
83            entry.message = stub;
84        } else {
85            seen.insert(h, idx);
86        }
87    }
88
89    (entries, stats)
90}
91
92/// Detect a skill-template marker in the first ~8 lines of a message body.
93///
94/// Pattern: `Base directory for this skill: <path>` (optionally with a
95/// `> ` quote prefix when the message was rendered into a markdown
96/// blockquote). Returns the skill name — the path basename — when found.
97///
98/// Examples:
99/// - `"Base directory for this skill: /Users/x/.claude/skills/vc-ownership"`
100///   → `Some("vc-ownership")`
101/// - `"> Base directory for this skill: /home/y/.claude/skills/vc-init"`
102///   → `Some("vc-init")`
103/// - `"Some other content"` → `None`
104pub fn detect_skill_marker(message: &str) -> Option<String> {
105    const MARKER: &str = "Base directory for this skill: ";
106    message.lines().take(8).find_map(|line| {
107        let trimmed = line.trim_start();
108        let after_quote = trimmed.trim_start_matches('>').trim_start();
109        let path = after_quote.strip_prefix(MARKER)?;
110        std::path::Path::new(path.trim())
111            .file_name()
112            .and_then(|s| s.to_str())
113            .map(String::from)
114    })
115}
116
117/// Stable hash of a message body for content-equality lookup.
118///
119/// Uses explicitly pinned SipHash-1-3. Content-equality is enforced by the
120/// source string itself; the hash is only an index into the seen-set, so
121/// collision risk stays negligible at session scale (typically `<= O(10^4)`
122/// messages).
123fn hash_message(message: &str) -> u64 {
124    use std::hash::{Hash, Hasher};
125    let mut h = siphasher::sip::SipHasher13::new();
126    message.hash(&mut h);
127    h.finish()
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use chrono::Utc;
134
135    fn entry(role: &str, message: &str) -> TimelineEntry {
136        TimelineEntry {
137            timestamp: Utc::now(),
138            agent: "claude".into(),
139            session_id: "test-session".into(),
140            role: role.into(),
141            message: message.into(),
142            frame_kind: None,
143            branch: None,
144            cwd: None,
145            timestamp_source: None,
146        }
147    }
148
149    fn long_skill_body(skill: &str, suffix: &str) -> String {
150        let mut body = format!("Base directory for this skill: /home/u/.claude/skills/{skill}\n");
151        body.push_str("# SkillName\n## Purpose\n");
152        for i in 0..30 {
153            body.push_str(&format!("- bullet line {i}\n"));
154        }
155        body.push_str(suffix);
156        body
157    }
158
159    #[test]
160    fn collapses_repeated_skill_template() {
161        let body = long_skill_body("vc-ownership", "");
162        let entries = vec![
163            entry("user", &body),
164            entry("user", &body),
165            entry("user", &body),
166        ];
167        let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
168
169        assert_eq!(stats.messages_collapsed, 2);
170        assert!(stats.bytes_saved > 0);
171        assert_eq!(out[0].message, body, "first occurrence kept verbatim");
172        assert!(
173            out[1].message.starts_with("<skill-ref: vc-ownership"),
174            "second occurrence collapsed with skill-ref stub: {}",
175            out[1].message
176        );
177        assert!(out[2].message.contains("first-seen: entry #0"));
178    }
179
180    #[test]
181    fn skill_marker_naming_extracted() {
182        let body = long_skill_body("vc-init", "");
183        let entries = vec![entry("user", &body), entry("user", &body)];
184        let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
185        assert!(out[1].message.contains("vc-init"));
186    }
187
188    #[test]
189    fn dedup_ref_used_when_no_skill_marker() {
190        let body = (0..20)
191            .map(|i| format!("line {i}"))
192            .collect::<Vec<_>>()
193            .join("\n");
194        let entries = vec![entry("assistant", &body), entry("assistant", &body)];
195        let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
196        assert_eq!(stats.messages_collapsed, 1);
197        assert!(out[1].message.starts_with("<dedup-ref: hash:"));
198        assert!(out[1].message.contains("first-seen: entry #0"));
199    }
200
201    #[test]
202    fn short_messages_not_collapsed() {
203        // Two-line bodies are way under threshold even when identical.
204        let entries = vec![
205            entry("user", "ok\nthanks"),
206            entry("user", "ok\nthanks"),
207            entry("user", "ok\nthanks"),
208        ];
209        let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
210        assert_eq!(stats.messages_collapsed, 0);
211        assert_eq!(out[1].message, "ok\nthanks");
212        assert_eq!(out[2].message, "ok\nthanks");
213    }
214
215    #[test]
216    fn different_long_messages_not_collapsed() {
217        let a = long_skill_body("vc-ownership", "different-tail-A");
218        let b = long_skill_body("vc-ownership", "different-tail-B");
219        let entries = vec![entry("user", &a), entry("user", &b)];
220        let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
221        assert_eq!(stats.messages_collapsed, 0);
222        assert_eq!(out[0].message, a);
223        assert_eq!(out[1].message, b);
224    }
225
226    #[test]
227    fn first_occurrence_index_is_stable() {
228        let body = long_skill_body("vc-init", "");
229        let entries = vec![
230            entry("user", "short start"),
231            entry("user", &body),
232            entry("user", "short middle"),
233            entry("user", &body),
234        ];
235        let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
236        assert!(
237            out[3].message.contains("first-seen: entry #1"),
238            "stub should reference index 1 (the first long occurrence): {}",
239            out[3].message
240        );
241    }
242
243    #[test]
244    fn detect_skill_marker_plain() {
245        let msg = "Base directory for this skill: /home/u/.claude/skills/vc-init\n# Init\n";
246        assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-init"));
247    }
248
249    #[test]
250    fn detect_skill_marker_quote_prefix() {
251        let msg = "> Base directory for this skill: /home/u/.claude/skills/vc-ownership\n";
252        assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-ownership"));
253    }
254
255    #[test]
256    fn detect_skill_marker_indented_quote() {
257        let msg = "   >> Base directory for this skill: /a/b/skills/vc-marbles\n";
258        assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-marbles"));
259    }
260
261    #[test]
262    fn detect_skill_marker_returns_none_for_plain_text() {
263        assert_eq!(detect_skill_marker("Just a regular message"), None);
264        assert_eq!(detect_skill_marker(""), None);
265    }
266
267    #[test]
268    fn detect_skill_marker_skipped_after_first_8_lines() {
269        let mut msg = String::new();
270        for _ in 0..10 {
271            msg.push_str("filler\n");
272        }
273        msg.push_str("Base directory for this skill: /a/b/c/vc-late\n");
274        // The marker is past the 8-line scan window — must not match.
275        assert_eq!(detect_skill_marker(&msg), None);
276    }
277
278    #[test]
279    fn merge_stats_is_additive() {
280        let mut a = CollapseStats {
281            messages_collapsed: 2,
282            bytes_saved: 1000,
283        };
284        let b = CollapseStats {
285            messages_collapsed: 3,
286            bytes_saved: 500,
287        };
288        a.merge(&b);
289        assert_eq!(a.messages_collapsed, 5);
290        assert_eq!(a.bytes_saved, 1500);
291    }
292
293    #[test]
294    fn empty_input_returns_empty() {
295        let (out, stats) = collapse_repeats(Vec::new(), DEFAULT_THRESHOLD_LINES);
296        assert!(out.is_empty());
297        assert_eq!(stats, CollapseStats::default());
298    }
299}