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/user/.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            source_path: None,
147            source_sha256: None,
148            source_line_span: None,
149        }
150    }
151
152    fn long_skill_body(skill: &str, suffix: &str) -> String {
153        let mut body =
154            format!("Base directory for this skill: /home/user/.claude/skills/{skill}\n");
155        body.push_str("# SkillName\n## Purpose\n");
156        for i in 0..30 {
157            body.push_str(&format!("- bullet line {i}\n"));
158        }
159        body.push_str(suffix);
160        body
161    }
162
163    #[test]
164    fn collapses_repeated_skill_template() {
165        let body = long_skill_body("vc-ownership", "");
166        let entries = vec![
167            entry("user", &body),
168            entry("user", &body),
169            entry("user", &body),
170        ];
171        let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
172
173        assert_eq!(stats.messages_collapsed, 2);
174        assert!(stats.bytes_saved > 0);
175        assert_eq!(out[0].message, body, "first occurrence kept verbatim");
176        assert!(
177            out[1].message.starts_with("<skill-ref: vc-ownership"),
178            "second occurrence collapsed with skill-ref stub: {}",
179            out[1].message
180        );
181        assert!(out[2].message.contains("first-seen: entry #0"));
182    }
183
184    #[test]
185    fn skill_marker_naming_extracted() {
186        let body = long_skill_body("vc-init", "");
187        let entries = vec![entry("user", &body), entry("user", &body)];
188        let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
189        assert!(out[1].message.contains("vc-init"));
190    }
191
192    #[test]
193    fn dedup_ref_used_when_no_skill_marker() {
194        let body = (0..20)
195            .map(|i| format!("line {i}"))
196            .collect::<Vec<_>>()
197            .join("\n");
198        let entries = vec![entry("assistant", &body), entry("assistant", &body)];
199        let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
200        assert_eq!(stats.messages_collapsed, 1);
201        assert!(out[1].message.starts_with("<dedup-ref: hash:"));
202        assert!(out[1].message.contains("first-seen: entry #0"));
203    }
204
205    #[test]
206    fn short_messages_not_collapsed() {
207        // Two-line bodies are way under threshold even when identical.
208        let entries = vec![
209            entry("user", "ok\nthanks"),
210            entry("user", "ok\nthanks"),
211            entry("user", "ok\nthanks"),
212        ];
213        let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
214        assert_eq!(stats.messages_collapsed, 0);
215        assert_eq!(out[1].message, "ok\nthanks");
216        assert_eq!(out[2].message, "ok\nthanks");
217    }
218
219    #[test]
220    fn different_long_messages_not_collapsed() {
221        let a = long_skill_body("vc-ownership", "different-tail-A");
222        let b = long_skill_body("vc-ownership", "different-tail-B");
223        let entries = vec![entry("user", &a), entry("user", &b)];
224        let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
225        assert_eq!(stats.messages_collapsed, 0);
226        assert_eq!(out[0].message, a);
227        assert_eq!(out[1].message, b);
228    }
229
230    #[test]
231    fn first_occurrence_index_is_stable() {
232        let body = long_skill_body("vc-init", "");
233        let entries = vec![
234            entry("user", "short start"),
235            entry("user", &body),
236            entry("user", "short middle"),
237            entry("user", &body),
238        ];
239        let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
240        assert!(
241            out[3].message.contains("first-seen: entry #1"),
242            "stub should reference index 1 (the first long occurrence): {}",
243            out[3].message
244        );
245    }
246
247    #[test]
248    fn detect_skill_marker_plain() {
249        let msg = "Base directory for this skill: /home/user/.claude/skills/vc-init\n# Init\n";
250        assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-init"));
251    }
252
253    #[test]
254    fn detect_skill_marker_quote_prefix() {
255        let msg = "> Base directory for this skill: /home/user/.claude/skills/vc-ownership\n";
256        assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-ownership"));
257    }
258
259    #[test]
260    fn detect_skill_marker_indented_quote() {
261        let msg = "   >> Base directory for this skill: /a/b/skills/vc-marbles\n";
262        assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-marbles"));
263    }
264
265    #[test]
266    fn detect_skill_marker_returns_none_for_plain_text() {
267        assert_eq!(detect_skill_marker("Just a regular message"), None);
268        assert_eq!(detect_skill_marker(""), None);
269    }
270
271    #[test]
272    fn detect_skill_marker_skipped_after_first_8_lines() {
273        let mut msg = String::new();
274        for _ in 0..10 {
275            msg.push_str("filler\n");
276        }
277        msg.push_str("Base directory for this skill: /a/b/c/vc-late\n");
278        // The marker is past the 8-line scan window — must not match.
279        assert_eq!(detect_skill_marker(&msg), None);
280    }
281
282    #[test]
283    fn merge_stats_is_additive() {
284        let mut a = CollapseStats {
285            messages_collapsed: 2,
286            bytes_saved: 1000,
287        };
288        let b = CollapseStats {
289            messages_collapsed: 3,
290            bytes_saved: 500,
291        };
292        a.merge(&b);
293        assert_eq!(a.messages_collapsed, 5);
294        assert_eq!(a.bytes_saved, 1500);
295    }
296
297    #[test]
298    fn empty_input_returns_empty() {
299        let (out, stats) = collapse_repeats(Vec::new(), DEFAULT_THRESHOLD_LINES);
300        assert!(out.is_empty());
301        assert_eq!(stats, CollapseStats::default());
302    }
303}