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