Skip to main content

aicx_parser/
chunker.rs

1//! Semantic windowing chunker for RAG indexing.
2//!
3//! Splits timeline entries into overlapping windows of ~1.5k tokens,
4//! suitable for vector embedding and semantic search via memex.
5//!
6//! Vibecrafted with AI Agents by VetCoders (c)2026 VetCoders
7
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::borrow::Cow;
11use std::collections::{BTreeMap, HashMap, HashSet};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use crate::timeline::{FrameKind, Kind, TimelineEntry};
16
17// ============================================================================
18// Types
19// ============================================================================
20
21/// A single chunk ready for vector indexing.
22#[derive(Debug, Clone)]
23pub struct Chunk {
24    /// Unique ID: `{project}_{agent}_{date}_{seq:03}`
25    pub id: String,
26    pub project: String,
27    pub agent: String,
28    /// Date string (YYYY-MM-DD)
29    pub date: String,
30    /// Session ID from first message in chunk
31    pub session_id: String,
32    /// Working directory from the first message in the chunk window
33    pub cwd: Option<String>,
34    /// Timestamp provenance when any source frame used an inferred timestamp.
35    pub timestamp_source: Option<String>,
36    /// Classified kind for this chunk's content
37    pub kind: Kind,
38    /// Stable stream/channel classification for the chunk contents.
39    pub frame_kind: Option<FrameKind>,
40    /// Optional correlation ID for the originating run
41    pub run_id: Option<String>,
42    /// Optional prompt or task identity for the originating run
43    pub prompt_id: Option<String>,
44    /// Optional agent model reported by the source frontmatter
45    pub agent_model: Option<String>,
46    /// Optional run start timestamp reported by the source frontmatter
47    pub started_at: Option<String>,
48    /// Optional run completion timestamp reported by the source frontmatter
49    pub completed_at: Option<String>,
50    /// Optional token usage reported by the source frontmatter
51    pub token_usage: Option<u64>,
52    /// Optional findings count reported by the source frontmatter
53    pub findings_count: Option<u32>,
54    /// Optional workflow phase reported by the source frontmatter
55    pub workflow_phase: Option<String>,
56    /// Optional routing mode reported by the source frontmatter
57    pub mode: Option<String>,
58    /// Optional framework skill code reported by the source frontmatter
59    pub skill_code: Option<String>,
60    /// Optional steering schema/framework version reported by the source frontmatter
61    pub framework_version: Option<String>,
62    /// Index range in original day's entries (start, end exclusive)
63    pub msg_range: (usize, usize),
64    /// Formatted chunk text with header
65    pub text: String,
66    /// Estimated token count (~chars/4)
67    pub token_estimate: usize,
68    /// Decision/plan highlights extracted from the chunk
69    pub highlights: Vec<String>,
70    /// Number of structural-noise lines (line-numbered grep matches, tool
71    /// echoes, stray YAML delimiters) dropped from the source entries while
72    /// building this chunk. Operators use this as observability — high values
73    /// flag corpora that should be re-ingested with the filter on, or
74    /// upstream emitters that produce excessive scaffolding.
75    pub noise_lines_dropped: usize,
76}
77
78/// Structured metadata sidecar persisted alongside each chunk file.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct ChunkMetadataSidecar {
81    pub id: String,
82    pub project: String,
83    pub agent: String,
84    pub date: String,
85    pub session_id: String,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub cwd: Option<String>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub timestamp_source: Option<String>,
90    pub kind: Kind,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub frame_kind: Option<FrameKind>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub speaker_hint: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub run_id: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub prompt_id: Option<String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub agent_model: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub started_at: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub completed_at: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub token_usage: Option<u64>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub findings_count: Option<u32>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub workflow_phase: Option<String>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub mode: Option<String>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub skill_code: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub framework_version: Option<String>,
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub intent_entries: Vec<crate::types::IntentEntry>,
119    /// Weak repo/content mentions preserved for query/tag surfaces. This is
120    /// append-only so pre-tag sidecars deserialize with an empty vector.
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub tags: Vec<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub artifact_family: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub schema_version: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub truth_status: Option<TruthStatus>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub learning_use: Option<LearningUse>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub keywords: Option<Vec<String>>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub content_sha256: Option<String>,
135    /// Number of noise lines dropped during chunk construction. Defaults to
136    /// `0` when the field is absent in older sidecars.
137    #[serde(default, skip_serializing_if = "is_zero_usize")]
138    pub noise_lines_dropped: usize,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct TruthStatus {
143    pub role: TruthRole,
144    #[serde(default)]
145    pub runtime_authoritative: bool,
146    #[serde(default)]
147    pub stale_against_current_head: bool,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub current_head_when_ingested: Option<String>,
150}
151
152#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "snake_case")]
154pub enum TruthRole {
155    Live,
156    Example,
157}
158
159#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
160pub struct LearningUse {
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub allowed: Vec<String>,
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub forbidden: Vec<String>,
165}
166
167fn is_zero_usize(value: &usize) -> bool {
168    *value == 0
169}
170
171impl From<&Chunk> for ChunkMetadataSidecar {
172    fn from(chunk: &Chunk) -> Self {
173        Self {
174            id: chunk.id.clone(),
175            project: chunk.project.clone(),
176            agent: chunk.agent.clone(),
177            date: chunk.date.clone(),
178            session_id: chunk.session_id.clone(),
179            cwd: chunk.cwd.clone(),
180            timestamp_source: chunk.timestamp_source.clone(),
181            kind: chunk.kind,
182            frame_kind: chunk.frame_kind,
183            speaker_hint: speaker_hint_from_chunk_text(&chunk.text),
184            run_id: chunk.run_id.clone(),
185            prompt_id: chunk.prompt_id.clone(),
186            agent_model: chunk.agent_model.clone(),
187            started_at: chunk.started_at.clone(),
188            completed_at: chunk.completed_at.clone(),
189            token_usage: chunk.token_usage,
190            findings_count: chunk.findings_count,
191            workflow_phase: chunk.workflow_phase.clone(),
192            mode: chunk.mode.clone(),
193            skill_code: chunk.skill_code.clone(),
194            framework_version: chunk.framework_version.clone(),
195            intent_entries: Vec::new(),
196            tags: Vec::new(),
197            artifact_family: None,
198            schema_version: None,
199            truth_status: None,
200            learning_use: None,
201            keywords: None,
202            content_sha256: None,
203            noise_lines_dropped: chunk.noise_lines_dropped,
204        }
205    }
206}
207
208fn speaker_hint_from_chunk_text(text: &str) -> Option<String> {
209    text.lines()
210        .find_map(|line| line.strip_prefix("speaker_hint: "))
211        .map(str::trim)
212        .filter(|value| !value.is_empty())
213        .map(ToOwned::to_owned)
214}
215
216/// Configuration for the chunker.
217#[derive(Debug, Clone)]
218pub struct ChunkerConfig {
219    /// Target tokens per chunk (default: 1500)
220    pub target_tokens: usize,
221    /// Minimum tokens — don't create tiny chunks unless it's the last window (default: 500)
222    pub min_tokens: usize,
223    /// Maximum tokens — force split if exceeded (default: 2500)
224    pub max_tokens: usize,
225    /// Number of messages to overlap between consecutive windows (default: 2)
226    pub overlap_messages: usize,
227    /// Whether to strip structural noise (line-numbered grep matches, tool
228    /// echoes, stray YAML delimiters) before signal/highlight extraction.
229    /// Default: `true`. Set to `false` for debugging or when raw upstream
230    /// content must be preserved verbatim.
231    pub noise_filter_enabled: bool,
232}
233
234impl Default for ChunkerConfig {
235    fn default() -> Self {
236        Self {
237            target_tokens: 1500,
238            min_tokens: 500,
239            max_tokens: 2500,
240            overlap_messages: 2,
241            noise_filter_enabled: true,
242        }
243    }
244}
245
246// ============================================================================
247// Token estimation
248// ============================================================================
249
250/// Estimate token count from text length.
251///
252/// Uses the simple heuristic: 1 token ≈ 4 characters.
253/// Rounds up to avoid underestimation.
254pub fn estimate_tokens(text: &str) -> usize {
255    text.len().div_ceil(4)
256}
257
258// ── Kind heuristics ────────────────────────────────────────────────────────
259
260const PLAN_KEYWORDS: &[&str] = &[
261    "implementation plan",
262    "plan:",
263    "## plan",
264    "step 1:",
265    "step 2:",
266    "step 3:",
267    "action items",
268    "milestones",
269    "roadmap",
270    "todo list",
271    "acceptance criteria",
272    "## steps",
273    "## phases",
274];
275
276const REPORT_KEYWORDS: &[&str] = &[
277    "## findings",
278    "## summary",
279    "## report",
280    "audit report",
281    "coverage report",
282    "test results",
283    "## metrics",
284    "## recommendations",
285    "## conclusion",
286    "status report",
287    "incident report",
288    "pr review",
289    "code review",
290];
291
292/// Classify a set of timeline entries into a canonical `Kind`.
293///
294/// Uses a lightweight keyword-scoring approach:
295/// - Scans assistant messages (where classification signal is strongest)
296/// - Scores plan vs report keywords
297/// - Conversations win by default when neither plan nor report signal is strong
298///
299/// The approach is intentionally conservative: ambiguous content falls to
300/// `Conversations` (the most common kind), not `Other`.
301pub fn classify_kind(entries: &[TimelineEntry]) -> Kind {
302    if entries.is_empty() {
303        return Kind::Other;
304    }
305
306    let mut plan_score: u32 = 0;
307    let mut report_score: u32 = 0;
308    let mut has_conversation = false;
309
310    for entry in entries {
311        let lower = entry.message.to_lowercase();
312
313        // Only count strong signals from assistant messages.
314        if entry.role == "assistant" {
315            for kw in PLAN_KEYWORDS {
316                if lower.contains(kw) {
317                    plan_score += 1;
318                }
319            }
320            for kw in REPORT_KEYWORDS {
321                if lower.contains(kw) {
322                    report_score += 1;
323                }
324            }
325        }
326
327        if entry.role == "user" || entry.role == "assistant" {
328            has_conversation = true;
329        }
330    }
331
332    let threshold = 3;
333
334    if plan_score >= threshold && plan_score > report_score {
335        Kind::Plans
336    } else if report_score >= threshold && report_score > plan_score {
337        Kind::Reports
338    } else if has_conversation {
339        Kind::Conversations
340    } else {
341        Kind::Other
342    }
343}
344
345fn prepare_entries_for_chunking<'a>(
346    entries: &'a [TimelineEntry],
347) -> (
348    Option<crate::frontmatter::ReportFrontmatter>,
349    Cow<'a, [TimelineEntry]>,
350) {
351    let Some(first) = entries.first() else {
352        return (None, Cow::Borrowed(entries));
353    };
354
355    if !first.message.trim_start().starts_with("---") {
356        return (None, Cow::Borrowed(entries));
357    }
358
359    let (frontmatter, body) = crate::frontmatter::parse(&first.message);
360    if body == first.message {
361        return (None, Cow::Borrowed(entries));
362    }
363
364    let mut stripped_entries = entries.to_vec();
365    if let Some(stripped_first) = stripped_entries.first_mut() {
366        stripped_first.message = body.to_string();
367    }
368
369    (frontmatter, Cow::Owned(stripped_entries))
370}
371
372fn apply_frontmatter(chunk: &mut Chunk, frontmatter: &crate::frontmatter::ReportFrontmatter) {
373    if chunk.frame_kind.is_none() {
374        chunk.frame_kind = frontmatter.telemetry.frame_kind;
375    }
376    chunk.run_id = frontmatter.telemetry.run_id.clone();
377    chunk.prompt_id = frontmatter.telemetry.prompt_id.clone();
378    chunk.agent_model = frontmatter.telemetry.model.clone();
379    chunk.started_at = frontmatter.telemetry.started_at.clone();
380    chunk.completed_at = frontmatter.telemetry.completed_at.clone();
381    chunk.token_usage = frontmatter.telemetry.token_usage;
382    chunk.findings_count = frontmatter.telemetry.findings_count;
383    chunk.workflow_phase = frontmatter.steering.workflow_phase.clone();
384    chunk.mode = frontmatter.steering.mode.clone();
385    chunk.skill_code = frontmatter.steering.skill_code.clone();
386    chunk.framework_version = frontmatter.steering.framework_version.clone();
387}
388
389fn split_day_entries_by_frame_kind<'a>(
390    entries: &'a [(usize, &'a TimelineEntry)],
391) -> Vec<&'a [(usize, &'a TimelineEntry)]> {
392    if entries.is_empty() {
393        return Vec::new();
394    }
395
396    let mut groups = Vec::new();
397    let mut start = 0usize;
398
399    for idx in 1..entries.len() {
400        let previous = entries[idx - 1].1.frame_kind;
401        let current = entries[idx].1.frame_kind;
402        if previous != current {
403            groups.push(&entries[start..idx]);
404            start = idx;
405        }
406    }
407
408    groups.push(&entries[start..]);
409    groups
410}
411
412fn frame_kind_for_window(entries: &[&TimelineEntry]) -> Option<FrameKind> {
413    let first = entries.first().and_then(|entry| entry.frame_kind)?;
414    entries
415        .iter()
416        .all(|entry| entry.frame_kind == Some(first))
417        .then_some(first)
418}
419
420fn timestamp_source_for_window(entries: &[&TimelineEntry]) -> Option<String> {
421    let mut sources = entries
422        .iter()
423        .filter_map(|entry| entry.timestamp_source.as_deref());
424    let first = sources.next()?;
425    let mut source = first.to_string();
426    if sources.any(|candidate| candidate != first) {
427        source = "mixed".to_string();
428    }
429    Some(source)
430}
431
432// ============================================================================
433// Chunking logic
434// ============================================================================
435
436/// Chunk timeline entries into semantic windows with overlap.
437///
438/// Groups entries by date, then applies sliding window within each day.
439/// Returns chunks sorted by date and sequence number.
440pub fn chunk_entries(
441    entries: &[TimelineEntry],
442    project: &str,
443    agent: &str,
444    config: &ChunkerConfig,
445) -> Vec<Chunk> {
446    if entries.is_empty() {
447        return vec![];
448    }
449
450    let project = canonical_project_label(project);
451    let (frontmatter, prepared_entries) = prepare_entries_for_chunking(entries);
452    let prepared_entries = prepared_entries.as_ref();
453
454    // Group entries by date
455    let mut by_date: BTreeMap<String, Vec<(usize, &TimelineEntry)>> = BTreeMap::new();
456    for (idx, entry) in prepared_entries.iter().enumerate() {
457        let date = entry.timestamp.format("%Y-%m-%d").to_string();
458        by_date.entry(date).or_default().push((idx, entry));
459    }
460
461    let mut chunks = Vec::new();
462
463    for (date, day_entries) in &by_date {
464        let mut day_chunks = Vec::new();
465        let mut next_seq = 1usize;
466        for frame_group in split_day_entries_by_frame_kind(day_entries) {
467            let (mut group_chunks, updated_seq) =
468                chunk_day_entries(frame_group, &project, agent, date, config, next_seq);
469            next_seq = updated_seq;
470            day_chunks.append(&mut group_chunks);
471        }
472        if let Some(frontmatter) = frontmatter.as_ref() {
473            for chunk in &mut day_chunks {
474                apply_frontmatter(chunk, frontmatter);
475            }
476        }
477        chunks.extend(day_chunks);
478    }
479
480    chunks
481}
482
483/// Apply sliding window chunking to a single day's entries.
484fn chunk_day_entries(
485    entries: &[(usize, &TimelineEntry)],
486    project: &str,
487    agent: &str,
488    date: &str,
489    config: &ChunkerConfig,
490    start_seq: usize,
491) -> (Vec<Chunk>, usize) {
492    if entries.is_empty() {
493        return (vec![], start_seq);
494    }
495
496    let mut chunks = Vec::new();
497    let mut seq = start_seq;
498    let mut start = 0usize;
499
500    while start < entries.len() {
501        // Find window end: accumulate until target_tokens reached
502        let mut end = start;
503        let mut accumulated_tokens = 0usize;
504
505        while end < entries.len() {
506            let msg_tokens = estimate_tokens(&entries[end].1.message);
507            let next_total = accumulated_tokens + msg_tokens + 20; // ~20 tokens for timestamp/role header
508
509            if next_total > config.max_tokens && end > start {
510                break;
511            }
512
513            accumulated_tokens = next_total;
514            end += 1;
515
516            if accumulated_tokens >= config.target_tokens {
517                break;
518            }
519        }
520
521        // Build chunk from entries[start..end].
522        //
523        // Pre-sanitize each entry's message through the noise filter BEFORE
524        // signal/highlight extraction so the `[signals]` block and the
525        // entry-level body are both built from semantic content only. The
526        // filter can be disabled via `ChunkerConfig::noise_filter_enabled`
527        // for debugging/raw modes.
528        let window: Vec<&TimelineEntry> = entries[start..end].iter().map(|(_, e)| *e).collect();
529        let (sanitized_owned, noise_lines_dropped) = sanitize_window(&window, config);
530        let window: Vec<&TimelineEntry> = sanitized_owned.iter().collect();
531        let highlights = extract_highlights(&window);
532        let signals = extract_signals(&window);
533        let frame_kind = frame_kind_for_window(&window);
534        let timestamp_source = timestamp_source_for_window(&window);
535        let text = format_chunk_text_inner(
536            &window,
537            project,
538            agent,
539            date,
540            frame_kind,
541            &signals,
542            &highlights,
543        );
544        let token_estimate = estimate_tokens(&text);
545
546        let session_id = window
547            .first()
548            .map(|e| e.session_id.clone())
549            .unwrap_or_default();
550        let cwd = window.first().and_then(|entry| entry.cwd.clone());
551
552        let global_start = entries[start].0;
553        let global_end = entries[end - 1].0 + 1;
554
555        let kind = classify_kind(&window.iter().map(|e| (*e).clone()).collect::<Vec<_>>());
556
557        chunks.push(Chunk {
558            id: format!("{}_{}_{}_{{:03}}", project, agent, date)
559                .replace("{:03}", &format!("{:03}", seq)),
560            project: project.to_string(),
561            agent: agent.to_string(),
562            date: date.to_string(),
563            session_id,
564            cwd,
565            timestamp_source,
566            kind,
567            frame_kind,
568            run_id: None,
569            prompt_id: None,
570            agent_model: None,
571            started_at: None,
572            completed_at: None,
573            token_usage: None,
574            findings_count: None,
575            workflow_phase: None,
576            mode: None,
577            skill_code: None,
578            framework_version: None,
579            msg_range: (global_start, global_end),
580            text,
581            token_estimate,
582            highlights,
583            noise_lines_dropped,
584        });
585
586        seq += 1;
587
588        // Next window starts at (end - overlap), but always advance at least 1
589        let overlap = config.overlap_messages.min(end - start);
590        let next_start = if end >= entries.len() {
591            entries.len() // done
592        } else if end - overlap > start {
593            end - overlap
594        } else {
595            end // avoid infinite loop
596        };
597
598        start = next_start;
599    }
600
601    (chunks, seq)
602}
603
604/// Format entries into chunk text with metadata header.
605///
606/// The public surface uses the chunker's default configuration; callers that
607/// need non-default behavior (e.g. disabled noise filter) should go through
608/// [`chunk_entries`] which threads [`ChunkerConfig`] in full.
609pub fn format_chunk_text(
610    entries: &[&TimelineEntry],
611    project: &str,
612    agent: &str,
613    date: &str,
614) -> String {
615    let config = ChunkerConfig::default();
616    let (sanitized_owned, _dropped) = sanitize_window(entries, &config);
617    let entries: Vec<&TimelineEntry> = sanitized_owned.iter().collect();
618    let highlights = extract_highlights(&entries);
619    let signals = extract_signals(&entries);
620    let project = canonical_project_label(project);
621    format_chunk_text_inner(
622        &entries,
623        &project,
624        agent,
625        date,
626        frame_kind_for_window(&entries),
627        &signals,
628        &highlights,
629    )
630}
631
632fn canonical_project_label(project: &str) -> String {
633    project
634        .split('/')
635        .map(|segment| segment.trim().to_ascii_lowercase())
636        .collect::<Vec<_>>()
637        .join("/")
638}
639
640/// Clone a window's entries with their messages run through
641/// [`crate::noise::filter_noise_lines_with_count`]. Used before any signal
642/// or highlight extraction so structural noise never leaks into the semantic
643/// surface. Returns the cloned window plus the aggregate count of dropped
644/// noise lines for observability sidecars.
645///
646/// When `config.noise_filter_enabled` is `false`, returns a plain clone of
647/// the input window with `dropped == 0`.
648fn sanitize_window(
649    window: &[&TimelineEntry],
650    config: &ChunkerConfig,
651) -> (Vec<TimelineEntry>, usize) {
652    if !config.noise_filter_enabled {
653        let cloned = window.iter().map(|entry| (*entry).clone()).collect();
654        return (cloned, 0);
655    }
656
657    let mut total_dropped = 0usize;
658    let cloned: Vec<TimelineEntry> = window
659        .iter()
660        .map(|entry| {
661            let mut cloned = (*entry).clone();
662            let (filtered, dropped) = crate::noise::filter_noise_lines_with_count(&entry.message);
663            cloned.message = filtered;
664            total_dropped += dropped;
665            cloned
666        })
667        .collect();
668    (cloned, total_dropped)
669}
670
671fn format_chunk_text_inner(
672    entries: &[&TimelineEntry],
673    project: &str,
674    agent: &str,
675    date: &str,
676    frame_kind: Option<FrameKind>,
677    signals: &ChunkSignals,
678    highlights: &[String],
679) -> String {
680    let mut text = if let Some(frame_kind) = frame_kind {
681        format!(
682            "[project: {} | agent: {} | date: {} | frame_kind: {}]\n\n",
683            project, agent, date, frame_kind
684        )
685    } else {
686        format!(
687            "[project: {} | agent: {} | date: {}]\n\n",
688            project, agent, date
689        )
690    };
691
692    if let Some(block) = format_signals_block(signals, highlights) {
693        text.push_str(&block);
694        text.push('\n');
695    }
696
697    // Note: callers (`format_chunk_text`, `chunk_day_entries`) pass entries
698    // that have already been routed through `sanitize_window`, so message
699    // bodies here are noise-free. Skip empty messages so windows that reduced
700    // to pure scaffolding don't emit empty role lines.
701    for entry in entries {
702        if entry.message.is_empty() {
703            continue;
704        }
705        let time = entry.timestamp.format("%H:%M:%S");
706        // Truncate very long messages to avoid monster chunks (UTF-8 safe).
707        let msg = if entry.message.len() > 4000 {
708            truncate_message_bytes(&entry.message, 4000)
709        } else {
710            entry.message.clone()
711        };
712        text.push_str(&format!("[{}] {}: {}\n", time, entry.role, msg));
713    }
714
715    text
716}
717
718const HIGHLIGHT_KEYWORDS: &[&str] = &[
719    "decision:",
720    "plan:",
721    "architecture",
722    "breaking",
723    "todo:",
724    "fixme:",
725];
726
727const HIGHLIGHT_KEYWORDS_CASE_SENSITIVE: &[&str] = &["WAŻNE", "KEY"];
728
729fn extract_highlights(entries: &[&TimelineEntry]) -> Vec<String> {
730    let mut highlights = Vec::new();
731    for entry in entries {
732        if highlights.len() >= 3 {
733            break;
734        }
735        if !is_highlight_message(&entry.message) {
736            continue;
737        }
738
739        if let Some(line) = entry.message.lines().map(str::trim).find(|l| !l.is_empty())
740            && highlights.last().map(String::as_str) != Some(line)
741        {
742            highlights.push(line.to_string());
743        }
744    }
745    highlights
746}
747
748fn is_highlight_message(message: &str) -> bool {
749    let lower = message.to_lowercase();
750    HIGHLIGHT_KEYWORDS.iter().any(|kw| lower.contains(kw))
751        || HIGHLIGHT_KEYWORDS_CASE_SENSITIVE
752            .iter()
753            .any(|kw| message.contains(kw))
754}
755
756// ============================================================================
757// Signals (intent + checklists)
758// ============================================================================
759
760#[derive(Debug, Clone, Default)]
761struct ChunkSignals {
762    todo_open: Vec<String>,
763    todo_done: Vec<String>,
764    ultrathink: Vec<String>,
765    insights: Vec<String>,
766    plan_mode: Vec<String>,
767    intents: Vec<String>,
768    results: Vec<String>,
769    skills: Vec<String>,
770    decisions: Vec<String>,
771    outcomes: Vec<String>,
772}
773
774const MAX_TODO_ITEMS: usize = 8;
775const MAX_ULTRATHINK_BLOCKS: usize = 4;
776const MAX_INSIGHT_BLOCKS: usize = 6;
777const MAX_PLAN_MODE_EVENTS: usize = 8;
778const MAX_INTENT_LINES: usize = 6;
779const MAX_RESULT_LINES: usize = 6;
780const MAX_TAG_BLOCK_LINES: usize = 4;
781
782pub const INTENT_KEYWORDS: &[&str] = &[
783    // Polish
784    "mam pomysl",
785    "mam pomysł",
786    "mam taki pomysl",
787    "mam taki pomysł",
788    "pomysl",
789    "pomysł",
790    "proponuje",
791    "proponuję",
792    "zrobmy",
793    "zróbmy",
794    "ustalmy",
795    "ustalmy",
796    "chce",
797    "chcę",
798    "chcialbym",
799    "chciałbym",
800    "potrzebuje",
801    "potrzebuję",
802    "następny krok",
803    "nastepny krok",
804    "kolejny krok",
805    // English
806    "i want",
807    "i'd like",
808    "let's",
809    "next step",
810];
811
812const RESULT_KEYWORDS: &[&str] = &[
813    "smoke test",
814    "passed",
815    "all checks passed",
816    "0 failed",
817    "completed",
818    "done",
819    "zrobione",
820    "dowiezione",
821    "gotowe",
822    "dziala",
823    "działa",
824];
825
826fn extract_signals(entries: &[&TimelineEntry]) -> ChunkSignals {
827    let (todo_open, todo_done) = extract_checklist_items(entries);
828    let ultrathink = extract_tag_blocks(entries, is_ultrathink_tag, MAX_ULTRATHINK_BLOCKS);
829    let insights = extract_tag_blocks(entries, is_insight_tag, MAX_INSIGHT_BLOCKS);
830    let plan_mode = extract_tag_blocks(entries, is_plan_mode_tag, MAX_PLAN_MODE_EVENTS);
831    let intents = extract_intent_lines(entries);
832    let results = extract_result_lines(entries);
833    let skills = extract_tag_blocks(entries, is_skill_tag, 4);
834    let decisions = extract_tag_blocks(entries, is_decision_tag, 4);
835    let outcomes = extract_tag_blocks(entries, is_outcome_tag, 4);
836
837    ChunkSignals {
838        todo_open,
839        todo_done,
840        ultrathink,
841        insights,
842        plan_mode,
843        intents,
844        results,
845        skills,
846        decisions,
847        outcomes,
848    }
849}
850
851fn extract_checklist_items(entries: &[&TimelineEntry]) -> (Vec<String>, Vec<String>) {
852    #[derive(Debug, Clone, Copy)]
853    enum TaskState {
854        Open,
855        Done,
856    }
857
858    let mut state_by_key: HashMap<String, TaskState> = HashMap::new();
859    let mut display_by_key: HashMap<String, String> = HashMap::new();
860    let mut order: Vec<String> = Vec::new();
861
862    for entry in entries {
863        // Track fenced code blocks per entry. Checklist-looking lines inside
864        // ``` fences (pasted markdown snippets, code samples documenting
865        // checklist syntax) must not be promoted to actual tasks.
866        let mut in_fence = false;
867        for line in entry.message.lines() {
868            if line.trim_start().starts_with("```") {
869                in_fence = !in_fence;
870                continue;
871            }
872            if in_fence {
873                continue;
874            }
875            if let Some((is_done, task)) = parse_checklist_task(line) {
876                let key = normalize_key(&task);
877                if !state_by_key.contains_key(&key) {
878                    order.push(key.clone());
879                    display_by_key.insert(key.clone(), task);
880                    state_by_key.insert(key.clone(), TaskState::Open);
881                }
882
883                // Once a task is marked done anywhere, keep it done.
884                if is_done {
885                    state_by_key.insert(key, TaskState::Done);
886                }
887            }
888        }
889    }
890
891    let mut open = Vec::new();
892    let mut done = Vec::new();
893    for key in order {
894        let Some(task) = display_by_key.get(&key) else {
895            continue;
896        };
897        match state_by_key.get(&key) {
898            Some(TaskState::Done) => done.push(task.clone()),
899            Some(TaskState::Open) => open.push(task.clone()),
900            None => {}
901        }
902    }
903
904    (open, done)
905}
906
907pub fn parse_checklist_task(line: &str) -> Option<(bool, String)> {
908    let l = line.trim_start();
909    let mut chars = l.chars();
910    let bullet = chars.next()?;
911    if !matches!(bullet, '-' | '*' | '+') {
912        return None;
913    }
914    let rest = chars.as_str().trim_start();
915    let rest = rest.strip_prefix('[')?;
916    let mut chars = rest.chars();
917    let state = chars.next()?;
918    let rest = chars.as_str();
919    let rest = rest.strip_prefix(']')?;
920    let task = rest.trim_start();
921    if task.is_empty() {
922        return None;
923    }
924
925    match state {
926        'x' | 'X' => Some((true, task.trim().to_string())),
927        ' ' => Some((false, task.trim().to_string())),
928        _ => None,
929    }
930}
931
932fn extract_intent_lines(entries: &[&TimelineEntry]) -> Vec<String> {
933    let mut out = Vec::new();
934    let mut seen = HashSet::new();
935
936    for entry in entries {
937        if entry.role.to_lowercase() != "user" {
938            continue;
939        }
940        for line in entry.message.lines().map(str::trim) {
941            if line.is_empty() {
942                continue;
943            }
944            if is_source_metadata_line(line) {
945                continue;
946            }
947            if !is_intent_line(line) {
948                continue;
949            }
950
951            let key = normalize_key(line);
952            if !seen.insert(key) {
953                continue;
954            }
955
956            out.push(truncate_signal_line(line));
957            if out.len() >= MAX_INTENT_LINES {
958                return out;
959            }
960        }
961    }
962
963    out
964}
965
966pub(crate) fn is_intent_line(line: &str) -> bool {
967    let lower = line.to_lowercase();
968    lower.starts_with("intent:")
969        || lower.starts_with("[intent]")
970        || severity_marker(line).is_some()
971        || INTENT_KEYWORDS.iter().any(|kw| lower.contains(kw))
972}
973
974fn severity_marker(line: &str) -> Option<&'static str> {
975    let upper = line.to_ascii_uppercase();
976    let has_marker = |marker: &str| {
977        upper
978            .split(|ch: char| !ch.is_ascii_alphanumeric())
979            .any(|token| token == marker)
980    };
981    ["P0", "P1", "P2"]
982        .into_iter()
983        .find(|marker| has_marker(marker))
984}
985
986fn is_source_metadata_line(line: &str) -> bool {
987    let lower = line.to_ascii_lowercase();
988    [
989        "source:",
990        "kind:",
991        "source_file:",
992        "severity:",
993        "project:",
994        "author:",
995        "heading:",
996    ]
997    .iter()
998    .any(|prefix| lower.starts_with(prefix))
999}
1000
1001fn extract_result_lines(entries: &[&TimelineEntry]) -> Vec<String> {
1002    let mut out = Vec::new();
1003    let mut seen = HashSet::new();
1004
1005    for entry in entries {
1006        for line in entry.message.lines().map(str::trim) {
1007            if line.is_empty() {
1008                continue;
1009            }
1010            if !is_result_line(line) {
1011                continue;
1012            }
1013            let key = normalize_key(line);
1014            if !seen.insert(key) {
1015                continue;
1016            }
1017            out.push(truncate_signal_line(line));
1018            if out.len() >= MAX_RESULT_LINES {
1019                return out;
1020            }
1021        }
1022    }
1023
1024    out
1025}
1026
1027pub fn is_result_line(line: &str) -> bool {
1028    let lower = line.to_lowercase();
1029    RESULT_KEYWORDS.iter().any(|kw| lower.contains(kw))
1030}
1031
1032pub fn normalize_key(s: &str) -> String {
1033    // Strip invisible characters that would otherwise let "fix au\u{200B}th"
1034    // bypass dedup of "fix auth". Covers zero-width family
1035    // (U+200B/200C/200D/FEFF) and bidi controls (U+202A-202E/2066-2069) that
1036    // can be pasted from chat clients or copy-paste from PDFs.
1037    let cleaned: String = s
1038        .chars()
1039        .filter(|ch| !is_invisible_normalize_char(*ch))
1040        .collect();
1041    cleaned
1042        .split_whitespace()
1043        .collect::<Vec<_>>()
1044        .join(" ")
1045        .to_lowercase()
1046}
1047
1048fn is_invisible_normalize_char(ch: char) -> bool {
1049    matches!(
1050        ch,
1051        '\u{200B}'
1052            | '\u{200C}'
1053            | '\u{200D}'
1054            | '\u{FEFF}'
1055            | '\u{202A}'
1056            | '\u{202B}'
1057            | '\u{202C}'
1058            | '\u{202D}'
1059            | '\u{202E}'
1060            | '\u{2066}'
1061            | '\u{2067}'
1062            | '\u{2068}'
1063            | '\u{2069}'
1064    )
1065}
1066
1067pub fn truncate_signal_line(line: &str) -> String {
1068    const MAX_BYTES: usize = 240;
1069    if line.len() <= MAX_BYTES {
1070        return line.to_string();
1071    }
1072    truncate_message_bytes(line, MAX_BYTES)
1073}
1074
1075fn is_ultrathink_tag(line: &str) -> bool {
1076    line.to_lowercase().contains("ultrathink")
1077}
1078
1079fn is_insight_tag(line: &str) -> bool {
1080    let lower = line.to_lowercase();
1081    // Prefer common "tag" forms like "Insight:" / "★ Insight" / "Insight ─".
1082    lower.starts_with("insight")
1083        || lower.contains("★ insight")
1084        || lower.contains("insight ─")
1085        || lower.contains("insight -")
1086}
1087
1088fn is_plan_mode_tag(line: &str) -> bool {
1089    let lower = line.to_lowercase();
1090    // Capture Plan Mode session transitions + explicit accept/approval actions.
1091    lower.contains("plan mode")
1092        || lower.contains("accept plan")
1093        || lower.contains("user accepted the plan")
1094        || lower.contains("approve and bypass permissions")
1095        || lower.contains("bypass permissions")
1096}
1097
1098fn is_skill_tag(line: &str) -> bool {
1099    let lower = line.to_lowercase();
1100    lower.contains("[skill_enter]")
1101        || lower.contains("vetcoders-partner")
1102        || lower.contains("vetcoders-spawn")
1103        || lower.contains("vetcoders-ownership")
1104        || lower.contains("vetcoders-workflow")
1105}
1106
1107pub fn is_decision_tag(line: &str) -> bool {
1108    let lower = line.to_lowercase();
1109    lower.contains("[decision]") || lower.starts_with("decision:")
1110}
1111
1112pub fn is_outcome_tag(line: &str) -> bool {
1113    let lower = line.to_lowercase();
1114    lower.contains("[skill_outcome]")
1115        || lower.starts_with("outcome:")
1116        || lower.starts_with("validation:")
1117}
1118
1119fn extract_tag_blocks(
1120    entries: &[&TimelineEntry],
1121    is_tag: fn(&str) -> bool,
1122    max_blocks: usize,
1123) -> Vec<String> {
1124    let mut out = Vec::new();
1125    let mut seen = HashSet::new();
1126
1127    for entry in entries {
1128        let lines: Vec<&str> = entry.message.lines().collect();
1129        for (i, raw) in lines.iter().enumerate() {
1130            let line = raw.trim();
1131            if line.is_empty() || !is_tag(line) {
1132                continue;
1133            }
1134
1135            let mut block = Vec::new();
1136            block.push(line);
1137
1138            for raw_next in lines.iter().skip(i + 1) {
1139                let next = raw_next.trim();
1140                if next.is_empty() {
1141                    break;
1142                }
1143                if is_tag(next) {
1144                    break;
1145                }
1146                block.push(next);
1147                if block.len() >= MAX_TAG_BLOCK_LINES {
1148                    break;
1149                }
1150            }
1151
1152            let joined = block.join(" ");
1153            let key = normalize_key(&joined);
1154            if !seen.insert(key) {
1155                continue;
1156            }
1157
1158            out.push(truncate_signal_line(&joined));
1159            if out.len() >= max_blocks {
1160                return out;
1161            }
1162        }
1163    }
1164
1165    out
1166}
1167
1168fn format_signals_block(signals: &ChunkSignals, highlights: &[String]) -> Option<String> {
1169    let has_any = !signals.todo_open.is_empty()
1170        || !signals.todo_done.is_empty()
1171        || !signals.ultrathink.is_empty()
1172        || !signals.insights.is_empty()
1173        || !signals.plan_mode.is_empty()
1174        || !signals.intents.is_empty()
1175        || !signals.results.is_empty()
1176        || !signals.skills.is_empty()
1177        || !signals.decisions.is_empty()
1178        || !signals.outcomes.is_empty()
1179        || !highlights.is_empty();
1180    if !has_any {
1181        return None;
1182    }
1183
1184    let mut out = String::new();
1185    out.push_str("[signals]\n");
1186
1187    if !signals.skills.is_empty() {
1188        out.push_str("=== SKILL ENTER ===\n");
1189        for line in &signals.skills {
1190            out.push_str(&format!("{}\n", line));
1191        }
1192        out.push_str("===================\n");
1193    }
1194
1195    if !signals.todo_open.is_empty() || !signals.todo_done.is_empty() {
1196        if !signals.todo_open.is_empty() {
1197            out.push_str(&format!(
1198                "RED LIGHT: checklist detected (open: {}, done: {})\n",
1199                signals.todo_open.len(),
1200                signals.todo_done.len()
1201            ));
1202        } else {
1203            out.push_str(&format!(
1204                "Checklist detected (open: 0, done: {})\n",
1205                signals.todo_done.len()
1206            ));
1207        }
1208
1209        for task in signals.todo_open.iter().take(MAX_TODO_ITEMS) {
1210            out.push_str(&format!("- [ ] {}\n", task));
1211        }
1212        if signals.todo_open.len() > MAX_TODO_ITEMS {
1213            out.push_str(&format!(
1214                "... (+{} more open)\n",
1215                signals.todo_open.len() - MAX_TODO_ITEMS
1216            ));
1217        }
1218
1219        for task in signals.todo_done.iter().take(MAX_TODO_ITEMS) {
1220            out.push_str(&format!("- [x] {}\n", task));
1221        }
1222        if signals.todo_done.len() > MAX_TODO_ITEMS {
1223            out.push_str(&format!(
1224                "... (+{} more done)\n",
1225                signals.todo_done.len() - MAX_TODO_ITEMS
1226            ));
1227        }
1228    }
1229
1230    if !signals.ultrathink.is_empty() {
1231        out.push_str("Ultrathink:\n");
1232        for line in &signals.ultrathink {
1233            out.push_str(&format!("- {}\n", line));
1234        }
1235    }
1236
1237    if !signals.insights.is_empty() {
1238        out.push_str("Insight:\n");
1239        for line in &signals.insights {
1240            out.push_str(&format!("- {}\n", line));
1241        }
1242    }
1243
1244    if !signals.plan_mode.is_empty() {
1245        out.push_str("Plan mode:\n");
1246        for line in &signals.plan_mode {
1247            out.push_str(&format!("- {}\n", line));
1248        }
1249    }
1250
1251    if !signals.intents.is_empty() {
1252        out.push_str("Intent:\n");
1253        for line in &signals.intents {
1254            out.push_str(&format!("- {}\n", line));
1255        }
1256    }
1257
1258    if !signals.decisions.is_empty() {
1259        out.push_str("Decision:\n");
1260        for line in &signals.decisions {
1261            out.push_str(&format!("- {}\n", line));
1262        }
1263    }
1264
1265    if !signals.results.is_empty() {
1266        out.push_str("Results:\n");
1267        for line in &signals.results {
1268            out.push_str(&format!("- {}\n", line));
1269        }
1270    }
1271
1272    if !signals.outcomes.is_empty() {
1273        out.push_str("Outcome:\n");
1274        for line in &signals.outcomes {
1275            out.push_str(&format!("- {}\n", line));
1276        }
1277    }
1278
1279    if !highlights.is_empty() {
1280        out.push_str("Notes:\n");
1281        for line in highlights {
1282            out.push_str(&format!("- {}\n", truncate_signal_line(line)));
1283        }
1284    }
1285
1286    out.push_str("[/signals]\n");
1287    Some(out)
1288}
1289
1290fn truncate_message_bytes(message: &str, max_bytes: usize) -> String {
1291    let mut cutoff = max_bytes.min(message.len());
1292    while cutoff > 0 && !message.is_char_boundary(cutoff) {
1293        cutoff -= 1;
1294    }
1295    let mut out = String::with_capacity(cutoff + 15);
1296    out.push_str(&message[..cutoff]);
1297    out.push_str("...[truncated]");
1298    out
1299}
1300
1301// ============================================================================
1302// File output
1303// ============================================================================
1304
1305/// Write chunks as individual .txt files to a directory.
1306///
1307/// Each file is named `{chunk.id}.txt`. Returns paths of written files.
1308pub fn write_chunks_to_dir(chunks: &[Chunk], dir: &Path) -> Result<Vec<PathBuf>> {
1309    fs::create_dir_all(dir)?;
1310
1311    let mut paths = Vec::new();
1312
1313    for chunk in chunks {
1314        let filename = format!("{}.txt", chunk.id);
1315        let path = dir.join(&filename);
1316        fs::write(&path, &chunk.text)?;
1317        let sidecar_path = dir.join(format!("{}.meta.json", chunk.id));
1318        let sidecar = ChunkMetadataSidecar::from(chunk);
1319        fs::write(&sidecar_path, serde_json::to_vec_pretty(&sidecar)?)?;
1320        paths.push(path);
1321    }
1322
1323    Ok(paths)
1324}
1325
1326/// Summary of chunking results.
1327pub fn chunk_summary(chunks: &[Chunk]) -> String {
1328    if chunks.is_empty() {
1329        return "No chunks generated.".to_string();
1330    }
1331
1332    let total_tokens: usize = chunks.iter().map(|c| c.token_estimate).sum();
1333    let avg_tokens = total_tokens / chunks.len();
1334    let dates: Vec<&str> = chunks
1335        .iter()
1336        .map(|c| c.date.as_str())
1337        .collect::<std::collections::HashSet<_>>()
1338        .into_iter()
1339        .collect();
1340
1341    format!(
1342        "{} chunks, {} total tokens (avg {}), {} days",
1343        chunks.len(),
1344        total_tokens,
1345        avg_tokens,
1346        dates.len(),
1347    )
1348}
1349
1350// ============================================================================
1351// Tests
1352// ============================================================================
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357    use chrono::{TimeZone, Utc};
1358
1359    fn make_entry(hour: u32, min: u32, role: &str, msg: &str) -> TimelineEntry {
1360        TimelineEntry {
1361            timestamp: Utc.with_ymd_and_hms(2026, 1, 22, hour, min, 0).unwrap(),
1362            agent: "claude".to_string(),
1363            session_id: "sess-1".to_string(),
1364            role: role.to_string(),
1365            message: msg.to_string(),
1366            frame_kind: None,
1367            branch: None,
1368            cwd: None,
1369            timestamp_source: None,
1370        }
1371    }
1372
1373    #[test]
1374    fn test_estimate_tokens() {
1375        assert_eq!(estimate_tokens(""), 0);
1376        assert_eq!(estimate_tokens("hi"), 1); // 2 chars → ceil(2/4) = 1
1377        assert_eq!(estimate_tokens("hello world"), 3); // 11 chars → ceil(11/4) = 3
1378        assert_eq!(estimate_tokens("1234"), 1); // exactly 4 chars = 1 token
1379        assert_eq!(estimate_tokens("12345"), 2); // 5 chars → 2 tokens
1380    }
1381
1382    #[test]
1383    fn test_chunk_entries_empty() {
1384        let config = ChunkerConfig::default();
1385        let chunks = chunk_entries(&[], "proj", "claude", &config);
1386        assert!(chunks.is_empty());
1387    }
1388
1389    #[test]
1390    fn test_chunk_entries_single_message() {
1391        let entries = vec![make_entry(14, 0, "user", "short message")];
1392        let config = ChunkerConfig::default();
1393        let chunks = chunk_entries(&entries, "proj", "claude", &config);
1394
1395        assert_eq!(chunks.len(), 1);
1396        assert_eq!(chunks[0].project, "proj");
1397        assert_eq!(chunks[0].agent, "claude");
1398        assert_eq!(chunks[0].date, "2026-01-22");
1399        assert!(chunks[0].text.contains("short message"));
1400    }
1401
1402    #[test]
1403    fn test_chunk_entries_basic() {
1404        // Create 10 entries with ~200 chars each → ~500 tokens total
1405        // With target=150 tokens, should get multiple chunks
1406        let entries: Vec<TimelineEntry> = (0..10)
1407            .map(|i| make_entry(14, i as u32, "user", &"x".repeat(200)))
1408            .collect();
1409
1410        let config = ChunkerConfig {
1411            target_tokens: 150,
1412            min_tokens: 50,
1413            max_tokens: 300,
1414            overlap_messages: 2,
1415            noise_filter_enabled: true,
1416        };
1417
1418        let chunks = chunk_entries(&entries, "proj", "claude", &config);
1419        assert!(
1420            chunks.len() > 1,
1421            "Expected multiple chunks, got {}",
1422            chunks.len()
1423        );
1424
1425        // Verify sequential IDs
1426        for (i, chunk) in chunks.iter().enumerate() {
1427            assert!(chunk.id.contains(&format!("{:03}", i + 1)));
1428        }
1429    }
1430
1431    #[test]
1432    fn test_chunk_entries_respects_max_tokens() {
1433        // One very long message
1434        let entries = vec![make_entry(14, 0, "user", &"x".repeat(20000))];
1435        let config = ChunkerConfig {
1436            target_tokens: 1500,
1437            min_tokens: 500,
1438            max_tokens: 2500,
1439            overlap_messages: 2,
1440            noise_filter_enabled: true,
1441        };
1442
1443        let chunks = chunk_entries(&entries, "proj", "claude", &config);
1444        // Single long message can't be split within chunker (it's per-message)
1445        // but format_chunk_text truncates at 4000 bytes
1446        assert_eq!(chunks.len(), 1);
1447        assert!(chunks[0].text.contains("[truncated]"));
1448    }
1449
1450    #[test]
1451    fn test_chunk_entries_groups_by_date() {
1452        let entries = vec![
1453            TimelineEntry {
1454                timestamp: Utc.with_ymd_and_hms(2026, 1, 20, 10, 0, 0).unwrap(),
1455                agent: "claude".to_string(),
1456                session_id: "s1".to_string(),
1457                role: "user".to_string(),
1458                message: "day one".to_string(),
1459                frame_kind: None,
1460                branch: None,
1461                cwd: None,
1462                timestamp_source: None,
1463            },
1464            TimelineEntry {
1465                timestamp: Utc.with_ymd_and_hms(2026, 1, 21, 10, 0, 0).unwrap(),
1466                agent: "claude".to_string(),
1467                session_id: "s2".to_string(),
1468                role: "user".to_string(),
1469                message: "day two".to_string(),
1470                frame_kind: None,
1471                branch: None,
1472                cwd: None,
1473                timestamp_source: None,
1474            },
1475        ];
1476
1477        let config = ChunkerConfig::default();
1478        let chunks = chunk_entries(&entries, "proj", "claude", &config);
1479
1480        assert_eq!(chunks.len(), 2);
1481        assert_eq!(chunks[0].date, "2026-01-20");
1482        assert_eq!(chunks[1].date, "2026-01-21");
1483    }
1484
1485    #[test]
1486    fn test_format_chunk_text() {
1487        let entries = [
1488            make_entry(14, 30, "user", "hello"),
1489            make_entry(14, 31, "assistant", "hi there"),
1490        ];
1491        let refs: Vec<&TimelineEntry> = entries.iter().collect();
1492
1493        let text = format_chunk_text(&refs, "TestProj", "claude", "2026-01-22");
1494
1495        assert!(text.starts_with("[project: testproj | agent: claude | date: 2026-01-22]"));
1496        assert!(text.contains("[14:30:00] user: hello"));
1497        assert!(text.contains("[14:31:00] assistant: hi there"));
1498    }
1499
1500    #[test]
1501    fn test_format_chunk_text_truncates_utf8_safely() {
1502        let mut msg = "a".repeat(3999);
1503        msg.push('é'); // 2-byte char forces non-boundary at 4000
1504        let entries = [make_entry(14, 30, "user", &msg)];
1505        let refs: Vec<&TimelineEntry> = entries.iter().collect();
1506
1507        let text = format_chunk_text(&refs, "TestProj", "claude", "2026-01-22");
1508
1509        assert!(text.contains("[truncated]"));
1510        assert!(!text.contains('é'));
1511    }
1512
1513    #[test]
1514    fn test_chunk_entries_extracts_frontmatter_telemetry() {
1515        let entries = vec![make_entry(
1516            14,
1517            30,
1518            "assistant",
1519            "---\nrun_id: mrbl-001\nprompt_id: api-redesign_20260327\nmodel: gpt-5.4\nstarted_at: 2026-03-27T10:00:00Z\ncompleted_at: 2026-03-27T10:01:00Z\ntoken_usage: 1234\nfindings_count: 4\nframe_kind: agent_reply\nphase: implement\nmode: session-first\nskill_code: vc-workflow\nframework_version: 2026-03\n---\n## Report\nContent here",
1520        )];
1521
1522        let chunks = chunk_entries(&entries, "proj", "claude", &ChunkerConfig::default());
1523        assert_eq!(chunks.len(), 1);
1524
1525        let chunk = &chunks[0];
1526        assert_eq!(chunk.run_id.as_deref(), Some("mrbl-001"));
1527        assert_eq!(chunk.prompt_id.as_deref(), Some("api-redesign_20260327"));
1528        assert_eq!(chunk.agent_model.as_deref(), Some("gpt-5.4"));
1529        assert_eq!(chunk.started_at.as_deref(), Some("2026-03-27T10:00:00Z"));
1530        assert_eq!(chunk.completed_at.as_deref(), Some("2026-03-27T10:01:00Z"));
1531        assert_eq!(chunk.token_usage, Some(1234));
1532        assert_eq!(chunk.findings_count, Some(4));
1533        assert_eq!(chunk.frame_kind, Some(FrameKind::AgentReply));
1534        assert_eq!(chunk.workflow_phase.as_deref(), Some("implement"));
1535        assert_eq!(chunk.mode.as_deref(), Some("session-first"));
1536        assert_eq!(chunk.skill_code.as_deref(), Some("vc-workflow"));
1537        assert_eq!(chunk.framework_version.as_deref(), Some("2026-03"));
1538        assert!(chunk.text.contains("## Report"));
1539        assert!(!chunk.text.contains("run_id: mrbl-001"));
1540        assert!(!chunk.text.contains("phase: implement"));
1541    }
1542
1543    #[test]
1544    fn test_chunk_entries_skips_unsupported_frontmatter_values_without_dropping_metadata() {
1545        let entries = vec![make_entry(
1546            14,
1547            30,
1548            "assistant",
1549            "---\nrun_id: [nope\nmode: session-first\n---\n## Report\nBody survives",
1550        )];
1551
1552        let chunks = chunk_entries(&entries, "proj", "claude", &ChunkerConfig::default());
1553        assert_eq!(chunks.len(), 1);
1554
1555        let chunk = &chunks[0];
1556        assert_eq!(chunk.run_id, None);
1557        assert_eq!(chunk.mode.as_deref(), Some("session-first"));
1558        assert!(chunk.text.contains("## Report"));
1559        assert!(chunk.text.contains("Body survives"));
1560        assert!(!chunk.text.contains("mode: session-first"));
1561    }
1562
1563    #[test]
1564    fn test_write_chunks_to_dir() {
1565        let tmp = std::env::temp_dir().join("ai-ctx-chunker-test");
1566        let _ = fs::remove_dir_all(&tmp);
1567
1568        let chunks = vec![
1569            Chunk {
1570                id: "proj_claude_2026-01-22_001".to_string(),
1571                project: "proj".to_string(),
1572                agent: "claude".to_string(),
1573                date: "2026-01-22".to_string(),
1574                session_id: "s1".to_string(),
1575                cwd: Some("/Users/tester/workspaces/proj".to_string()),
1576                timestamp_source: None,
1577                kind: Kind::Conversations,
1578                frame_kind: Some(FrameKind::UserMsg),
1579                run_id: None,
1580                prompt_id: None,
1581                agent_model: None,
1582                started_at: None,
1583                completed_at: None,
1584                token_usage: None,
1585                findings_count: None,
1586                workflow_phase: Some("implement".to_string()),
1587                mode: Some("session-first".to_string()),
1588                skill_code: Some("vc-workflow".to_string()),
1589                framework_version: Some("2026-03".to_string()),
1590                msg_range: (0, 5),
1591                text: "chunk one content".to_string(),
1592                token_estimate: 4,
1593                highlights: vec![],
1594                noise_lines_dropped: 0,
1595            },
1596            Chunk {
1597                id: "proj_claude_2026-01-22_002".to_string(),
1598                project: "proj".to_string(),
1599                agent: "claude".to_string(),
1600                date: "2026-01-22".to_string(),
1601                session_id: "s1".to_string(),
1602                cwd: None,
1603                timestamp_source: None,
1604                kind: Kind::Conversations,
1605                frame_kind: None,
1606                run_id: None,
1607                prompt_id: None,
1608                agent_model: None,
1609                started_at: None,
1610                completed_at: None,
1611                token_usage: None,
1612                findings_count: None,
1613                workflow_phase: None,
1614                mode: None,
1615                skill_code: None,
1616                framework_version: None,
1617                msg_range: (3, 8),
1618                text: "chunk two content".to_string(),
1619                token_estimate: 4,
1620                highlights: vec![],
1621                noise_lines_dropped: 0,
1622            },
1623        ];
1624
1625        let paths = write_chunks_to_dir(&chunks, &tmp).unwrap();
1626        assert_eq!(paths.len(), 2);
1627        assert!(paths[0].exists());
1628        assert!(paths[1].exists());
1629
1630        let content = fs::read_to_string(&paths[0]).unwrap();
1631        assert_eq!(content, "chunk one content");
1632
1633        let sidecar = fs::read_to_string(tmp.join("proj_claude_2026-01-22_001.meta.json")).unwrap();
1634        let metadata: ChunkMetadataSidecar = serde_json::from_str(&sidecar).unwrap();
1635        assert_eq!(metadata.project, "proj");
1636        assert_eq!(metadata.agent, "claude");
1637        assert_eq!(metadata.date, "2026-01-22");
1638        assert_eq!(
1639            metadata.cwd.as_deref(),
1640            Some("/Users/tester/workspaces/proj")
1641        );
1642        assert_eq!(metadata.kind, Kind::Conversations);
1643        assert_eq!(metadata.frame_kind, Some(FrameKind::UserMsg));
1644        assert_eq!(metadata.workflow_phase.as_deref(), Some("implement"));
1645        assert_eq!(metadata.mode.as_deref(), Some("session-first"));
1646        assert_eq!(metadata.skill_code.as_deref(), Some("vc-workflow"));
1647        assert_eq!(metadata.framework_version.as_deref(), Some("2026-03"));
1648
1649        let legacy: ChunkMetadataSidecar = serde_json::from_value(serde_json::json!({
1650            "id": "legacy",
1651            "project": "proj",
1652            "agent": "claude",
1653            "date": "2026-01-22",
1654            "session_id": "s1",
1655            "kind": "conversations",
1656        }))
1657        .unwrap();
1658        assert_eq!(legacy.cwd, None);
1659        assert_eq!(legacy.frame_kind, None);
1660        assert_eq!(legacy.workflow_phase, None);
1661        assert_eq!(legacy.mode, None);
1662        assert_eq!(legacy.skill_code, None);
1663        assert_eq!(legacy.framework_version, None);
1664        assert_eq!(legacy.artifact_family, None);
1665        assert_eq!(legacy.schema_version, None);
1666        assert_eq!(legacy.truth_status, None);
1667        assert_eq!(legacy.learning_use, None);
1668        assert_eq!(legacy.keywords, None);
1669        assert_eq!(legacy.content_sha256, None);
1670
1671        let _ = fs::remove_dir_all(&tmp);
1672    }
1673
1674    #[test]
1675    fn sidecar_deserializes_context_corpus_contract_fields() {
1676        let sidecar: ChunkMetadataSidecar = serde_json::from_value(serde_json::json!({
1677            "id": "ctx-001",
1678            "project": "vetcoders/aicx",
1679            "agent": "loct-context-pack",
1680            "date": "2026-05-08",
1681            "session_id": "batch-001",
1682            "kind": "reports",
1683            "artifact_family": "loct-context-pack",
1684            "schema_version": "context_corpus.v1",
1685            "truth_status": {
1686                "role": "example",
1687                "runtime_authoritative": false,
1688                "stale_against_current_head": true,
1689                "current_head_when_ingested": "269d13c"
1690            },
1691            "learning_use": {
1692                "allowed": ["retrieval-test"],
1693                "forbidden": ["live-truth"]
1694            },
1695            "keywords": ["prism", "context"],
1696            "content_sha256": "abc123"
1697        }))
1698        .unwrap();
1699
1700        assert_eq!(
1701            sidecar.artifact_family.as_deref(),
1702            Some("loct-context-pack")
1703        );
1704        assert_eq!(sidecar.schema_version.as_deref(), Some("context_corpus.v1"));
1705        assert_eq!(
1706            sidecar.truth_status.as_ref().map(|status| status.role),
1707            Some(TruthRole::Example)
1708        );
1709        assert_eq!(
1710            sidecar.keywords.as_deref(),
1711            Some(&["prism".to_string(), "context".to_string()][..])
1712        );
1713        assert_eq!(sidecar.content_sha256.as_deref(), Some("abc123"));
1714    }
1715
1716    #[test]
1717    fn test_overlap_messages() {
1718        // 8 entries with short messages (~22 tokens each incl. header)
1719        // target=80 → ~4 messages per window, overlap=2 → windows share 2 messages
1720        let entries: Vec<TimelineEntry> = (0..8)
1721            .map(|i| make_entry(14, i as u32, "user", &format!("msg_{}", i)))
1722            .collect();
1723
1724        let config = ChunkerConfig {
1725            target_tokens: 80,
1726            min_tokens: 20,
1727            max_tokens: 200,
1728            overlap_messages: 2,
1729            noise_filter_enabled: true,
1730        };
1731
1732        let chunks = chunk_entries(&entries, "p", "c", &config);
1733
1734        // With overlap=2, consecutive chunks should share messages
1735        if chunks.len() >= 2 {
1736            // Verify ranges overlap (overlap=2 means last 2 msgs of chunk N start chunk N+1)
1737            let (_, end1) = chunks[0].msg_range;
1738            let (start2, _) = chunks[1].msg_range;
1739            assert!(
1740                start2 < end1,
1741                "Expected overlap: chunk1 ends at {}, chunk2 starts at {}",
1742                end1,
1743                start2
1744            );
1745        }
1746    }
1747
1748    #[test]
1749    fn test_chunk_id_format() {
1750        let entries = vec![make_entry(10, 0, "user", "test")];
1751        let config = ChunkerConfig::default();
1752        let chunks = chunk_entries(&entries, "MyProject", "gemini", &config);
1753
1754        assert_eq!(chunks[0].id, "myproject_gemini_2026-01-22_001");
1755    }
1756
1757    #[test]
1758    fn test_chunk_summary() {
1759        let chunks = vec![
1760            Chunk {
1761                id: "a".to_string(),
1762                project: "p".to_string(),
1763                agent: "c".to_string(),
1764                date: "2026-01-20".to_string(),
1765                session_id: "s".to_string(),
1766                cwd: None,
1767                timestamp_source: None,
1768                kind: Kind::Conversations,
1769                frame_kind: None,
1770                run_id: None,
1771                prompt_id: None,
1772                agent_model: None,
1773                started_at: None,
1774                completed_at: None,
1775                token_usage: None,
1776                findings_count: None,
1777                workflow_phase: None,
1778                mode: None,
1779                skill_code: None,
1780                framework_version: None,
1781                msg_range: (0, 5),
1782                text: "x".repeat(100),
1783                token_estimate: 25,
1784                highlights: vec![],
1785                noise_lines_dropped: 0,
1786            },
1787            Chunk {
1788                id: "b".to_string(),
1789                project: "p".to_string(),
1790                agent: "c".to_string(),
1791                date: "2026-01-21".to_string(),
1792                session_id: "s".to_string(),
1793                cwd: None,
1794                timestamp_source: None,
1795                kind: Kind::Conversations,
1796                frame_kind: None,
1797                run_id: None,
1798                prompt_id: None,
1799                agent_model: None,
1800                started_at: None,
1801                completed_at: None,
1802                token_usage: None,
1803                findings_count: None,
1804                workflow_phase: None,
1805                mode: None,
1806                skill_code: None,
1807                framework_version: None,
1808                msg_range: (5, 10),
1809                text: "y".repeat(200),
1810                token_estimate: 50,
1811                highlights: vec![],
1812                noise_lines_dropped: 0,
1813            },
1814        ];
1815
1816        let summary = chunk_summary(&chunks);
1817        assert!(summary.contains("2 chunks"));
1818        assert!(summary.contains("75 total tokens"));
1819        assert!(summary.contains("2 days"));
1820    }
1821
1822    #[test]
1823    fn test_extract_highlights_filters_keywords() {
1824        let entries = [
1825            make_entry(10, 0, "user", "Decision: lock chunking heuristics"),
1826            make_entry(10, 1, "assistant", "Just chatting"),
1827            make_entry(10, 2, "user", "TODO: add summarization notes"),
1828            make_entry(10, 3, "user", "KEY architectural choice"),
1829        ];
1830        let refs: Vec<&TimelineEntry> = entries.iter().collect();
1831
1832        let highlights = extract_highlights(&refs);
1833        assert_eq!(
1834            highlights,
1835            vec![
1836                "Decision: lock chunking heuristics",
1837                "TODO: add summarization notes",
1838                "KEY architectural choice"
1839            ]
1840        );
1841    }
1842
1843    #[test]
1844    fn test_format_chunk_text_includes_signals_for_checklist_and_intent() {
1845        let entries = [make_entry(
1846            14,
1847            30,
1848            "user",
1849            "No i tutaj mam taki pomysł, żeby to zrobić\nPlan mode: enabled\nUser accepted the plan\nUltrathink:\n- [ ] pierwsza rzecz\n- [x] druga rzecz\n\n★ Insight ─ to działa",
1850        )];
1851        let refs: Vec<&TimelineEntry> = entries.iter().collect();
1852
1853        let text = format_chunk_text(&refs, "TestProj", "claude", "2026-01-22");
1854
1855        assert!(text.contains("[signals]"));
1856        assert!(text.contains("RED LIGHT: checklist detected (open: 1, done: 1)"));
1857        assert!(text.contains("- [ ] pierwsza rzecz"));
1858        assert!(text.contains("- [x] druga rzecz"));
1859        assert!(text.contains("Ultrathink:"));
1860        assert!(text.contains("- Ultrathink:"));
1861        assert!(text.contains("Insight:"));
1862        assert!(text.contains("- ★ Insight ─ to działa"));
1863        assert!(text.contains("Plan mode:"));
1864        assert!(text.contains("- Plan mode: enabled"));
1865        assert!(text.contains("- User accepted the plan"));
1866        assert!(text.contains("Intent:"));
1867        assert!(text.contains("No i tutaj mam taki pomysł, żeby to zrobić"));
1868        assert!(text.contains("[/signals]"));
1869    }
1870
1871    // ── Area E.8 + E.12 regression coverage ─────────────────────────────
1872
1873    #[test]
1874    fn test_normalize_key_strips_zero_width_chars() {
1875        assert_eq!(normalize_key("fix au\u{200B}th"), "fix auth");
1876        assert_eq!(normalize_key("fix au\u{200C}th"), "fix auth");
1877        assert_eq!(normalize_key("fix au\u{200D}th"), "fix auth");
1878        assert_eq!(normalize_key("\u{FEFF}fix auth"), "fix auth");
1879    }
1880
1881    #[test]
1882    fn test_normalize_key_strips_bidi_controls() {
1883        assert_eq!(normalize_key("fix\u{202A}auth\u{202C}"), "fixauth");
1884        assert_eq!(normalize_key("fix \u{2066}auth\u{2069}"), "fix auth");
1885    }
1886
1887    #[test]
1888    fn test_normalize_key_preserves_visible_chars() {
1889        assert_eq!(normalize_key("  Fix  AUTH  Bug  "), "fix auth bug");
1890        assert_eq!(normalize_key("naprawdę"), "naprawdę");
1891    }
1892
1893    #[test]
1894    fn test_extract_checklist_skips_lines_inside_code_fence() {
1895        let entry = make_entry(
1896            12,
1897            0,
1898            "user",
1899            "Here is sample syntax:\n```\n- [ ] sample task A\n- [x] sample task B\n```\nReal work:\n- [ ] real task open\n- [x] real task done",
1900        );
1901        let entries = vec![&entry];
1902        let (open, done) = extract_checklist_items(&entries);
1903        assert_eq!(open, vec!["real task open".to_string()]);
1904        assert_eq!(done, vec!["real task done".to_string()]);
1905    }
1906
1907    #[test]
1908    fn test_extract_checklist_fence_toggle_resets_per_entry() {
1909        let entry_a = make_entry(12, 0, "user", "```\n- [ ] fenced leak");
1910        let entry_b = make_entry(12, 1, "user", "- [ ] honest task");
1911        let entries = vec![&entry_a, &entry_b];
1912        let (open, _done) = extract_checklist_items(&entries);
1913        assert_eq!(open, vec!["honest task".to_string()]);
1914    }
1915}