1use 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#[derive(Debug, Clone)]
23pub struct Chunk {
24 pub id: String,
26 pub project: String,
27 pub agent: String,
28 pub date: String,
30 pub session_id: String,
32 pub cwd: Option<String>,
34 pub timestamp_source: Option<String>,
36 pub kind: Kind,
38 pub frame_kind: Option<FrameKind>,
40 pub run_id: Option<String>,
42 pub prompt_id: Option<String>,
44 pub agent_model: Option<String>,
46 pub started_at: Option<String>,
48 pub completed_at: Option<String>,
50 pub token_usage: Option<u64>,
52 pub findings_count: Option<u32>,
54 pub workflow_phase: Option<String>,
56 pub mode: Option<String>,
58 pub skill_code: Option<String>,
60 pub framework_version: Option<String>,
62 pub source_file: Option<String>,
65 pub source_format: Option<String>,
66 pub import_id: Option<String>,
67 pub source: Option<CardSource>,
70 pub msg_range: (usize, usize),
72 pub text: String,
74 pub token_estimate: usize,
76 pub highlights: Vec<String>,
78 pub signals: Vec<CardSignal>,
81 pub noise_lines_dropped: usize,
87}
88
89#[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 #[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 #[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
179pub 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
194pub 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#[derive(Debug, Clone)]
339pub struct ChunkerConfig {
340 pub target_tokens: usize,
342 pub min_tokens: usize,
344 pub max_tokens: usize,
346 pub overlap_messages: usize,
348 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
367pub fn estimate_tokens(text: &str) -> usize {
376 text.len().div_ceil(4)
377}
378
379const 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
413pub 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 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
598pub 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 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
649fn 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 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; 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 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 let overlap = config.overlap_messages.min(end - start);
755 let next_start = if end >= entries.len() {
756 entries.len() } else if end - overlap > start {
758 end - overlap
759 } else {
760 end };
762
763 start = next_start;
764 }
765
766 (chunks, seq)
767}
768
769pub 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
805fn 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 for entry in entries {
860 if entry.message.is_empty() {
861 continue;
862 }
863 let time = entry.timestamp.format("%H:%M:%S");
864 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#[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
948fn 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 "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 "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 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 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 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 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 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 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
1456fn 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
1603pub 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
1628pub 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#[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); assert_eq!(estimate_tokens("hello world"), 3); assert_eq!(estimate_tokens("1234"), 1); assert_eq!(estimate_tokens("12345"), 2); }
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 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 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 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 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('é'); 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 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 assert!(!chunk.text.contains("import_id: blake3:abc123"));
1983
1984 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 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 if chunks.len() >= 2 {
2293 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 #[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 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 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 "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 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 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}