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 rust-memex.
5//!
6//! Vibecrafted with AI Agents by Vetcoders (c)2026 Vetcoders
7
8use anyhow::Result;
9use serde::{Deserialize, Deserializer, 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    /// Foreign-import provenance (operator-md and similar): originating file,
63    /// its format, and a stable content-hash import id (Round II / oś 3+5).
64    pub source_file: Option<String>,
65    pub source_format: Option<String>,
66    pub import_id: Option<String>,
67    /// Raw L0 source pointer for provider-backed transcripts when every entry
68    /// in the chunk window comes from the same source file.
69    pub source: Option<CardSource>,
70    /// Index range in original day's entries (start, end exclusive)
71    pub msg_range: (usize, usize),
72    /// Formatted chunk text with header
73    pub text: String,
74    /// Estimated token count (~chars/4)
75    pub token_estimate: usize,
76    /// Decision/plan highlights extracted from the chunk
77    pub highlights: Vec<String>,
78    /// Typed signal records for this chunk. These are the primary artifact;
79    /// the `[signals]` block inside `text` is a deterministic render of them.
80    pub signals: Vec<CardSignal>,
81    /// Number of structural-noise lines (line-numbered grep matches, tool
82    /// echoes, stray YAML delimiters) dropped from the source entries while
83    /// building this chunk. Operators use this as observability — high values
84    /// flag corpora that should be re-ingested with the filter on, or
85    /// upstream emitters that produce excessive scaffolding.
86    pub noise_lines_dropped: usize,
87}
88
89/// Structured metadata sidecar persisted alongside each chunk file.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct ChunkMetadataSidecar {
92    pub id: String,
93    #[serde(
94        default = "default_card_schema_version",
95        deserialize_with = "deserialize_card_schema_version",
96        skip_serializing_if = "is_default_card_schema_version"
97    )]
98    pub schema_version: u32,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub migrated_from_schema: Option<u32>,
101    pub project: String,
102    pub agent: String,
103    pub date: String,
104    pub session_id: String,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub cwd: Option<String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub timestamp_source: Option<String>,
109    pub kind: Kind,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub frame_kind: Option<FrameKind>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub speaker_hint: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub run_id: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub prompt_id: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub agent_model: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub started_at: Option<String>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub completed_at: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub token_usage: Option<u64>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub findings_count: Option<u32>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub workflow_phase: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub mode: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub skill_code: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub framework_version: Option<String>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub source_file: Option<String>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub source_format: Option<String>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub import_id: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub source: Option<CardSource>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub claim_scope: Option<String>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub freshness_contract: Option<String>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub verification_state: Option<String>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub signals: Option<Vec<CardSignal>>,
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub intent_entries: Vec<crate::types::IntentEntry>,
154    /// Weak repo/content mentions preserved for query/tag surfaces. This is
155    /// append-only so pre-tag sidecars deserialize with an empty vector.
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub tags: Vec<String>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub artifact_family: Option<String>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub truth_status: Option<TruthStatus>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub learning_use: Option<LearningUse>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub keywords: Option<Vec<String>>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub content_sha256: Option<String>,
168    /// Number of noise lines dropped during chunk construction. Defaults to
169    /// `0` when the field is absent in older sidecars.
170    #[serde(default, skip_serializing_if = "is_zero_usize")]
171    pub noise_lines_dropped: usize,
172}
173
174pub const CARD_SCHEMA_VERSION: u32 = 2;
175pub const CARD_CLAIM_SCOPE_SESSION_CLOSE: &str = "session_close";
176pub const CARD_FRESHNESS_CONTRACT_HISTORICAL: &str = "historical";
177pub const CARD_VERIFICATION_STATE_NOT_VERIFIED_BY_AICX: &str = "not_verified_by_aicx";
178
179/// Canonical `kind` labels for [`CardSignal`] records. One label per
180/// `ChunkSignals` family plus `highlight`; consumers (validator, migration,
181/// display) must match against these constants, never re-derive the strings.
182pub const SIGNAL_KIND_SKILL: &str = "skill";
183pub const SIGNAL_KIND_TODO_OPEN: &str = "todo_open";
184pub const SIGNAL_KIND_TODO_DONE: &str = "todo_done";
185pub const SIGNAL_KIND_ULTRATHINK: &str = "ultrathink";
186pub const SIGNAL_KIND_INSIGHT: &str = "insight";
187pub const SIGNAL_KIND_PLAN_MODE: &str = "plan_mode";
188pub const SIGNAL_KIND_INTENT: &str = "intent";
189pub const SIGNAL_KIND_DECISION: &str = "decision";
190pub const SIGNAL_KIND_RESULT: &str = "result";
191pub const SIGNAL_KIND_OUTCOME: &str = "outcome";
192pub const SIGNAL_KIND_HIGHLIGHT: &str = "highlight";
193
194/// Extractor generation stamped on every [`CardSignal`] so future
195/// re-extraction passes can tell which heuristics produced a record.
196pub const SIGNAL_EXTRACTOR_VERSION: &str = env!("CARGO_PKG_VERSION");
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199pub struct CardSource {
200    pub path: String,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub sha256: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub span: Option<(u64, u64)>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208pub struct CardSignal {
209    pub kind: String,
210    pub text: String,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub line_span: Option<(u64, u64)>,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub extractor_version: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218pub struct TruthStatus {
219    pub role: TruthRole,
220    #[serde(default)]
221    pub runtime_authoritative: bool,
222    #[serde(default)]
223    pub stale_against_current_head: bool,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub current_head_when_ingested: Option<String>,
226}
227
228#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
229#[serde(rename_all = "snake_case")]
230pub enum TruthRole {
231    Live,
232    Example,
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
236pub struct LearningUse {
237    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238    pub allowed: Vec<String>,
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub forbidden: Vec<String>,
241}
242
243fn default_card_schema_version() -> u32 {
244    1
245}
246
247fn is_default_card_schema_version(value: &u32) -> bool {
248    *value == default_card_schema_version()
249}
250
251fn deserialize_card_schema_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
252where
253    D: Deserializer<'de>,
254{
255    #[derive(Deserialize)]
256    #[serde(untagged)]
257    enum SchemaVersionWire {
258        Number(u32),
259        String(String),
260    }
261
262    match SchemaVersionWire::deserialize(deserializer)? {
263        SchemaVersionWire::Number(version) => Ok(version),
264        SchemaVersionWire::String(version) => parse_schema_version_string(&version)
265            .ok_or_else(|| serde::de::Error::custom(format!("invalid schema_version {version:?}"))),
266    }
267}
268
269fn parse_schema_version_string(value: &str) -> Option<u32> {
270    if let Ok(version) = value.parse::<u32>() {
271        return Some(version);
272    }
273
274    ["card.v", "context_corpus.v", "v"]
275        .iter()
276        .find_map(|prefix| value.strip_prefix(prefix)?.parse::<u32>().ok())
277}
278
279fn is_zero_usize(value: &usize) -> bool {
280    *value == 0
281}
282
283impl From<&Chunk> for ChunkMetadataSidecar {
284    fn from(chunk: &Chunk) -> Self {
285        Self {
286            id: chunk.id.clone(),
287            project: chunk.project.clone(),
288            agent: chunk.agent.clone(),
289            date: chunk.date.clone(),
290            session_id: chunk.session_id.clone(),
291            cwd: chunk.cwd.clone(),
292            timestamp_source: chunk.timestamp_source.clone(),
293            kind: chunk.kind,
294            frame_kind: chunk.frame_kind,
295            speaker_hint: speaker_hint_from_chunk_text(&chunk.text),
296            run_id: chunk.run_id.clone(),
297            prompt_id: chunk.prompt_id.clone(),
298            agent_model: chunk.agent_model.clone(),
299            started_at: chunk.started_at.clone(),
300            completed_at: chunk.completed_at.clone(),
301            token_usage: chunk.token_usage,
302            findings_count: chunk.findings_count,
303            workflow_phase: chunk.workflow_phase.clone(),
304            mode: chunk.mode.clone(),
305            skill_code: chunk.skill_code.clone(),
306            framework_version: chunk.framework_version.clone(),
307            source_file: chunk.source_file.clone(),
308            source_format: chunk.source_format.clone(),
309            import_id: chunk.import_id.clone(),
310            schema_version: CARD_SCHEMA_VERSION,
311            migrated_from_schema: None,
312            source: chunk.source.clone(),
313            claim_scope: Some(CARD_CLAIM_SCOPE_SESSION_CLOSE.to_string()),
314            freshness_contract: Some(CARD_FRESHNESS_CONTRACT_HISTORICAL.to_string()),
315            verification_state: Some(CARD_VERIFICATION_STATE_NOT_VERIFIED_BY_AICX.to_string()),
316            signals: (!chunk.signals.is_empty()).then(|| chunk.signals.clone()),
317            intent_entries: Vec::new(),
318            tags: Vec::new(),
319            artifact_family: None,
320            truth_status: None,
321            learning_use: None,
322            keywords: None,
323            content_sha256: None,
324            noise_lines_dropped: chunk.noise_lines_dropped,
325        }
326    }
327}
328
329fn speaker_hint_from_chunk_text(text: &str) -> Option<String> {
330    text.lines()
331        .find_map(|line| line.strip_prefix("speaker_hint: "))
332        .map(str::trim)
333        .filter(|value| !value.is_empty())
334        .map(ToOwned::to_owned)
335}
336
337/// Configuration for the chunker.
338#[derive(Debug, Clone)]
339pub struct ChunkerConfig {
340    /// Target tokens per chunk (default: 1500)
341    pub target_tokens: usize,
342    /// Minimum tokens — don't create tiny chunks unless it's the last window (default: 500)
343    pub min_tokens: usize,
344    /// Maximum tokens — force split if exceeded (default: 2500)
345    pub max_tokens: usize,
346    /// Number of messages to overlap between consecutive windows (default: 2)
347    pub overlap_messages: usize,
348    /// Whether to strip structural noise (line-numbered grep matches, tool
349    /// echoes, stray YAML delimiters) before signal/highlight extraction.
350    /// Default: `true`. Set to `false` for debugging or when raw upstream
351    /// content must be preserved verbatim.
352    pub noise_filter_enabled: bool,
353}
354
355impl Default for ChunkerConfig {
356    fn default() -> Self {
357        Self {
358            target_tokens: 1500,
359            min_tokens: 500,
360            max_tokens: 2500,
361            overlap_messages: 2,
362            noise_filter_enabled: true,
363        }
364    }
365}
366
367// ============================================================================
368// Token estimation
369// ============================================================================
370
371/// Estimate token count from text length.
372///
373/// Uses the simple heuristic: 1 token ≈ 4 characters.
374/// Rounds up to avoid underestimation.
375pub fn estimate_tokens(text: &str) -> usize {
376    text.len().div_ceil(4)
377}
378
379// ── Kind heuristics ────────────────────────────────────────────────────────
380
381const PLAN_KEYWORDS: &[&str] = &[
382    "implementation plan",
383    "plan:",
384    "## plan",
385    "step 1:",
386    "step 2:",
387    "step 3:",
388    "action items",
389    "milestones",
390    "roadmap",
391    "todo list",
392    "acceptance criteria",
393    "## steps",
394    "## phases",
395];
396
397const REPORT_KEYWORDS: &[&str] = &[
398    "## findings",
399    "## summary",
400    "## report",
401    "audit report",
402    "coverage report",
403    "test results",
404    "## metrics",
405    "## recommendations",
406    "## conclusion",
407    "status report",
408    "incident report",
409    "pr review",
410    "code review",
411];
412
413/// Classify a set of timeline entries into a canonical `Kind`.
414///
415/// Uses a lightweight keyword-scoring approach:
416/// - Scans assistant messages (where classification signal is strongest)
417/// - Scores plan vs report keywords
418/// - Conversations win by default when neither plan nor report signal is strong
419///
420/// The approach is intentionally conservative: ambiguous content falls to
421/// `Conversations` (the most common kind), not `Other`.
422pub fn classify_kind(entries: &[TimelineEntry]) -> Kind {
423    if entries.is_empty() {
424        return Kind::Other;
425    }
426
427    let mut plan_score: u32 = 0;
428    let mut report_score: u32 = 0;
429    let mut has_conversation = false;
430
431    for entry in entries {
432        let lower = entry.message.to_lowercase();
433
434        // Only count strong signals from assistant messages.
435        if entry.role == "assistant" {
436            for kw in PLAN_KEYWORDS {
437                if lower.contains(kw) {
438                    plan_score += 1;
439                }
440            }
441            for kw in REPORT_KEYWORDS {
442                if lower.contains(kw) {
443                    report_score += 1;
444                }
445            }
446        }
447
448        if entry.role == "user" || entry.role == "assistant" {
449            has_conversation = true;
450        }
451    }
452
453    let threshold = 3;
454
455    if plan_score >= threshold && plan_score > report_score {
456        Kind::Plans
457    } else if report_score >= threshold && report_score > plan_score {
458        Kind::Reports
459    } else if has_conversation {
460        Kind::Conversations
461    } else {
462        Kind::Other
463    }
464}
465
466fn prepare_entries_for_chunking<'a>(
467    entries: &'a [TimelineEntry],
468) -> (
469    Option<crate::frontmatter::ReportFrontmatter>,
470    Cow<'a, [TimelineEntry]>,
471) {
472    let Some(first) = entries.first() else {
473        return (None, Cow::Borrowed(entries));
474    };
475
476    if !first.message.trim_start().starts_with("---") {
477        return (None, Cow::Borrowed(entries));
478    }
479
480    let (frontmatter, body) = crate::frontmatter::parse(&first.message);
481    if body == first.message {
482        return (None, Cow::Borrowed(entries));
483    }
484
485    let mut stripped_entries = entries.to_vec();
486    if let Some(stripped_first) = stripped_entries.first_mut() {
487        stripped_first.message = body.to_string();
488    }
489
490    (frontmatter, Cow::Owned(stripped_entries))
491}
492
493fn apply_frontmatter(chunk: &mut Chunk, frontmatter: &crate::frontmatter::ReportFrontmatter) {
494    if chunk.frame_kind.is_none() {
495        chunk.frame_kind = frontmatter.telemetry.frame_kind;
496    }
497    chunk.run_id = frontmatter.telemetry.run_id.clone();
498    chunk.prompt_id = frontmatter.telemetry.prompt_id.clone();
499    chunk.agent_model = frontmatter.telemetry.model.clone();
500    chunk.started_at = frontmatter.telemetry.started_at.clone();
501    chunk.completed_at = frontmatter.telemetry.completed_at.clone();
502    chunk.token_usage = frontmatter.telemetry.token_usage;
503    chunk.findings_count = frontmatter.telemetry.findings_count;
504    chunk.workflow_phase = frontmatter.steering.workflow_phase.clone();
505    chunk.mode = frontmatter.steering.mode.clone();
506    chunk.skill_code = frontmatter.steering.skill_code.clone();
507    chunk.framework_version = frontmatter.steering.framework_version.clone();
508    chunk.source_file = frontmatter.telemetry.source_file.clone();
509    chunk.source_format = frontmatter.telemetry.source_format.clone();
510    chunk.import_id = frontmatter.telemetry.import_id.clone();
511}
512
513fn split_day_entries_by_frame_kind<'a>(
514    entries: &'a [(usize, &'a TimelineEntry)],
515) -> Vec<&'a [(usize, &'a TimelineEntry)]> {
516    if entries.is_empty() {
517        return Vec::new();
518    }
519
520    let mut groups = Vec::new();
521    let mut start = 0usize;
522
523    for idx in 1..entries.len() {
524        let previous = entries[idx - 1].1.frame_kind;
525        let current = entries[idx].1.frame_kind;
526        if previous != current {
527            groups.push(&entries[start..idx]);
528            start = idx;
529        }
530    }
531
532    groups.push(&entries[start..]);
533    groups
534}
535
536fn frame_kind_for_window(entries: &[&TimelineEntry]) -> Option<FrameKind> {
537    let first = entries.first().and_then(|entry| entry.frame_kind)?;
538    entries
539        .iter()
540        .all(|entry| entry.frame_kind == Some(first))
541        .then_some(first)
542}
543
544fn timestamp_source_for_window(entries: &[&TimelineEntry]) -> Option<String> {
545    let mut sources = entries
546        .iter()
547        .filter_map(|entry| entry.timestamp_source.as_deref());
548    let first = sources.next()?;
549    let mut source = first.to_string();
550    if sources.any(|candidate| candidate != first) {
551        source = "mixed".to_string();
552    }
553    Some(source)
554}
555
556fn source_for_window(entries: &[&TimelineEntry]) -> Option<CardSource> {
557    let mut sourced_entries = entries.iter().filter_map(|entry| {
558        entry
559            .source_path
560            .as_ref()
561            .map(|path| (*entry, path.as_str()))
562    });
563    let (first_entry, first_path) = sourced_entries.next()?;
564    if sourced_entries.any(|(_, path)| path != first_path) {
565        return None;
566    }
567
568    let sha256 = entries
569        .iter()
570        .filter(|entry| entry.source_path.as_deref() == Some(first_path))
571        .filter_map(|entry| entry.source_sha256.as_deref())
572        .try_fold(None, |known: Option<&str>, sha| match known {
573            Some(existing) if existing != sha => None,
574            Some(existing) => Some(Some(existing)),
575            None => Some(Some(sha)),
576        })
577        .flatten()
578        .map(ToOwned::to_owned);
579
580    let span = entries
581        .iter()
582        .filter(|entry| entry.source_path.as_deref() == Some(first_path))
583        .filter_map(|entry| entry.source_line_span)
584        .fold(None, |acc: Option<(u64, u64)>, (start, end)| {
585            Some(match acc {
586                Some((known_start, known_end)) => (known_start.min(start), known_end.max(end)),
587                None => (start, end),
588            })
589        });
590
591    Some(CardSource {
592        path: first_entry.source_path.clone().unwrap_or_default(),
593        sha256,
594        span,
595    })
596}
597
598// ============================================================================
599// Chunking logic
600// ============================================================================
601
602/// Chunk timeline entries into semantic windows with overlap.
603///
604/// Groups entries by date, then applies sliding window within each day.
605/// Returns chunks sorted by date and sequence number.
606pub fn chunk_entries(
607    entries: &[TimelineEntry],
608    project: &str,
609    agent: &str,
610    config: &ChunkerConfig,
611) -> Vec<Chunk> {
612    if entries.is_empty() {
613        return vec![];
614    }
615
616    let project = canonical_project_label(project);
617    let (frontmatter, prepared_entries) = prepare_entries_for_chunking(entries);
618    let prepared_entries = prepared_entries.as_ref();
619
620    // Group entries by date
621    let mut by_date: BTreeMap<String, Vec<(usize, &TimelineEntry)>> = BTreeMap::new();
622    for (idx, entry) in prepared_entries.iter().enumerate() {
623        let date = entry.timestamp.format("%Y-%m-%d").to_string();
624        by_date.entry(date).or_default().push((idx, entry));
625    }
626
627    let mut chunks = Vec::new();
628
629    for (date, day_entries) in &by_date {
630        let mut day_chunks = Vec::new();
631        let mut next_seq = 1usize;
632        for frame_group in split_day_entries_by_frame_kind(day_entries) {
633            let (mut group_chunks, updated_seq) =
634                chunk_day_entries(frame_group, &project, agent, date, config, next_seq);
635            next_seq = updated_seq;
636            day_chunks.append(&mut group_chunks);
637        }
638        if let Some(frontmatter) = frontmatter.as_ref() {
639            for chunk in &mut day_chunks {
640                apply_frontmatter(chunk, frontmatter);
641            }
642        }
643        chunks.extend(day_chunks);
644    }
645
646    chunks
647}
648
649/// Apply sliding window chunking to a single day's entries.
650fn chunk_day_entries(
651    entries: &[(usize, &TimelineEntry)],
652    project: &str,
653    agent: &str,
654    date: &str,
655    config: &ChunkerConfig,
656    start_seq: usize,
657) -> (Vec<Chunk>, usize) {
658    if entries.is_empty() {
659        return (vec![], start_seq);
660    }
661
662    let mut chunks = Vec::new();
663    let mut seq = start_seq;
664    let mut start = 0usize;
665
666    while start < entries.len() {
667        // Find window end: accumulate until target_tokens reached
668        let mut end = start;
669        let mut accumulated_tokens = 0usize;
670
671        while end < entries.len() {
672            let msg_tokens = estimate_tokens(&entries[end].1.message);
673            let next_total = accumulated_tokens + msg_tokens + 20; // ~20 tokens for timestamp/role header
674
675            if next_total > config.max_tokens && end > start {
676                break;
677            }
678
679            accumulated_tokens = next_total;
680            end += 1;
681
682            if accumulated_tokens >= config.target_tokens {
683                break;
684            }
685        }
686
687        // Build chunk from entries[start..end].
688        //
689        // Pre-sanitize each entry's message through the noise filter BEFORE
690        // signal/highlight extraction so the `[signals]` block and the
691        // entry-level body are both built from semantic content only. The
692        // filter can be disabled via `ChunkerConfig::noise_filter_enabled`
693        // for debugging/raw modes.
694        let window: Vec<&TimelineEntry> = entries[start..end].iter().map(|(_, e)| *e).collect();
695        let (sanitized_owned, noise_lines_dropped) = sanitize_window(&window, config);
696        let window: Vec<&TimelineEntry> = sanitized_owned.iter().collect();
697        let highlights = extract_highlights(&window);
698        let signals = extract_signals(&window);
699        let records = signal_records(&signals, &highlights);
700        let frame_kind = frame_kind_for_window(&window);
701        let timestamp_source = timestamp_source_for_window(&window);
702        let source = source_for_window(&window);
703        let text = format_chunk_text_inner(&window, project, agent, date, frame_kind, &records);
704        let token_estimate = estimate_tokens(&text);
705
706        let session_id = window
707            .first()
708            .map(|e| e.session_id.clone())
709            .unwrap_or_default();
710        let cwd = window.first().and_then(|entry| entry.cwd.clone());
711
712        let global_start = entries[start].0;
713        let global_end = entries[end - 1].0 + 1;
714
715        let kind = classify_kind(&window.iter().map(|e| (*e).clone()).collect::<Vec<_>>());
716
717        chunks.push(Chunk {
718            id: format!("{}_{}_{}_{{:03}}", project, agent, date)
719                .replace("{:03}", &format!("{:03}", seq)),
720            project: project.to_string(),
721            agent: agent.to_string(),
722            date: date.to_string(),
723            session_id,
724            cwd,
725            timestamp_source,
726            kind,
727            frame_kind,
728            run_id: None,
729            prompt_id: None,
730            agent_model: None,
731            started_at: None,
732            completed_at: None,
733            token_usage: None,
734            findings_count: None,
735            workflow_phase: None,
736            mode: None,
737            skill_code: None,
738            framework_version: None,
739            source_file: None,
740            source_format: None,
741            import_id: None,
742            source,
743            msg_range: (global_start, global_end),
744            text,
745            token_estimate,
746            highlights: highlights.iter().map(|item| item.text.clone()).collect(),
747            signals: records,
748            noise_lines_dropped,
749        });
750
751        seq += 1;
752
753        // Next window starts at (end - overlap), but always advance at least 1
754        let overlap = config.overlap_messages.min(end - start);
755        let next_start = if end >= entries.len() {
756            entries.len() // done
757        } else if end - overlap > start {
758            end - overlap
759        } else {
760            end // avoid infinite loop
761        };
762
763        start = next_start;
764    }
765
766    (chunks, seq)
767}
768
769/// Format entries into chunk text with metadata header.
770///
771/// The public surface uses the chunker's default configuration; callers that
772/// need non-default behavior (e.g. disabled noise filter) should go through
773/// [`chunk_entries`] which threads [`ChunkerConfig`] in full.
774pub fn format_chunk_text(
775    entries: &[&TimelineEntry],
776    project: &str,
777    agent: &str,
778    date: &str,
779) -> String {
780    let config = ChunkerConfig::default();
781    let (sanitized_owned, _dropped) = sanitize_window(entries, &config);
782    let entries: Vec<&TimelineEntry> = sanitized_owned.iter().collect();
783    let highlights = extract_highlights(&entries);
784    let signals = extract_signals(&entries);
785    let records = signal_records(&signals, &highlights);
786    let project = canonical_project_label(project);
787    format_chunk_text_inner(
788        &entries,
789        &project,
790        agent,
791        date,
792        frame_kind_for_window(&entries),
793        &records,
794    )
795}
796
797fn canonical_project_label(project: &str) -> String {
798    project
799        .split('/')
800        .map(|segment| segment.trim().to_ascii_lowercase())
801        .collect::<Vec<_>>()
802        .join("/")
803}
804
805/// Clone a window's entries with their messages run through
806/// [`crate::noise::filter_noise_lines_with_count`]. Used before any signal
807/// or highlight extraction so structural noise never leaks into the semantic
808/// surface. Returns the cloned window plus the aggregate count of dropped
809/// noise lines for observability sidecars.
810///
811/// When `config.noise_filter_enabled` is `false`, returns a plain clone of
812/// the input window with `dropped == 0`.
813fn sanitize_window(
814    window: &[&TimelineEntry],
815    config: &ChunkerConfig,
816) -> (Vec<TimelineEntry>, usize) {
817    if !config.noise_filter_enabled {
818        let cloned = window.iter().map(|entry| (*entry).clone()).collect();
819        return (cloned, 0);
820    }
821
822    let mut total_dropped = 0usize;
823    let cloned: Vec<TimelineEntry> = window
824        .iter()
825        .map(|entry| {
826            let mut cloned = (*entry).clone();
827            let (filtered, dropped) = crate::noise::filter_noise_lines_with_count(&entry.message);
828            cloned.message = filtered;
829            total_dropped += dropped;
830            cloned
831        })
832        .collect();
833    (cloned, total_dropped)
834}
835
836fn format_chunk_text_inner(
837    entries: &[&TimelineEntry],
838    project: &str,
839    agent: &str,
840    date: &str,
841    frame_kind: Option<FrameKind>,
842    signal_records: &[CardSignal],
843) -> String {
844    let mut text = format!("---\nproject: {project}\nagent: {agent}\ndate: {date}\n");
845    if let Some(frame_kind) = frame_kind {
846        text.push_str(&format!("frame_kind: {frame_kind}\n"));
847    }
848    text.push_str("schema: card.v2\n---\n\n");
849
850    if let Some(block) = format_signals_block(signal_records) {
851        text.push_str(&block);
852        text.push('\n');
853    }
854
855    // Note: callers (`format_chunk_text`, `chunk_day_entries`) pass entries
856    // that have already been routed through `sanitize_window`, so message
857    // bodies here are noise-free. Skip empty messages so windows that reduced
858    // to pure scaffolding don't emit empty role lines.
859    for entry in entries {
860        if entry.message.is_empty() {
861            continue;
862        }
863        let time = entry.timestamp.format("%H:%M:%S");
864        // Truncate very long messages to avoid monster chunks (UTF-8 safe).
865        let msg = if entry.message.len() > 4000 {
866            truncate_message_bytes(&entry.message, 4000)
867        } else {
868            entry.message.clone()
869        };
870        text.push_str(&format!("[{}] {}: {}\n", time, entry.role, msg));
871    }
872
873    text
874}
875
876const HIGHLIGHT_KEYWORDS: &[&str] = &[
877    "decision:",
878    "plan:",
879    "architecture",
880    "breaking",
881    "todo:",
882    "fixme:",
883];
884
885const HIGHLIGHT_KEYWORDS_CASE_SENSITIVE: &[&str] = &["WAŻNE", "KEY"];
886
887fn extract_highlights(entries: &[&TimelineEntry]) -> Vec<SignalItem> {
888    let mut highlights: Vec<SignalItem> = Vec::new();
889    for entry in entries {
890        if highlights.len() >= 3 {
891            break;
892        }
893        if !is_highlight_message(&entry.message) {
894            continue;
895        }
896
897        if let Some(line) = entry.message.lines().map(str::trim).find(|l| !l.is_empty())
898            && highlights.last().map(|item| item.text.as_str()) != Some(line)
899        {
900            highlights.push(SignalItem::new(line.to_string(), entry.source_line_span));
901        }
902    }
903    highlights
904}
905
906fn is_highlight_message(message: &str) -> bool {
907    let lower = message.to_lowercase();
908    HIGHLIGHT_KEYWORDS.iter().any(|kw| lower.contains(kw))
909        || HIGHLIGHT_KEYWORDS_CASE_SENSITIVE
910            .iter()
911            .any(|kw| message.contains(kw))
912}
913
914// ============================================================================
915// Signals (intent + checklists)
916// ============================================================================
917
918/// One extracted signal line/block plus the raw-source line span of the
919/// timeline entry it came from (when the provider reported one). The span is
920/// entry-granular — the extractor does not track per-line offsets, so it
921/// never invents anything finer.
922#[derive(Debug, Clone, Default)]
923struct SignalItem {
924    text: String,
925    line_span: Option<(u64, u64)>,
926}
927
928impl SignalItem {
929    fn new(text: String, line_span: Option<(u64, u64)>) -> Self {
930        Self { text, line_span }
931    }
932}
933
934#[derive(Debug, Clone, Default)]
935struct ChunkSignals {
936    todo_open: Vec<SignalItem>,
937    todo_done: Vec<SignalItem>,
938    ultrathink: Vec<SignalItem>,
939    insights: Vec<SignalItem>,
940    plan_mode: Vec<SignalItem>,
941    intents: Vec<SignalItem>,
942    results: Vec<SignalItem>,
943    skills: Vec<SignalItem>,
944    decisions: Vec<SignalItem>,
945    outcomes: Vec<SignalItem>,
946}
947
948/// Flatten extracted signal families into typed [`CardSignal`] records, in
949/// the same family order the `[signals]` block renders them. Every record is
950/// stamped with [`SIGNAL_EXTRACTOR_VERSION`] so later re-extraction can tell
951/// generations apart.
952fn signal_records(signals: &ChunkSignals, highlights: &[SignalItem]) -> Vec<CardSignal> {
953    fn push_family(out: &mut Vec<CardSignal>, kind: &str, items: &[SignalItem]) {
954        for item in items {
955            out.push(CardSignal {
956                kind: kind.to_string(),
957                text: item.text.clone(),
958                line_span: item.line_span,
959                extractor_version: Some(SIGNAL_EXTRACTOR_VERSION.to_string()),
960            });
961        }
962    }
963
964    let mut out = Vec::new();
965    push_family(&mut out, SIGNAL_KIND_SKILL, &signals.skills);
966    push_family(&mut out, SIGNAL_KIND_TODO_OPEN, &signals.todo_open);
967    push_family(&mut out, SIGNAL_KIND_TODO_DONE, &signals.todo_done);
968    push_family(&mut out, SIGNAL_KIND_ULTRATHINK, &signals.ultrathink);
969    push_family(&mut out, SIGNAL_KIND_INSIGHT, &signals.insights);
970    push_family(&mut out, SIGNAL_KIND_PLAN_MODE, &signals.plan_mode);
971    push_family(&mut out, SIGNAL_KIND_INTENT, &signals.intents);
972    push_family(&mut out, SIGNAL_KIND_DECISION, &signals.decisions);
973    push_family(&mut out, SIGNAL_KIND_RESULT, &signals.results);
974    push_family(&mut out, SIGNAL_KIND_OUTCOME, &signals.outcomes);
975    push_family(&mut out, SIGNAL_KIND_HIGHLIGHT, highlights);
976    out
977}
978
979const MAX_TODO_ITEMS: usize = 8;
980const MAX_ULTRATHINK_BLOCKS: usize = 4;
981const MAX_INSIGHT_BLOCKS: usize = 6;
982const MAX_PLAN_MODE_EVENTS: usize = 8;
983const MAX_INTENT_LINES: usize = 6;
984const MAX_RESULT_LINES: usize = 6;
985const MAX_TAG_BLOCK_LINES: usize = 4;
986
987pub const INTENT_KEYWORDS: &[&str] = &[
988    // Polish
989    "mam pomysl",
990    "mam pomysł",
991    "mam taki pomysl",
992    "mam taki pomysł",
993    "pomysl",
994    "pomysł",
995    "proponuje",
996    "proponuję",
997    "zrobmy",
998    "zróbmy",
999    "ustalmy",
1000    "ustalmy",
1001    "chce",
1002    "chcę",
1003    "chcialbym",
1004    "chciałbym",
1005    "potrzebuje",
1006    "potrzebuję",
1007    "prosze",
1008    "proszę",
1009    "odpal",
1010    "uruchom",
1011    "usun",
1012    "usuń",
1013    "następny krok",
1014    "nastepny krok",
1015    "kolejny krok",
1016    // English
1017    "i want",
1018    "i'd like",
1019    "let's",
1020    "next step",
1021];
1022
1023const RESULT_KEYWORDS: &[&str] = &[
1024    "smoke test",
1025    "passed",
1026    "all checks passed",
1027    "0 failed",
1028    "completed",
1029    "done",
1030    "zrobione",
1031    "dowiezione",
1032    "gotowe",
1033    "dziala",
1034    "działa",
1035];
1036
1037fn extract_signals(entries: &[&TimelineEntry]) -> ChunkSignals {
1038    let (todo_open, todo_done) = extract_checklist_items(entries);
1039    let ultrathink = extract_tag_blocks(entries, is_ultrathink_tag, MAX_ULTRATHINK_BLOCKS);
1040    let insights = extract_tag_blocks(entries, is_insight_tag, MAX_INSIGHT_BLOCKS);
1041    let plan_mode = extract_tag_blocks(entries, is_plan_mode_tag, MAX_PLAN_MODE_EVENTS);
1042    let intents = extract_intent_lines(entries);
1043    let results = extract_result_lines(entries);
1044    let skills = extract_tag_blocks(entries, is_skill_tag, 4);
1045    let decisions = extract_tag_blocks(entries, is_decision_tag, 4);
1046    let outcomes = extract_tag_blocks(entries, is_outcome_tag, 4);
1047
1048    ChunkSignals {
1049        todo_open,
1050        todo_done,
1051        ultrathink,
1052        insights,
1053        plan_mode,
1054        intents,
1055        results,
1056        skills,
1057        decisions,
1058        outcomes,
1059    }
1060}
1061
1062fn extract_checklist_items(entries: &[&TimelineEntry]) -> (Vec<SignalItem>, Vec<SignalItem>) {
1063    #[derive(Debug, Clone, Copy)]
1064    enum TaskState {
1065        Open,
1066        Done,
1067    }
1068
1069    let mut state_by_key: HashMap<String, TaskState> = HashMap::new();
1070    let mut display_by_key: HashMap<String, String> = HashMap::new();
1071    // Provenance of the first sighting; the displayed task text comes from
1072    // there too, so the span matches what the record actually shows.
1073    let mut span_by_key: HashMap<String, Option<(u64, u64)>> = HashMap::new();
1074    let mut order: Vec<String> = Vec::new();
1075
1076    for entry in entries {
1077        // Track fenced code blocks per entry. Checklist-looking lines inside
1078        // ``` fences (pasted markdown snippets, code samples documenting
1079        // checklist syntax) must not be promoted to actual tasks.
1080        let mut in_fence = false;
1081        for line in entry.message.lines() {
1082            if line.trim_start().starts_with("```") {
1083                in_fence = !in_fence;
1084                continue;
1085            }
1086            if in_fence {
1087                continue;
1088            }
1089            if let Some((is_done, task)) = parse_checklist_task(line) {
1090                let key = normalize_key(&task);
1091                if !state_by_key.contains_key(&key) {
1092                    order.push(key.clone());
1093                    display_by_key.insert(key.clone(), task);
1094                    span_by_key.insert(key.clone(), entry.source_line_span);
1095                    state_by_key.insert(key.clone(), TaskState::Open);
1096                }
1097
1098                // Once a task is marked done anywhere, keep it done.
1099                if is_done {
1100                    state_by_key.insert(key, TaskState::Done);
1101                }
1102            }
1103        }
1104    }
1105
1106    let mut open = Vec::new();
1107    let mut done = Vec::new();
1108    for key in order {
1109        let Some(task) = display_by_key.get(&key) else {
1110            continue;
1111        };
1112        let span = span_by_key.get(&key).copied().flatten();
1113        match state_by_key.get(&key) {
1114            Some(TaskState::Done) => done.push(SignalItem::new(task.clone(), span)),
1115            Some(TaskState::Open) => open.push(SignalItem::new(task.clone(), span)),
1116            None => {}
1117        }
1118    }
1119
1120    (open, done)
1121}
1122
1123pub fn parse_checklist_task(line: &str) -> Option<(bool, String)> {
1124    let l = line.trim_start();
1125    let mut chars = l.chars();
1126    let bullet = chars.next()?;
1127    if !matches!(bullet, '-' | '*' | '+') {
1128        return None;
1129    }
1130    let rest = chars.as_str().trim_start();
1131    let rest = rest.strip_prefix('[')?;
1132    let mut chars = rest.chars();
1133    let state = chars.next()?;
1134    let rest = chars.as_str();
1135    let rest = rest.strip_prefix(']')?;
1136    let task = rest.trim_start();
1137    if task.is_empty() {
1138        return None;
1139    }
1140
1141    match state {
1142        'x' | 'X' => Some((true, task.trim().to_string())),
1143        ' ' => Some((false, task.trim().to_string())),
1144        _ => None,
1145    }
1146}
1147
1148fn extract_intent_lines(entries: &[&TimelineEntry]) -> Vec<SignalItem> {
1149    let mut out = Vec::new();
1150    let mut seen = HashSet::new();
1151
1152    for entry in entries {
1153        if entry.role.to_lowercase() != "user" {
1154            continue;
1155        }
1156        if is_local_command_artifact_entry(entry) {
1157            continue;
1158        }
1159        for line in entry.message.lines().map(str::trim) {
1160            if line.is_empty() {
1161                continue;
1162            }
1163            if is_source_metadata_line(line) {
1164                continue;
1165            }
1166            if is_local_command_artifact_line(line) {
1167                continue;
1168            }
1169            if !is_intent_line(line) {
1170                continue;
1171            }
1172
1173            let key = normalize_key(line);
1174            if !seen.insert(key) {
1175                continue;
1176            }
1177
1178            out.push(SignalItem::new(
1179                truncate_signal_line(line),
1180                entry.source_line_span,
1181            ));
1182            if out.len() >= MAX_INTENT_LINES {
1183                return out;
1184            }
1185        }
1186    }
1187
1188    out
1189}
1190
1191fn is_local_command_artifact_entry(entry: &TimelineEntry) -> bool {
1192    entry
1193        .message
1194        .lines()
1195        .take(3)
1196        .any(is_local_command_artifact_line)
1197}
1198
1199pub fn is_local_command_artifact_line(line: &str) -> bool {
1200    let trimmed = strip_signal_bullet(line).trim();
1201    let lower = trimmed.to_ascii_lowercase();
1202    if lower.starts_with("<local-command-caveat")
1203        || lower.starts_with("</local-command-caveat")
1204        || lower.starts_with("<bash-stdout")
1205        || lower.starts_with("</bash-stdout")
1206        || lower.starts_with("<bash-stderr")
1207        || lower.starts_with("</bash-stderr")
1208    {
1209        return true;
1210    }
1211
1212    let shell_line = trimmed.trim_start_matches(['*', '>', '<']).trim_start();
1213    let shell_lower = shell_line.to_ascii_lowercase();
1214    shell_lower.starts_with("issuer:")
1215        || shell_lower.starts_with("subject:")
1216        || shell_lower.starts_with("subjectaltname:")
1217        || shell_lower.starts_with("ssl certificate")
1218        || shell_lower.starts_with("alpn:")
1219        || shell_lower.starts_with("http/")
1220        || shell_lower.starts_with("server:")
1221        || shell_lower.starts_with("content-type:")
1222        || shell_lower.starts_with("x-ratelimit-")
1223        || shell_lower.starts_with("x-request-id:")
1224        || shell_lower.starts_with("strict-transport-security:")
1225        || shell_lower.starts_with("content-security-policy:")
1226        || shell_lower.starts_with("referrer-policy:")
1227        || shell_lower.starts_with("permissions-policy:")
1228}
1229
1230fn strip_signal_bullet(line: &str) -> &str {
1231    let trimmed = line.trim_start();
1232    if let Some(rest) = trimmed.strip_prefix("- ") {
1233        return rest.trim_start();
1234    }
1235    if let Some(rest) = trimmed.strip_prefix("* ") {
1236        return rest.trim_start();
1237    }
1238    trimmed
1239}
1240
1241pub(crate) fn is_intent_line(line: &str) -> bool {
1242    let lower = line.to_lowercase();
1243    lower.starts_with("intent:")
1244        || lower.starts_with("[intent]")
1245        || severity_marker(line).is_some()
1246        || INTENT_KEYWORDS.iter().any(|kw| lower.contains(kw))
1247}
1248
1249fn severity_marker(line: &str) -> Option<&'static str> {
1250    let upper = line.to_ascii_uppercase();
1251    let has_marker = |marker: &str| {
1252        upper
1253            .split(|ch: char| !ch.is_ascii_alphanumeric())
1254            .any(|token| token == marker)
1255    };
1256    ["P0", "P1", "P2"]
1257        .into_iter()
1258        .find(|marker| has_marker(marker))
1259}
1260
1261fn is_source_metadata_line(line: &str) -> bool {
1262    let lower = line.to_ascii_lowercase();
1263    [
1264        "source:",
1265        "kind:",
1266        "source_file:",
1267        "severity:",
1268        "project:",
1269        "author:",
1270        "heading:",
1271        "input:",
1272        "output:",
1273        "\"output\":",
1274        "current topic:",
1275        "topic summary:",
1276        "successfully created and wrote to new file:",
1277        "base directory for this skill:",
1278    ]
1279    .iter()
1280    .any(|prefix| lower.starts_with(prefix))
1281}
1282
1283fn extract_result_lines(entries: &[&TimelineEntry]) -> Vec<SignalItem> {
1284    let mut out = Vec::new();
1285    let mut seen = HashSet::new();
1286
1287    for entry in entries {
1288        for line in entry.message.lines().map(str::trim) {
1289            if line.is_empty() {
1290                continue;
1291            }
1292            if !is_result_line(line) {
1293                continue;
1294            }
1295            let key = normalize_key(line);
1296            if !seen.insert(key) {
1297                continue;
1298            }
1299            out.push(SignalItem::new(
1300                truncate_signal_line(line),
1301                entry.source_line_span,
1302            ));
1303            if out.len() >= MAX_RESULT_LINES {
1304                return out;
1305            }
1306        }
1307    }
1308
1309    out
1310}
1311
1312pub fn is_result_line(line: &str) -> bool {
1313    let lower = line.to_lowercase();
1314    RESULT_KEYWORDS.iter().any(|kw| lower.contains(kw))
1315}
1316
1317pub fn normalize_key(s: &str) -> String {
1318    // Strip invisible characters that would otherwise let "fix au\u{200B}th"
1319    // bypass dedup of "fix auth". Covers zero-width family
1320    // (U+200B/200C/200D/FEFF) and bidi controls (U+202A-202E/2066-2069) that
1321    // can be pasted from chat clients or copy-paste from PDFs.
1322    let cleaned: String = s
1323        .chars()
1324        .filter(|ch| !is_invisible_normalize_char(*ch))
1325        .collect();
1326    cleaned
1327        .split_whitespace()
1328        .collect::<Vec<_>>()
1329        .join(" ")
1330        .to_lowercase()
1331}
1332
1333fn is_invisible_normalize_char(ch: char) -> bool {
1334    matches!(
1335        ch,
1336        '\u{200B}'
1337            | '\u{200C}'
1338            | '\u{200D}'
1339            | '\u{FEFF}'
1340            | '\u{202A}'
1341            | '\u{202B}'
1342            | '\u{202C}'
1343            | '\u{202D}'
1344            | '\u{202E}'
1345            | '\u{2066}'
1346            | '\u{2067}'
1347            | '\u{2068}'
1348            | '\u{2069}'
1349    )
1350}
1351
1352pub fn truncate_signal_line(line: &str) -> String {
1353    const MAX_BYTES: usize = 240;
1354    if line.len() <= MAX_BYTES {
1355        return line.to_string();
1356    }
1357    truncate_message_bytes(line, MAX_BYTES)
1358}
1359
1360fn is_ultrathink_tag(line: &str) -> bool {
1361    line.to_lowercase().contains("ultrathink")
1362}
1363
1364fn is_insight_tag(line: &str) -> bool {
1365    let lower = line.to_lowercase();
1366    // Prefer common "tag" forms like "Insight:" / "★ Insight" / "Insight ─".
1367    lower.starts_with("insight")
1368        || lower.contains("★ insight")
1369        || lower.contains("insight ─")
1370        || lower.contains("insight -")
1371}
1372
1373fn is_plan_mode_tag(line: &str) -> bool {
1374    let lower = line.to_lowercase();
1375    // Capture Plan Mode session transitions + explicit accept/approval actions.
1376    lower.contains("plan mode")
1377        || lower.contains("accept plan")
1378        || lower.contains("user accepted the plan")
1379        || lower.contains("approve and bypass permissions")
1380        || lower.contains("bypass permissions")
1381}
1382
1383fn is_skill_tag(line: &str) -> bool {
1384    let lower = line.to_lowercase();
1385    lower.contains("[skill_enter]")
1386        || lower.contains("vetcoders-partner")
1387        || lower.contains("vetcoders-spawn")
1388        || lower.contains("vetcoders-ownership")
1389        || lower.contains("vetcoders-workflow")
1390}
1391
1392pub fn is_decision_tag(line: &str) -> bool {
1393    let lower = line.to_lowercase();
1394    lower.contains("[decision]") || lower.starts_with("decision:")
1395}
1396
1397pub fn is_outcome_tag(line: &str) -> bool {
1398    let lower = line.to_lowercase();
1399    lower.contains("[skill_outcome]")
1400        || lower.starts_with("outcome:")
1401        || lower.starts_with("validation:")
1402}
1403
1404fn extract_tag_blocks(
1405    entries: &[&TimelineEntry],
1406    is_tag: fn(&str) -> bool,
1407    max_blocks: usize,
1408) -> Vec<SignalItem> {
1409    let mut out = Vec::new();
1410    let mut seen = HashSet::new();
1411
1412    for entry in entries {
1413        let lines: Vec<&str> = entry.message.lines().collect();
1414        for (i, raw) in lines.iter().enumerate() {
1415            let line = raw.trim();
1416            if line.is_empty() || !is_tag(line) {
1417                continue;
1418            }
1419
1420            let mut block = Vec::new();
1421            block.push(line);
1422
1423            for raw_next in lines.iter().skip(i + 1) {
1424                let next = raw_next.trim();
1425                if next.is_empty() {
1426                    break;
1427                }
1428                if is_tag(next) {
1429                    break;
1430                }
1431                block.push(next);
1432                if block.len() >= MAX_TAG_BLOCK_LINES {
1433                    break;
1434                }
1435            }
1436
1437            let joined = block.join(" ");
1438            let key = normalize_key(&joined);
1439            if !seen.insert(key) {
1440                continue;
1441            }
1442
1443            out.push(SignalItem::new(
1444                truncate_signal_line(&joined),
1445                entry.source_line_span,
1446            ));
1447            if out.len() >= max_blocks {
1448                return out;
1449            }
1450        }
1451    }
1452
1453    out
1454}
1455
1456/// Render the `[signals]` block as a deterministic projection of typed
1457/// [`CardSignal`] records. The records are the primary artifact (persisted in
1458/// the sidecar); this text form exists for humans and the intents pipeline
1459/// that still parses cards from text. Display caps (`MAX_TODO_ITEMS`) apply
1460/// here only — the record set always carries the full extraction.
1461fn format_signals_block(records: &[CardSignal]) -> Option<String> {
1462    if records.is_empty() {
1463        return None;
1464    }
1465
1466    let texts_of = |kind: &str| -> Vec<&str> {
1467        records
1468            .iter()
1469            .filter(|record| record.kind == kind)
1470            .map(|record| record.text.as_str())
1471            .collect()
1472    };
1473
1474    let skills = texts_of(SIGNAL_KIND_SKILL);
1475    let todo_open = texts_of(SIGNAL_KIND_TODO_OPEN);
1476    let todo_done = texts_of(SIGNAL_KIND_TODO_DONE);
1477    let ultrathink = texts_of(SIGNAL_KIND_ULTRATHINK);
1478    let insights = texts_of(SIGNAL_KIND_INSIGHT);
1479    let plan_mode = texts_of(SIGNAL_KIND_PLAN_MODE);
1480    let intents = texts_of(SIGNAL_KIND_INTENT);
1481    let decisions = texts_of(SIGNAL_KIND_DECISION);
1482    let results = texts_of(SIGNAL_KIND_RESULT);
1483    let outcomes = texts_of(SIGNAL_KIND_OUTCOME);
1484    let highlights = texts_of(SIGNAL_KIND_HIGHLIGHT);
1485
1486    let mut out = String::new();
1487    out.push_str("[signals]\n");
1488
1489    if !skills.is_empty() {
1490        out.push_str("=== SKILL ENTER ===\n");
1491        for line in &skills {
1492            out.push_str(&format!("{}\n", line));
1493        }
1494        out.push_str("===================\n");
1495    }
1496
1497    if !todo_open.is_empty() || !todo_done.is_empty() {
1498        if !todo_open.is_empty() {
1499            out.push_str(&format!(
1500                "RED LIGHT: checklist detected (open: {}, done: {})\n",
1501                todo_open.len(),
1502                todo_done.len()
1503            ));
1504        } else {
1505            out.push_str(&format!(
1506                "Checklist detected (open: 0, done: {})\n",
1507                todo_done.len()
1508            ));
1509        }
1510
1511        for task in todo_open.iter().take(MAX_TODO_ITEMS) {
1512            out.push_str(&format!("- [ ] {}\n", task));
1513        }
1514        if todo_open.len() > MAX_TODO_ITEMS {
1515            out.push_str(&format!(
1516                "... (+{} more open)\n",
1517                todo_open.len() - MAX_TODO_ITEMS
1518            ));
1519        }
1520
1521        for task in todo_done.iter().take(MAX_TODO_ITEMS) {
1522            out.push_str(&format!("- [x] {}\n", task));
1523        }
1524        if todo_done.len() > MAX_TODO_ITEMS {
1525            out.push_str(&format!(
1526                "... (+{} more done)\n",
1527                todo_done.len() - MAX_TODO_ITEMS
1528            ));
1529        }
1530    }
1531
1532    if !ultrathink.is_empty() {
1533        out.push_str("Ultrathink:\n");
1534        for line in &ultrathink {
1535            out.push_str(&format!("- {}\n", line));
1536        }
1537    }
1538
1539    if !insights.is_empty() {
1540        out.push_str("Insight:\n");
1541        for line in &insights {
1542            out.push_str(&format!("- {}\n", line));
1543        }
1544    }
1545
1546    if !plan_mode.is_empty() {
1547        out.push_str("Plan mode:\n");
1548        for line in &plan_mode {
1549            out.push_str(&format!("- {}\n", line));
1550        }
1551    }
1552
1553    if !intents.is_empty() {
1554        out.push_str("Intent:\n");
1555        for line in &intents {
1556            out.push_str(&format!("- {}\n", line));
1557        }
1558    }
1559
1560    if !decisions.is_empty() {
1561        out.push_str("Decision:\n");
1562        for line in &decisions {
1563            out.push_str(&format!("- {}\n", line));
1564        }
1565    }
1566
1567    if !results.is_empty() {
1568        out.push_str("Results:\n");
1569        for line in &results {
1570            out.push_str(&format!("- {}\n", line));
1571        }
1572    }
1573
1574    if !outcomes.is_empty() {
1575        out.push_str("Outcome:\n");
1576        for line in &outcomes {
1577            out.push_str(&format!("- {}\n", line));
1578        }
1579    }
1580
1581    if !highlights.is_empty() {
1582        out.push_str("Notes:\n");
1583        for line in &highlights {
1584            out.push_str(&format!("- {}\n", truncate_signal_line(line)));
1585        }
1586    }
1587
1588    out.push_str("[/signals]\n");
1589    Some(out)
1590}
1591
1592fn truncate_message_bytes(message: &str, max_bytes: usize) -> String {
1593    let mut cutoff = max_bytes.min(message.len());
1594    while cutoff > 0 && !message.is_char_boundary(cutoff) {
1595        cutoff -= 1;
1596    }
1597    let mut out = String::with_capacity(cutoff + 15);
1598    out.push_str(&message[..cutoff]);
1599    out.push_str("...[truncated]");
1600    out
1601}
1602
1603// ============================================================================
1604// File output
1605// ============================================================================
1606
1607/// Write chunks as individual .txt files to a directory.
1608///
1609/// Each file is named `{chunk.id}.txt`. Returns paths of written files.
1610pub fn write_chunks_to_dir(chunks: &[Chunk], dir: &Path) -> Result<Vec<PathBuf>> {
1611    fs::create_dir_all(dir)?;
1612
1613    let mut paths = Vec::new();
1614
1615    for chunk in chunks {
1616        let filename = format!("{}.txt", chunk.id);
1617        let path = dir.join(&filename);
1618        fs::write(&path, &chunk.text)?;
1619        let sidecar_path = dir.join(format!("{}.meta.json", chunk.id));
1620        let sidecar = ChunkMetadataSidecar::from(chunk);
1621        fs::write(&sidecar_path, serde_json::to_vec_pretty(&sidecar)?)?;
1622        paths.push(path);
1623    }
1624
1625    Ok(paths)
1626}
1627
1628/// Summary of chunking results.
1629pub fn chunk_summary(chunks: &[Chunk]) -> String {
1630    if chunks.is_empty() {
1631        return "No chunks generated.".to_string();
1632    }
1633
1634    let total_tokens: usize = chunks.iter().map(|c| c.token_estimate).sum();
1635    let avg_tokens = total_tokens / chunks.len();
1636    let dates: Vec<&str> = chunks
1637        .iter()
1638        .map(|c| c.date.as_str())
1639        .collect::<std::collections::HashSet<_>>()
1640        .into_iter()
1641        .collect();
1642
1643    format!(
1644        "{} chunks, {} total tokens (avg {}), {} days",
1645        chunks.len(),
1646        total_tokens,
1647        avg_tokens,
1648        dates.len(),
1649    )
1650}
1651
1652// ============================================================================
1653// Tests
1654// ============================================================================
1655
1656#[cfg(test)]
1657mod tests {
1658    use super::*;
1659    use chrono::{TimeZone, Utc};
1660
1661    fn make_entry(hour: u32, min: u32, role: &str, msg: &str) -> TimelineEntry {
1662        TimelineEntry {
1663            timestamp: Utc.with_ymd_and_hms(2026, 1, 22, hour, min, 0).unwrap(),
1664            agent: "claude".to_string(),
1665            session_id: "sess-1".to_string(),
1666            role: role.to_string(),
1667            message: msg.to_string(),
1668            frame_kind: None,
1669            branch: None,
1670            cwd: None,
1671            timestamp_source: None,
1672            source_path: None,
1673            source_sha256: None,
1674            source_line_span: None,
1675        }
1676    }
1677
1678    fn legacy_format_chunk_text_for_body_compare(
1679        entries: &[&TimelineEntry],
1680        project: &str,
1681        agent: &str,
1682        date: &str,
1683        frame_kind: Option<FrameKind>,
1684        signal_records: &[CardSignal],
1685    ) -> String {
1686        let mut text = if let Some(frame_kind) = frame_kind {
1687            format!(
1688                "[project: {} | agent: {} | date: {} | frame_kind: {}]\n\n",
1689                project, agent, date, frame_kind
1690            )
1691        } else {
1692            format!(
1693                "[project: {} | agent: {} | date: {}]\n\n",
1694                project, agent, date
1695            )
1696        };
1697
1698        if let Some(block) = format_signals_block(signal_records) {
1699            text.push_str(&block);
1700            text.push('\n');
1701        }
1702
1703        for entry in entries {
1704            if entry.message.is_empty() {
1705                continue;
1706            }
1707            let time = entry.timestamp.format("%H:%M:%S");
1708            let msg = if entry.message.len() > 4000 {
1709                truncate_message_bytes(&entry.message, 4000)
1710            } else {
1711                entry.message.clone()
1712            };
1713            text.push_str(&format!("[{}] {}: {}\n", time, entry.role, msg));
1714        }
1715
1716        text
1717    }
1718
1719    fn body_after_card_header(text: &str) -> &str {
1720        if let Some(rest) = text.strip_prefix("---\n")
1721            && let Some((_, body)) = rest.split_once("\n---\n\n")
1722        {
1723            return body;
1724        }
1725        text.split_once("\n\n").map(|(_, body)| body).unwrap_or("")
1726    }
1727
1728    #[test]
1729    fn test_estimate_tokens() {
1730        assert_eq!(estimate_tokens(""), 0);
1731        assert_eq!(estimate_tokens("hi"), 1); // 2 chars → ceil(2/4) = 1
1732        assert_eq!(estimate_tokens("hello world"), 3); // 11 chars → ceil(11/4) = 3
1733        assert_eq!(estimate_tokens("1234"), 1); // exactly 4 chars = 1 token
1734        assert_eq!(estimate_tokens("12345"), 2); // 5 chars → 2 tokens
1735    }
1736
1737    #[test]
1738    fn test_chunk_entries_empty() {
1739        let config = ChunkerConfig::default();
1740        let chunks = chunk_entries(&[], "proj", "claude", &config);
1741        assert!(chunks.is_empty());
1742    }
1743
1744    #[test]
1745    fn test_chunk_entries_single_message() {
1746        let entries = vec![make_entry(14, 0, "user", "short message")];
1747        let config = ChunkerConfig::default();
1748        let chunks = chunk_entries(&entries, "proj", "claude", &config);
1749
1750        assert_eq!(chunks.len(), 1);
1751        assert_eq!(chunks[0].project, "proj");
1752        assert_eq!(chunks[0].agent, "claude");
1753        assert_eq!(chunks[0].date, "2026-01-22");
1754        assert!(chunks[0].text.contains("short message"));
1755    }
1756
1757    #[test]
1758    fn test_chunk_entries_basic() {
1759        // Create 10 entries with ~200 chars each → ~500 tokens total
1760        // With target=150 tokens, should get multiple chunks
1761        let entries: Vec<TimelineEntry> = (0..10)
1762            .map(|i| make_entry(14, i as u32, "user", &"x".repeat(200)))
1763            .collect();
1764
1765        let config = ChunkerConfig {
1766            target_tokens: 150,
1767            min_tokens: 50,
1768            max_tokens: 300,
1769            overlap_messages: 2,
1770            noise_filter_enabled: true,
1771        };
1772
1773        let chunks = chunk_entries(&entries, "proj", "claude", &config);
1774        assert!(
1775            chunks.len() > 1,
1776            "Expected multiple chunks, got {}",
1777            chunks.len()
1778        );
1779
1780        // Verify sequential IDs
1781        for (i, chunk) in chunks.iter().enumerate() {
1782            assert!(chunk.id.contains(&format!("{:03}", i + 1)));
1783        }
1784    }
1785
1786    #[test]
1787    fn test_chunk_entries_respects_max_tokens() {
1788        // One very long message
1789        let entries = vec![make_entry(14, 0, "user", &"x".repeat(20000))];
1790        let config = ChunkerConfig {
1791            target_tokens: 1500,
1792            min_tokens: 500,
1793            max_tokens: 2500,
1794            overlap_messages: 2,
1795            noise_filter_enabled: true,
1796        };
1797
1798        let chunks = chunk_entries(&entries, "proj", "claude", &config);
1799        // Single long message can't be split within chunker (it's per-message)
1800        // but format_chunk_text truncates at 4000 bytes
1801        assert_eq!(chunks.len(), 1);
1802        assert!(chunks[0].text.contains("[truncated]"));
1803    }
1804
1805    #[test]
1806    fn test_chunk_entries_groups_by_date() {
1807        let entries = vec![
1808            TimelineEntry {
1809                timestamp: Utc.with_ymd_and_hms(2026, 1, 20, 10, 0, 0).unwrap(),
1810                agent: "claude".to_string(),
1811                session_id: "s1".to_string(),
1812                role: "user".to_string(),
1813                message: "day one".to_string(),
1814                frame_kind: None,
1815                branch: None,
1816                cwd: None,
1817                timestamp_source: None,
1818                source_path: None,
1819                source_sha256: None,
1820                source_line_span: None,
1821            },
1822            TimelineEntry {
1823                timestamp: Utc.with_ymd_and_hms(2026, 1, 21, 10, 0, 0).unwrap(),
1824                agent: "claude".to_string(),
1825                session_id: "s2".to_string(),
1826                role: "user".to_string(),
1827                message: "day two".to_string(),
1828                frame_kind: None,
1829                branch: None,
1830                cwd: None,
1831                timestamp_source: None,
1832                source_path: None,
1833                source_sha256: None,
1834                source_line_span: None,
1835            },
1836        ];
1837
1838        let config = ChunkerConfig::default();
1839        let chunks = chunk_entries(&entries, "proj", "claude", &config);
1840
1841        assert_eq!(chunks.len(), 2);
1842        assert_eq!(chunks[0].date, "2026-01-20");
1843        assert_eq!(chunks[1].date, "2026-01-21");
1844    }
1845
1846    #[test]
1847    fn test_format_chunk_text() {
1848        let entries = [
1849            make_entry(14, 30, "user", "hello"),
1850            make_entry(14, 31, "assistant", "hi there"),
1851        ];
1852        let refs: Vec<&TimelineEntry> = entries.iter().collect();
1853
1854        let text = format_chunk_text(&refs, "TestProj", "claude", "2026-01-22");
1855
1856        assert!(text.starts_with(
1857            "---\nproject: testproj\nagent: claude\ndate: 2026-01-22\nschema: card.v2\n---\n\n"
1858        ));
1859        assert!(text.contains("[14:30:00] user: hello"));
1860        assert!(text.contains("[14:31:00] assistant: hi there"));
1861    }
1862
1863    #[test]
1864    fn test_format_chunk_text_emits_card_v2_frontmatter_with_frame_kind() {
1865        let mut entry = make_entry(14, 30, "user", "hello");
1866        entry.frame_kind = Some(FrameKind::UserMsg);
1867        let entries = [entry];
1868        let refs: Vec<&TimelineEntry> = entries.iter().collect();
1869
1870        let text = format_chunk_text(&refs, "Loctree/AICX", "claude", "2026-01-22");
1871
1872        assert!(text.starts_with("---\n"));
1873        assert!(text.contains("project: loctree/aicx\n"));
1874        assert!(text.contains("agent: claude\n"));
1875        assert!(text.contains("date: 2026-01-22\n"));
1876        assert!(text.contains("frame_kind: user_msg\n"));
1877        assert!(text.contains("schema: card.v2\n---\n\n"));
1878        assert!(!text.starts_with("[project:"));
1879    }
1880
1881    #[test]
1882    fn test_card_v2_body_bytes_match_legacy_writer_after_header() {
1883        let mut entries = [
1884            make_entry(14, 30, "user", "todo: keep the body stable"),
1885            make_entry(14, 31, "assistant", "done"),
1886        ];
1887        entries[0].frame_kind = Some(FrameKind::UserMsg);
1888        entries[1].frame_kind = Some(FrameKind::UserMsg);
1889        let refs: Vec<&TimelineEntry> = entries.iter().collect();
1890        let config = ChunkerConfig::default();
1891        let (sanitized_owned, _dropped) = sanitize_window(&refs, &config);
1892        let sanitized_refs: Vec<&TimelineEntry> = sanitized_owned.iter().collect();
1893        let highlights = extract_highlights(&sanitized_refs);
1894        let signals = extract_signals(&sanitized_refs);
1895        let records = signal_records(&signals, &highlights);
1896
1897        let v2 = format_chunk_text_inner(
1898            &sanitized_refs,
1899            "proj",
1900            "claude",
1901            "2026-01-22",
1902            Some(FrameKind::UserMsg),
1903            &records,
1904        );
1905        let legacy = legacy_format_chunk_text_for_body_compare(
1906            &sanitized_refs,
1907            "proj",
1908            "claude",
1909            "2026-01-22",
1910            Some(FrameKind::UserMsg),
1911            &records,
1912        );
1913
1914        assert_eq!(body_after_card_header(&v2), body_after_card_header(&legacy));
1915    }
1916
1917    #[test]
1918    fn test_format_chunk_text_truncates_utf8_safely() {
1919        let mut msg = "a".repeat(3999);
1920        msg.push('é'); // 2-byte char forces non-boundary at 4000
1921        let entries = [make_entry(14, 30, "user", &msg)];
1922        let refs: Vec<&TimelineEntry> = entries.iter().collect();
1923
1924        let text = format_chunk_text(&refs, "TestProj", "claude", "2026-01-22");
1925
1926        assert!(text.contains("[truncated]"));
1927        assert!(!text.contains('é'));
1928    }
1929
1930    #[test]
1931    fn test_chunk_entries_extracts_frontmatter_telemetry() {
1932        let entries = vec![make_entry(
1933            14,
1934            30,
1935            "assistant",
1936            "---\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",
1937        )];
1938
1939        let chunks = chunk_entries(&entries, "proj", "claude", &ChunkerConfig::default());
1940        assert_eq!(chunks.len(), 1);
1941
1942        let chunk = &chunks[0];
1943        assert_eq!(chunk.run_id.as_deref(), Some("mrbl-001"));
1944        assert_eq!(chunk.prompt_id.as_deref(), Some("api-redesign_20260327"));
1945        assert_eq!(chunk.agent_model.as_deref(), Some("gpt-5.4"));
1946        assert_eq!(chunk.started_at.as_deref(), Some("2026-03-27T10:00:00Z"));
1947        assert_eq!(chunk.completed_at.as_deref(), Some("2026-03-27T10:01:00Z"));
1948        assert_eq!(chunk.token_usage, Some(1234));
1949        assert_eq!(chunk.findings_count, Some(4));
1950        assert_eq!(chunk.frame_kind, Some(FrameKind::AgentReply));
1951        assert_eq!(chunk.workflow_phase.as_deref(), Some("implement"));
1952        assert_eq!(chunk.mode.as_deref(), Some("session-first"));
1953        assert_eq!(chunk.skill_code.as_deref(), Some("vc-workflow"));
1954        assert_eq!(chunk.framework_version.as_deref(), Some("2026-03"));
1955        assert!(chunk.text.contains("## Report"));
1956        assert!(!chunk.text.contains("run_id: mrbl-001"));
1957        assert!(!chunk.text.contains("phase: implement"));
1958    }
1959
1960    #[test]
1961    fn test_chunk_entries_extracts_foreign_import_provenance() {
1962        // Round II / oś 3+5 cut 1: foreign-import provenance carried structurally
1963        // from frontmatter through Chunk to the sidecar (not left as body text).
1964        let entries = vec![make_entry(
1965            14,
1966            30,
1967            "user",
1968            "---\nsource_file: Downloads/ChatGPT-export.md\nsource_format: chatgpt-markdown\nimport_id: blake3:abc123\nframe_kind: user_msg\n---\n## Prompt\nzbadaj temat",
1969        )];
1970
1971        let chunks = chunk_entries(&entries, "proj", "operator", &ChunkerConfig::default());
1972        assert_eq!(chunks.len(), 1);
1973        let chunk = &chunks[0];
1974
1975        assert_eq!(
1976            chunk.source_file.as_deref(),
1977            Some("Downloads/ChatGPT-export.md")
1978        );
1979        assert_eq!(chunk.source_format.as_deref(), Some("chatgpt-markdown"));
1980        assert_eq!(chunk.import_id.as_deref(), Some("blake3:abc123"));
1981        // provenance is stripped from the chunk body (lives in metadata now)
1982        assert!(!chunk.text.contains("import_id: blake3:abc123"));
1983
1984        // and it flows into the structural sidecar
1985        let sidecar = ChunkMetadataSidecar::from(chunk);
1986        assert_eq!(
1987            sidecar.source_file.as_deref(),
1988            Some("Downloads/ChatGPT-export.md")
1989        );
1990        assert_eq!(sidecar.source_format.as_deref(), Some("chatgpt-markdown"));
1991        assert_eq!(sidecar.import_id.as_deref(), Some("blake3:abc123"));
1992    }
1993
1994    #[test]
1995    fn sidecar_from_chunk_defaults_to_card_v2_contract_fields() {
1996        let mut chunks = chunk_entries(
1997            &[make_entry(14, 30, "user", "hello")],
1998            "Loctree/AICX",
1999            "claude",
2000            &ChunkerConfig::default(),
2001        );
2002        assert_eq!(chunks.len(), 1);
2003        chunks[0].source = Some(CardSource {
2004            path: "/tmp/raw.jsonl".to_string(),
2005            sha256: Some("abc123".to_string()),
2006            span: Some((7, 9)),
2007        });
2008
2009        let sidecar = ChunkMetadataSidecar::from(&chunks[0]);
2010
2011        assert_eq!(sidecar.schema_version, CARD_SCHEMA_VERSION);
2012        assert_eq!(
2013            sidecar.claim_scope.as_deref(),
2014            Some(CARD_CLAIM_SCOPE_SESSION_CLOSE)
2015        );
2016        assert_eq!(
2017            sidecar.freshness_contract.as_deref(),
2018            Some(CARD_FRESHNESS_CONTRACT_HISTORICAL)
2019        );
2020        assert_eq!(
2021            sidecar.verification_state.as_deref(),
2022            Some(CARD_VERIFICATION_STATE_NOT_VERIFIED_BY_AICX)
2023        );
2024        assert_eq!(
2025            sidecar.source.as_ref().map(|source| source.path.as_str()),
2026            Some("/tmp/raw.jsonl")
2027        );
2028        assert_eq!(
2029            sidecar
2030                .source
2031                .as_ref()
2032                .and_then(|source| source.sha256.as_deref()),
2033            Some("abc123")
2034        );
2035        assert_eq!(
2036            sidecar.source.as_ref().and_then(|source| source.span),
2037            Some((7, 9))
2038        );
2039    }
2040
2041    #[test]
2042    fn test_chunk_entries_lifts_homogeneous_source_pointer_with_span() {
2043        let mut first = make_entry(14, 30, "user", "hello");
2044        first.source_path = Some("/tmp/raw.jsonl".to_string());
2045        first.source_sha256 = Some("abc123".to_string());
2046        first.source_line_span = Some((3, 3));
2047        let mut second = make_entry(14, 31, "assistant", "hi");
2048        second.source_path = Some("/tmp/raw.jsonl".to_string());
2049        second.source_sha256 = Some("abc123".to_string());
2050        second.source_line_span = Some((4, 4));
2051
2052        let chunks = chunk_entries(
2053            &[first, second],
2054            "proj",
2055            "claude",
2056            &ChunkerConfig::default(),
2057        );
2058
2059        assert_eq!(chunks.len(), 1);
2060        let source = chunks[0].source.as_ref().expect("source is lifted");
2061        assert_eq!(source.path, "/tmp/raw.jsonl");
2062        assert_eq!(source.sha256.as_deref(), Some("abc123"));
2063        assert_eq!(source.span, Some((3, 4)));
2064    }
2065
2066    #[test]
2067    fn test_chunk_entries_omits_source_pointer_for_mixed_raw_paths() {
2068        let mut first = make_entry(14, 30, "user", "hello");
2069        first.source_path = Some("/tmp/raw-a.jsonl".to_string());
2070        first.source_sha256 = Some("aaa".to_string());
2071        let mut second = make_entry(14, 31, "assistant", "hi");
2072        second.source_path = Some("/tmp/raw-b.jsonl".to_string());
2073        second.source_sha256 = Some("bbb".to_string());
2074
2075        let chunks = chunk_entries(
2076            &[first, second],
2077            "proj",
2078            "claude",
2079            &ChunkerConfig::default(),
2080        );
2081
2082        assert_eq!(chunks.len(), 1);
2083        assert_eq!(chunks[0].source, None);
2084    }
2085
2086    #[test]
2087    fn test_chunk_entries_skips_unsupported_frontmatter_values_without_dropping_metadata() {
2088        let entries = vec![make_entry(
2089            14,
2090            30,
2091            "assistant",
2092            "---\nrun_id: [nope\nmode: session-first\n---\n## Report\nBody survives",
2093        )];
2094
2095        let chunks = chunk_entries(&entries, "proj", "claude", &ChunkerConfig::default());
2096        assert_eq!(chunks.len(), 1);
2097
2098        let chunk = &chunks[0];
2099        assert_eq!(chunk.run_id, None);
2100        assert_eq!(chunk.mode.as_deref(), Some("session-first"));
2101        assert!(chunk.text.contains("## Report"));
2102        assert!(chunk.text.contains("Body survives"));
2103        assert!(!chunk.text.contains("mode: session-first"));
2104    }
2105
2106    #[test]
2107    fn test_write_chunks_to_dir() {
2108        let tmp = std::env::temp_dir().join("ai-ctx-chunker-test");
2109        let _ = fs::remove_dir_all(&tmp);
2110
2111        let chunks = vec![
2112            Chunk {
2113                id: "proj_claude_2026-01-22_001".to_string(),
2114                project: "proj".to_string(),
2115                agent: "claude".to_string(),
2116                date: "2026-01-22".to_string(),
2117                session_id: "s1".to_string(),
2118                cwd: Some("/Users/tester/workspaces/proj".to_string()),
2119                timestamp_source: None,
2120                kind: Kind::Conversations,
2121                frame_kind: Some(FrameKind::UserMsg),
2122                run_id: None,
2123                prompt_id: None,
2124                agent_model: None,
2125                started_at: None,
2126                completed_at: None,
2127                token_usage: None,
2128                findings_count: None,
2129                workflow_phase: Some("implement".to_string()),
2130                mode: Some("session-first".to_string()),
2131                skill_code: Some("vc-workflow".to_string()),
2132                framework_version: Some("2026-03".to_string()),
2133                source_file: None,
2134                source_format: None,
2135                import_id: None,
2136                source: None,
2137                msg_range: (0, 5),
2138                text: "chunk one content".to_string(),
2139                token_estimate: 4,
2140                highlights: vec![],
2141                signals: vec![],
2142                noise_lines_dropped: 0,
2143            },
2144            Chunk {
2145                id: "proj_claude_2026-01-22_002".to_string(),
2146                project: "proj".to_string(),
2147                agent: "claude".to_string(),
2148                date: "2026-01-22".to_string(),
2149                session_id: "s1".to_string(),
2150                cwd: None,
2151                timestamp_source: None,
2152                kind: Kind::Conversations,
2153                frame_kind: None,
2154                run_id: None,
2155                prompt_id: None,
2156                agent_model: None,
2157                started_at: None,
2158                completed_at: None,
2159                token_usage: None,
2160                findings_count: None,
2161                workflow_phase: None,
2162                mode: None,
2163                skill_code: None,
2164                framework_version: None,
2165                source_file: None,
2166                source_format: None,
2167                import_id: None,
2168                source: None,
2169                msg_range: (3, 8),
2170                text: "chunk two content".to_string(),
2171                token_estimate: 4,
2172                highlights: vec![],
2173                signals: vec![],
2174                noise_lines_dropped: 0,
2175            },
2176        ];
2177
2178        let paths = write_chunks_to_dir(&chunks, &tmp).unwrap();
2179        assert_eq!(paths.len(), 2);
2180        assert!(paths[0].exists());
2181        assert!(paths[1].exists());
2182
2183        let content = fs::read_to_string(&paths[0]).unwrap();
2184        assert_eq!(content, "chunk one content");
2185
2186        let sidecar = fs::read_to_string(tmp.join("proj_claude_2026-01-22_001.meta.json")).unwrap();
2187        let metadata: ChunkMetadataSidecar = serde_json::from_str(&sidecar).unwrap();
2188        assert_eq!(metadata.project, "proj");
2189        assert_eq!(metadata.agent, "claude");
2190        assert_eq!(metadata.date, "2026-01-22");
2191        assert_eq!(
2192            metadata.cwd.as_deref(),
2193            Some("/Users/tester/workspaces/proj")
2194        );
2195        assert_eq!(metadata.kind, Kind::Conversations);
2196        assert_eq!(metadata.frame_kind, Some(FrameKind::UserMsg));
2197        assert_eq!(metadata.workflow_phase.as_deref(), Some("implement"));
2198        assert_eq!(metadata.mode.as_deref(), Some("session-first"));
2199        assert_eq!(metadata.skill_code.as_deref(), Some("vc-workflow"));
2200        assert_eq!(metadata.framework_version.as_deref(), Some("2026-03"));
2201
2202        let legacy: ChunkMetadataSidecar = serde_json::from_value(serde_json::json!({
2203            "id": "legacy",
2204            "project": "proj",
2205            "agent": "claude",
2206            "date": "2026-01-22",
2207            "session_id": "s1",
2208            "kind": "conversations",
2209        }))
2210        .unwrap();
2211        assert_eq!(legacy.cwd, None);
2212        assert_eq!(legacy.frame_kind, None);
2213        assert_eq!(legacy.workflow_phase, None);
2214        assert_eq!(legacy.mode, None);
2215        assert_eq!(legacy.skill_code, None);
2216        assert_eq!(legacy.framework_version, None);
2217        assert_eq!(legacy.artifact_family, None);
2218        assert_eq!(legacy.schema_version, 1);
2219        assert_eq!(legacy.source, None);
2220        assert_eq!(legacy.claim_scope, None);
2221        assert_eq!(legacy.freshness_contract, None);
2222        assert_eq!(legacy.verification_state, None);
2223        assert_eq!(legacy.truth_status, None);
2224        assert_eq!(legacy.learning_use, None);
2225        assert_eq!(legacy.keywords, None);
2226        assert_eq!(legacy.content_sha256, None);
2227
2228        let _ = fs::remove_dir_all(&tmp);
2229    }
2230
2231    #[test]
2232    fn sidecar_deserializes_context_corpus_contract_fields() {
2233        let sidecar: ChunkMetadataSidecar = serde_json::from_value(serde_json::json!({
2234            "id": "ctx-001",
2235            "project": "vetcoders/aicx",
2236            "agent": "loct-context-pack",
2237            "date": "2026-05-08",
2238            "session_id": "batch-001",
2239            "kind": "reports",
2240            "artifact_family": "loct-context-pack",
2241            "schema_version": "context_corpus.v1",
2242            "truth_status": {
2243                "role": "example",
2244                "runtime_authoritative": false,
2245                "stale_against_current_head": true,
2246                "current_head_when_ingested": "269d13c"
2247            },
2248            "learning_use": {
2249                "allowed": ["retrieval-test"],
2250                "forbidden": ["live-truth"]
2251            },
2252            "keywords": ["prism", "context"],
2253            "content_sha256": "abc123"
2254        }))
2255        .unwrap();
2256
2257        assert_eq!(
2258            sidecar.artifact_family.as_deref(),
2259            Some("loct-context-pack")
2260        );
2261        assert_eq!(sidecar.schema_version, 1);
2262        assert_eq!(
2263            sidecar.truth_status.as_ref().map(|status| status.role),
2264            Some(TruthRole::Example)
2265        );
2266        assert_eq!(
2267            sidecar.keywords.as_deref(),
2268            Some(&["prism".to_string(), "context".to_string()][..])
2269        );
2270        assert_eq!(sidecar.content_sha256.as_deref(), Some("abc123"));
2271    }
2272
2273    #[test]
2274    fn test_overlap_messages() {
2275        // 8 entries with short messages (~22 tokens each incl. header)
2276        // target=80 → ~4 messages per window, overlap=2 → windows share 2 messages
2277        let entries: Vec<TimelineEntry> = (0..8)
2278            .map(|i| make_entry(14, i as u32, "user", &format!("msg_{}", i)))
2279            .collect();
2280
2281        let config = ChunkerConfig {
2282            target_tokens: 80,
2283            min_tokens: 20,
2284            max_tokens: 200,
2285            overlap_messages: 2,
2286            noise_filter_enabled: true,
2287        };
2288
2289        let chunks = chunk_entries(&entries, "p", "c", &config);
2290
2291        // With overlap=2, consecutive chunks should share messages
2292        if chunks.len() >= 2 {
2293            // Verify ranges overlap (overlap=2 means last 2 msgs of chunk N start chunk N+1)
2294            let (_, end1) = chunks[0].msg_range;
2295            let (start2, _) = chunks[1].msg_range;
2296            assert!(
2297                start2 < end1,
2298                "Expected overlap: chunk1 ends at {}, chunk2 starts at {}",
2299                end1,
2300                start2
2301            );
2302        }
2303    }
2304
2305    #[test]
2306    fn test_chunk_id_format() {
2307        let entries = vec![make_entry(10, 0, "user", "test")];
2308        let config = ChunkerConfig::default();
2309        let chunks = chunk_entries(&entries, "MyProject", "gemini", &config);
2310
2311        assert_eq!(chunks[0].id, "myproject_gemini_2026-01-22_001");
2312    }
2313
2314    #[test]
2315    fn test_chunk_summary() {
2316        let chunks = vec![
2317            Chunk {
2318                id: "a".to_string(),
2319                project: "p".to_string(),
2320                agent: "c".to_string(),
2321                date: "2026-01-20".to_string(),
2322                session_id: "s".to_string(),
2323                cwd: None,
2324                timestamp_source: None,
2325                kind: Kind::Conversations,
2326                frame_kind: None,
2327                run_id: None,
2328                prompt_id: None,
2329                agent_model: None,
2330                started_at: None,
2331                completed_at: None,
2332                token_usage: None,
2333                findings_count: None,
2334                workflow_phase: None,
2335                mode: None,
2336                skill_code: None,
2337                framework_version: None,
2338                source_file: None,
2339                source_format: None,
2340                import_id: None,
2341                source: None,
2342                msg_range: (0, 5),
2343                text: "x".repeat(100),
2344                token_estimate: 25,
2345                highlights: vec![],
2346                signals: vec![],
2347                noise_lines_dropped: 0,
2348            },
2349            Chunk {
2350                id: "b".to_string(),
2351                project: "p".to_string(),
2352                agent: "c".to_string(),
2353                date: "2026-01-21".to_string(),
2354                session_id: "s".to_string(),
2355                cwd: None,
2356                timestamp_source: None,
2357                kind: Kind::Conversations,
2358                frame_kind: None,
2359                run_id: None,
2360                prompt_id: None,
2361                agent_model: None,
2362                started_at: None,
2363                completed_at: None,
2364                token_usage: None,
2365                findings_count: None,
2366                workflow_phase: None,
2367                mode: None,
2368                skill_code: None,
2369                framework_version: None,
2370                source_file: None,
2371                source_format: None,
2372                import_id: None,
2373                source: None,
2374                msg_range: (5, 10),
2375                text: "y".repeat(200),
2376                token_estimate: 50,
2377                highlights: vec![],
2378                signals: vec![],
2379                noise_lines_dropped: 0,
2380            },
2381        ];
2382
2383        let summary = chunk_summary(&chunks);
2384        assert!(summary.contains("2 chunks"));
2385        assert!(summary.contains("75 total tokens"));
2386        assert!(summary.contains("2 days"));
2387    }
2388
2389    #[test]
2390    fn test_extract_highlights_filters_keywords() {
2391        let entries = [
2392            make_entry(10, 0, "user", "Decision: lock chunking heuristics"),
2393            make_entry(10, 1, "assistant", "Just chatting"),
2394            make_entry(10, 2, "user", "TODO: add summarization notes"),
2395            make_entry(10, 3, "user", "KEY architectural choice"),
2396        ];
2397        let refs: Vec<&TimelineEntry> = entries.iter().collect();
2398
2399        let highlights = extract_highlights(&refs);
2400        let texts: Vec<&str> = highlights.iter().map(|item| item.text.as_str()).collect();
2401        assert_eq!(
2402            texts,
2403            vec![
2404                "Decision: lock chunking heuristics",
2405                "TODO: add summarization notes",
2406                "KEY architectural choice"
2407            ]
2408        );
2409    }
2410
2411    #[test]
2412    fn test_format_chunk_text_includes_signals_for_checklist_and_intent() {
2413        let entries = [make_entry(
2414            14,
2415            30,
2416            "user",
2417            "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",
2418        )];
2419        let refs: Vec<&TimelineEntry> = entries.iter().collect();
2420
2421        let text = format_chunk_text(&refs, "TestProj", "claude", "2026-01-22");
2422
2423        assert!(text.contains("[signals]"));
2424        assert!(text.contains("RED LIGHT: checklist detected (open: 1, done: 1)"));
2425        assert!(text.contains("- [ ] pierwsza rzecz"));
2426        assert!(text.contains("- [x] druga rzecz"));
2427        assert!(text.contains("Ultrathink:"));
2428        assert!(text.contains("- Ultrathink:"));
2429        assert!(text.contains("Insight:"));
2430        assert!(text.contains("- ★ Insight ─ to działa"));
2431        assert!(text.contains("Plan mode:"));
2432        assert!(text.contains("- Plan mode: enabled"));
2433        assert!(text.contains("- User accepted the plan"));
2434        assert!(text.contains("Intent:"));
2435        assert!(text.contains("No i tutaj mam taki pomysł, żeby to zrobić"));
2436        assert!(text.contains("[/signals]"));
2437    }
2438
2439    #[test]
2440    fn test_format_chunk_text_skips_local_command_artifact_intents() {
2441        let entries = [
2442            make_entry(
2443                14,
2444                31,
2445                "user",
2446                "<local-command-caveat>DO NOT respond to these messages</local-command-caveat>",
2447            ),
2448            make_entry(
2449                14,
2450                32,
2451                "user",
2452                "<bash-stdout>curl output\n* issuer: C=US; O=Let's Encrypt; CN=E7\n* SSL certificate verify ok.\n</bash-stdout>",
2453            ),
2454        ];
2455        let refs: Vec<&TimelineEntry> = entries.iter().collect();
2456
2457        let text = format_chunk_text(&refs, "TestProj", "claude", "2026-01-22");
2458
2459        assert!(!text.contains("Intent:"));
2460    }
2461
2462    // ── Area E.8 + E.12 regression coverage ─────────────────────────────
2463
2464    #[test]
2465    fn test_normalize_key_strips_zero_width_chars() {
2466        assert_eq!(normalize_key("fix au\u{200B}th"), "fix auth");
2467        assert_eq!(normalize_key("fix au\u{200C}th"), "fix auth");
2468        assert_eq!(normalize_key("fix au\u{200D}th"), "fix auth");
2469        assert_eq!(normalize_key("\u{FEFF}fix auth"), "fix auth");
2470    }
2471
2472    #[test]
2473    fn test_normalize_key_strips_bidi_controls() {
2474        assert_eq!(normalize_key("fix\u{202A}auth\u{202C}"), "fixauth");
2475        assert_eq!(normalize_key("fix \u{2066}auth\u{2069}"), "fix auth");
2476    }
2477
2478    #[test]
2479    fn test_normalize_key_preserves_visible_chars() {
2480        assert_eq!(normalize_key("  Fix  AUTH  Bug  "), "fix auth bug");
2481        assert_eq!(normalize_key("naprawdę"), "naprawdę");
2482    }
2483
2484    #[test]
2485    fn test_extract_checklist_skips_lines_inside_code_fence() {
2486        let entry = make_entry(
2487            12,
2488            0,
2489            "user",
2490            "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",
2491        );
2492        let entries = vec![&entry];
2493        let (open, done) = extract_checklist_items(&entries);
2494        let open_texts: Vec<&str> = open.iter().map(|item| item.text.as_str()).collect();
2495        let done_texts: Vec<&str> = done.iter().map(|item| item.text.as_str()).collect();
2496        assert_eq!(open_texts, vec!["real task open"]);
2497        assert_eq!(done_texts, vec!["real task done"]);
2498    }
2499
2500    #[test]
2501    fn test_extract_checklist_fence_toggle_resets_per_entry() {
2502        let entry_a = make_entry(12, 0, "user", "```\n- [ ] fenced leak");
2503        let entry_b = make_entry(12, 1, "user", "- [ ] honest task");
2504        let entries = vec![&entry_a, &entry_b];
2505        let (open, _done) = extract_checklist_items(&entries);
2506        let open_texts: Vec<&str> = open.iter().map(|item| item.text.as_str()).collect();
2507        assert_eq!(open_texts, vec!["honest task"]);
2508    }
2509
2510    // ── B1: typed CardSignal records + [signals] rendered from records ──
2511
2512    /// The exact `[signals]` families the pre-B1 renderer consumed, as plain
2513    /// strings. Kept only for the golden comparison below.
2514    struct LegacySignalFamilies {
2515        todo_open: Vec<String>,
2516        todo_done: Vec<String>,
2517        ultrathink: Vec<String>,
2518        insights: Vec<String>,
2519        plan_mode: Vec<String>,
2520        intents: Vec<String>,
2521        results: Vec<String>,
2522        skills: Vec<String>,
2523        decisions: Vec<String>,
2524        outcomes: Vec<String>,
2525    }
2526
2527    /// Verbatim copy of the pre-B1 prose renderer (`format_signals_block`
2528    /// before it became a function of `CardSignal` records). Golden reference:
2529    /// the record-driven renderer must reproduce this byte-for-byte.
2530    fn legacy_format_signals_block_pre_b1(
2531        signals: &LegacySignalFamilies,
2532        highlights: &[String],
2533    ) -> Option<String> {
2534        let has_any = !signals.todo_open.is_empty()
2535            || !signals.todo_done.is_empty()
2536            || !signals.ultrathink.is_empty()
2537            || !signals.insights.is_empty()
2538            || !signals.plan_mode.is_empty()
2539            || !signals.intents.is_empty()
2540            || !signals.results.is_empty()
2541            || !signals.skills.is_empty()
2542            || !signals.decisions.is_empty()
2543            || !signals.outcomes.is_empty()
2544            || !highlights.is_empty();
2545        if !has_any {
2546            return None;
2547        }
2548
2549        let mut out = String::new();
2550        out.push_str("[signals]\n");
2551
2552        if !signals.skills.is_empty() {
2553            out.push_str("=== SKILL ENTER ===\n");
2554            for line in &signals.skills {
2555                out.push_str(&format!("{}\n", line));
2556            }
2557            out.push_str("===================\n");
2558        }
2559
2560        if !signals.todo_open.is_empty() || !signals.todo_done.is_empty() {
2561            if !signals.todo_open.is_empty() {
2562                out.push_str(&format!(
2563                    "RED LIGHT: checklist detected (open: {}, done: {})\n",
2564                    signals.todo_open.len(),
2565                    signals.todo_done.len()
2566                ));
2567            } else {
2568                out.push_str(&format!(
2569                    "Checklist detected (open: 0, done: {})\n",
2570                    signals.todo_done.len()
2571                ));
2572            }
2573
2574            for task in signals.todo_open.iter().take(MAX_TODO_ITEMS) {
2575                out.push_str(&format!("- [ ] {}\n", task));
2576            }
2577            if signals.todo_open.len() > MAX_TODO_ITEMS {
2578                out.push_str(&format!(
2579                    "... (+{} more open)\n",
2580                    signals.todo_open.len() - MAX_TODO_ITEMS
2581                ));
2582            }
2583
2584            for task in signals.todo_done.iter().take(MAX_TODO_ITEMS) {
2585                out.push_str(&format!("- [x] {}\n", task));
2586            }
2587            if signals.todo_done.len() > MAX_TODO_ITEMS {
2588                out.push_str(&format!(
2589                    "... (+{} more done)\n",
2590                    signals.todo_done.len() - MAX_TODO_ITEMS
2591                ));
2592            }
2593        }
2594
2595        if !signals.ultrathink.is_empty() {
2596            out.push_str("Ultrathink:\n");
2597            for line in &signals.ultrathink {
2598                out.push_str(&format!("- {}\n", line));
2599            }
2600        }
2601
2602        if !signals.insights.is_empty() {
2603            out.push_str("Insight:\n");
2604            for line in &signals.insights {
2605                out.push_str(&format!("- {}\n", line));
2606            }
2607        }
2608
2609        if !signals.plan_mode.is_empty() {
2610            out.push_str("Plan mode:\n");
2611            for line in &signals.plan_mode {
2612                out.push_str(&format!("- {}\n", line));
2613            }
2614        }
2615
2616        if !signals.intents.is_empty() {
2617            out.push_str("Intent:\n");
2618            for line in &signals.intents {
2619                out.push_str(&format!("- {}\n", line));
2620            }
2621        }
2622
2623        if !signals.decisions.is_empty() {
2624            out.push_str("Decision:\n");
2625            for line in &signals.decisions {
2626                out.push_str(&format!("- {}\n", line));
2627            }
2628        }
2629
2630        if !signals.results.is_empty() {
2631            out.push_str("Results:\n");
2632            for line in &signals.results {
2633                out.push_str(&format!("- {}\n", line));
2634            }
2635        }
2636
2637        if !signals.outcomes.is_empty() {
2638            out.push_str("Outcome:\n");
2639            for line in &signals.outcomes {
2640                out.push_str(&format!("- {}\n", line));
2641            }
2642        }
2643
2644        if !highlights.is_empty() {
2645            out.push_str("Notes:\n");
2646            for line in highlights {
2647                out.push_str(&format!("- {}\n", truncate_signal_line(line)));
2648            }
2649        }
2650
2651        out.push_str("[/signals]\n");
2652        Some(out)
2653    }
2654
2655    fn items(texts: &[String]) -> Vec<SignalItem> {
2656        texts
2657            .iter()
2658            .map(|text| SignalItem::new(text.clone(), None))
2659            .collect()
2660    }
2661
2662    fn rich_legacy_families() -> (LegacySignalFamilies, Vec<String>) {
2663        let legacy = LegacySignalFamilies {
2664            todo_open: (1..=10).map(|i| format!("open task {i}")).collect(),
2665            todo_done: (1..=9).map(|i| format!("closed item {i}")).collect(),
2666            ultrathink: vec!["Ultrathink: deep dive into chunk windows".to_string()],
2667            insights: vec!["★ Insight ─ records are the primary artifact".to_string()],
2668            plan_mode: vec![
2669                "Plan mode: enabled".to_string(),
2670                "User accepted the plan".to_string(),
2671            ],
2672            intents: vec!["mam pomysł, żeby serializować sygnały".to_string()],
2673            results: vec!["all checks passed".to_string()],
2674            skills: vec!["[SKILL_ENTER] vc-implement".to_string()],
2675            decisions: vec!["Decision: render prose from records".to_string()],
2676            outcomes: vec!["Outcome: sidecar carries signals[]".to_string()],
2677        };
2678        let highlights = vec![
2679            "Decision: lock chunking heuristics".to_string(),
2680            // Longer than truncate_signal_line's 240-byte cap so the golden
2681            // comparison also covers render-time truncation of Notes.
2682            "K".repeat(500),
2683        ];
2684        (legacy, highlights)
2685    }
2686
2687    fn records_from_legacy(
2688        legacy: &LegacySignalFamilies,
2689        highlights: &[String],
2690    ) -> Vec<CardSignal> {
2691        let signals = ChunkSignals {
2692            todo_open: items(&legacy.todo_open),
2693            todo_done: items(&legacy.todo_done),
2694            ultrathink: items(&legacy.ultrathink),
2695            insights: items(&legacy.insights),
2696            plan_mode: items(&legacy.plan_mode),
2697            intents: items(&legacy.intents),
2698            results: items(&legacy.results),
2699            skills: items(&legacy.skills),
2700            decisions: items(&legacy.decisions),
2701            outcomes: items(&legacy.outcomes),
2702        };
2703        signal_records(&signals, &items(highlights))
2704    }
2705
2706    #[test]
2707    fn golden_signals_block_from_records_matches_pre_b1_prose_byte_for_byte() {
2708        let (legacy, highlights) = rich_legacy_families();
2709        let expected = legacy_format_signals_block_pre_b1(&legacy, &highlights)
2710            .expect("legacy renderer emits a block");
2711
2712        let rendered = format_signals_block(&records_from_legacy(&legacy, &highlights))
2713            .expect("record renderer emits a block");
2714
2715        assert_eq!(rendered, expected);
2716        // Overflow caps must survive the record indirection.
2717        assert!(rendered.contains("RED LIGHT: checklist detected (open: 10, done: 9)"));
2718        assert!(rendered.contains("... (+2 more open)"));
2719        assert!(rendered.contains("... (+1 more done)"));
2720        assert!(rendered.contains("...[truncated]"));
2721    }
2722
2723    #[test]
2724    fn signal_records_keep_all_todo_items_beyond_render_cap() {
2725        let (legacy, highlights) = rich_legacy_families();
2726        let records = records_from_legacy(&legacy, &highlights);
2727
2728        let open_count = records
2729            .iter()
2730            .filter(|record| record.kind == SIGNAL_KIND_TODO_OPEN)
2731            .count();
2732        assert_eq!(open_count, 10, "records carry the FULL open-task list");
2733
2734        let rendered = format_signals_block(&records).unwrap();
2735        let rendered_open = rendered.matches("- [ ] ").count();
2736        assert_eq!(rendered_open, MAX_TODO_ITEMS, "render caps display only");
2737    }
2738
2739    #[test]
2740    fn sidecar_signals_carry_typed_records_per_family() {
2741        let entries = vec![make_entry(
2742            14,
2743            30,
2744            "user",
2745            "mam pomysł, żeby to zrobić\n\nDecision: keep records primary\n\nOutcome: sidecar carries signals\n\n- [ ] open task\n- [x] closed item",
2746        )];
2747
2748        let chunks = chunk_entries(&entries, "proj", "claude", &ChunkerConfig::default());
2749        assert_eq!(chunks.len(), 1);
2750        let sidecar = ChunkMetadataSidecar::from(&chunks[0]);
2751        let records = sidecar.signals.expect("sidecar persists signal records");
2752
2753        let texts_of = |kind: &str| -> Vec<&str> {
2754            records
2755                .iter()
2756                .filter(|record| record.kind == kind)
2757                .map(|record| record.text.as_str())
2758                .collect()
2759        };
2760
2761        assert_eq!(texts_of(SIGNAL_KIND_TODO_OPEN), vec!["open task"]);
2762        assert_eq!(texts_of(SIGNAL_KIND_TODO_DONE), vec!["closed item"]);
2763        assert_eq!(
2764            texts_of(SIGNAL_KIND_INTENT),
2765            vec!["mam pomysł, żeby to zrobić"]
2766        );
2767        assert_eq!(
2768            texts_of(SIGNAL_KIND_DECISION),
2769            vec!["Decision: keep records primary"]
2770        );
2771        assert_eq!(
2772            texts_of(SIGNAL_KIND_OUTCOME),
2773            vec!["Outcome: sidecar carries signals"]
2774        );
2775        assert_eq!(
2776            texts_of(SIGNAL_KIND_HIGHLIGHT),
2777            vec!["mam pomysł, żeby to zrobić"]
2778        );
2779
2780        // The rendered block and the records must agree: render(records) is
2781        // exactly the [signals] section embedded in the chunk text.
2782        let block = format_signals_block(&records).expect("block renders");
2783        assert!(chunks[0].text.contains(&block));
2784    }
2785
2786    #[test]
2787    fn signal_records_carry_entry_line_span_only_when_known() {
2788        let mut with_span = make_entry(14, 30, "user", "Decision: span provenance");
2789        with_span.source_line_span = Some((12, 14));
2790        let without_span = make_entry(14, 31, "user", "mam pomysł na kolejny krok");
2791
2792        let chunks = chunk_entries(
2793            &[with_span, without_span],
2794            "proj",
2795            "claude",
2796            &ChunkerConfig::default(),
2797        );
2798        assert_eq!(chunks.len(), 1);
2799        let records = &chunks[0].signals;
2800
2801        let decision = records
2802            .iter()
2803            .find(|record| record.kind == SIGNAL_KIND_DECISION)
2804            .expect("decision extracted");
2805        assert_eq!(decision.line_span, Some((12, 14)));
2806
2807        let intent = records
2808            .iter()
2809            .find(|record| record.kind == SIGNAL_KIND_INTENT)
2810            .expect("intent extracted");
2811        assert_eq!(intent.line_span, None, "no source span → no fake values");
2812    }
2813
2814    #[test]
2815    fn signal_records_stamp_extractor_version_with_crate_version() {
2816        let entries = vec![make_entry(
2817            14,
2818            30,
2819            "user",
2820            "Decision: stamp generations\n\n- [ ] open task",
2821        )];
2822        let chunks = chunk_entries(&entries, "proj", "claude", &ChunkerConfig::default());
2823        let records = &chunks[0].signals;
2824
2825        assert!(!records.is_empty());
2826        assert!(records.iter().all(|record| {
2827            record.extractor_version.as_deref() == Some(env!("CARGO_PKG_VERSION"))
2828        }));
2829        assert_eq!(SIGNAL_EXTRACTOR_VERSION, env!("CARGO_PKG_VERSION"));
2830    }
2831
2832    #[test]
2833    fn sidecar_omits_signals_field_for_signal_free_chunk() {
2834        let chunks = chunk_entries(
2835            &[make_entry(14, 30, "user", "hello there")],
2836            "proj",
2837            "claude",
2838            &ChunkerConfig::default(),
2839        );
2840        assert_eq!(chunks.len(), 1);
2841        assert!(chunks[0].signals.is_empty());
2842        assert!(!chunks[0].text.contains("[signals]"));
2843
2844        let sidecar = ChunkMetadataSidecar::from(&chunks[0]);
2845        assert_eq!(sidecar.signals, None);
2846        let json = serde_json::to_value(&sidecar).unwrap();
2847        assert!(
2848            json.get("signals").is_none(),
2849            "empty signals must not serialize"
2850        );
2851    }
2852
2853    #[test]
2854    fn write_chunks_to_dir_round_trips_signal_records_through_sidecar_file() {
2855        let tmp = std::env::temp_dir().join("aicx-chunker-b1-signals-roundtrip");
2856        let _ = fs::remove_dir_all(&tmp);
2857
2858        let entries = vec![make_entry(
2859            14,
2860            30,
2861            "user",
2862            "Decision: persist typed signals\n\n- [ ] wire sidecar\n- [x] type the records",
2863        )];
2864        let chunks = chunk_entries(&entries, "proj", "claude", &ChunkerConfig::default());
2865        assert_eq!(chunks.len(), 1);
2866        assert!(!chunks[0].signals.is_empty());
2867
2868        write_chunks_to_dir(&chunks, &tmp).unwrap();
2869        let raw = fs::read_to_string(tmp.join(format!("{}.meta.json", chunks[0].id))).unwrap();
2870        let sidecar: ChunkMetadataSidecar = serde_json::from_str(&raw).unwrap();
2871
2872        assert_eq!(
2873            sidecar.signals.as_deref(),
2874            Some(chunks[0].signals.as_slice())
2875        );
2876
2877        let _ = fs::remove_dir_all(&tmp);
2878    }
2879}