1use std::collections::{BTreeSet, HashSet};
77
78use async_trait::async_trait;
79use serde::{Deserialize, Serialize};
80use sha2::{Digest, Sha256};
81
82use contextgraph_types::{
83 Capabilities, ContentFidelity, ContentRef, ContextFrame, ContextQuery, ContextQueryResult,
84 DataFlow, EgressScope, FrameKind, FrameVerdict, InlineContentRequirement, Provenance,
85 ProviderInfo, QueryCapability, Representation, Transform, Verdict, VerifyRequest,
86 VerifyResponse, budget_tokens, is_protocol_timestamp,
87};
88
89use crate::error::HostError;
90use crate::provider::{ContextProvider, frame_kind_name};
91
92const COMPACT_MIN_TOKENS: u32 = 64;
95const LOG_CONTEXT: usize = 2;
97const LOG_HEAD: usize = 8;
99const LOG_TAIL: usize = 4;
100const TABLE_SAMPLE: usize = 5;
102const MIN_AMBIGUOUS_TABLE_ROWS: usize = 3;
106const MAX_CELL_WORDS: usize = 4;
110const STACK_FRAMES: usize = 8;
113const CODE_HEAD: usize = 20;
115const CODE_TAIL: usize = 8;
116const TRANSFORM_VERSION: &str = "1";
119const TRANSFORM_IMPL: &str = "contextgraph-host/ingest";
121pub const DEFAULT_PROVIDER_ID: &str = "prompt-ingest";
123
124fn sha256_hex(bytes: &[u8]) -> String {
129 let digest = Sha256::digest(bytes);
130 let mut hex = String::with_capacity(64);
131 for byte in digest {
132 hex.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
135 hex.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap());
136 }
137 hex
138}
139
140fn sha256_digest(s: &str) -> String {
142 format!("sha256:{}", sha256_hex(s.as_bytes()))
143}
144
145fn short_hash(digest: &str) -> &str {
148 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
149 &hex[..hex.len().min(12)]
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
164pub struct PasteIngest {
165 pub intent: String,
167 #[serde(default)]
169 pub anchors: Vec<String>,
170 #[serde(default)]
172 pub attachments: Vec<String>,
173}
174
175impl PasteIngest {
176 pub fn new(intent: impl Into<String>, attachment: impl Into<String>) -> Self {
178 Self {
179 intent: intent.into(),
180 anchors: Vec::new(),
181 attachments: vec![attachment.into()],
182 }
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct IngestConfig {
189 pub provider_id: String,
191}
192
193impl Default for IngestConfig {
194 fn default() -> Self {
195 Self {
196 provider_id: DEFAULT_PROVIDER_ID.to_string(),
197 }
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum SegmentKind {
206 Log,
208 StackTrace,
211 Table,
213 Code,
215 Prose,
217 PathRef,
219}
220
221impl SegmentKind {
222 fn frame_kind(self) -> Option<FrameKind> {
223 match self {
224 SegmentKind::Log | SegmentKind::StackTrace => Some(FrameKind::Episode),
228 SegmentKind::Table => Some(FrameKind::Fact),
229 SegmentKind::Code => Some(FrameKind::Snippet),
230 SegmentKind::Prose => Some(FrameKind::Doc),
231 SegmentKind::PathRef => None,
232 }
233 }
234
235 fn citation_label(self) -> &'static str {
236 match self {
237 SegmentKind::Log => "pasted log",
238 SegmentKind::StackTrace => "pasted stack trace",
239 SegmentKind::Table => "pasted table",
240 SegmentKind::Code => "pasted code",
241 SegmentKind::Prose => "pasted note",
242 SegmentKind::PathRef => "pasted path",
243 }
244 }
245
246 fn score(self) -> f32 {
249 match self {
250 SegmentKind::StackTrace => 0.85,
253 SegmentKind::Log => 0.8,
254 SegmentKind::Code => 0.75,
255 SegmentKind::Table => 0.7,
256 SegmentKind::Prose => 0.5,
257 SegmentKind::PathRef => 0.0,
258 }
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(tag = "outcome", rename_all = "snake_case")]
266pub enum SegmentOutcome {
267 Anchor { uri: String },
269 Frame {
271 id: String,
272 representation: Representation,
274 inline_tokens: u32,
276 source_tokens: u32,
278 },
279 Duplicate { id: String },
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct SegmentReport {
287 pub kind: SegmentKind,
288 pub summary: String,
290 pub became: SegmentOutcome,
291}
292
293pub struct IngestBundle {
296 pub query: ContextQuery,
299 pub provider: IngestProvider,
301 pub report: Vec<SegmentReport>,
303}
304
305pub fn ingest_paste(input: PasteIngest, config: IngestConfig) -> IngestBundle {
311 let PasteIngest {
312 intent,
313 mut anchors,
314 attachments,
315 } = input;
316
317 let mut artifacts: Vec<Artifact> = Vec::new();
318 let mut report: Vec<SegmentReport> = Vec::new();
319 let mut seen: HashSet<String> = HashSet::new();
320
321 for attachment in &attachments {
322 for block in split_blocks(attachment) {
323 let text = block.text();
324 if text.trim().is_empty() {
325 continue;
326 }
327 let kind = classify(&block);
328
329 if kind == SegmentKind::PathRef {
330 let uri = text.trim().to_string();
331 report.push(SegmentReport {
332 kind,
333 summary: format!("anchor · {uri}"),
334 became: SegmentOutcome::Anchor { uri: uri.clone() },
335 });
336 if !anchors.contains(&uri) {
337 anchors.push(uri);
338 }
339 continue;
340 }
341
342 let artifact = Artifact::build(kind, text);
343 if seen.contains(&artifact.id) {
344 report.push(SegmentReport {
345 kind,
346 summary: format!("duplicate · deduplicated to {}", artifact.id),
347 became: SegmentOutcome::Duplicate { id: artifact.id },
348 });
349 continue;
350 }
351 seen.insert(artifact.id.clone());
352 report.push(SegmentReport {
353 kind,
354 summary: artifact.summary.clone(),
355 became: SegmentOutcome::Frame {
356 id: artifact.id.clone(),
357 representation: Representation::Compact,
358 inline_tokens: budget_tokens(&artifact.inline_content),
359 source_tokens: budget_tokens(&artifact.full_content),
360 },
361 });
362 artifacts.push(artifact);
363 }
364 }
365
366 artifacts.sort_by(|a, b| a.id.cmp(&b.id));
368
369 let provider = IngestProvider::new(config.provider_id, artifacts);
370 let query = ContextQuery {
371 goal: intent,
372 query_text: None,
373 embedding: None,
374 kinds: Vec::new(),
375 anchors,
376 max_frames: provider.artifacts.len() as u32,
377 max_tokens: provider.default_budget_tokens(),
378 as_of: None,
379 representation_preferences: vec![Representation::Compact, Representation::Full],
380 };
381
382 IngestBundle {
383 query,
384 provider,
385 report,
386 }
387}
388
389struct RawBlock {
396 lines: Vec<String>,
397 fenced_code: bool,
398}
399
400impl RawBlock {
401 fn text(&self) -> String {
402 self.lines.join("\n")
403 }
404}
405
406fn flush_block(buf: &mut Vec<String>, fenced_code: bool, blocks: &mut Vec<RawBlock>) {
408 if !buf.is_empty() {
409 blocks.push(RawBlock {
410 lines: std::mem::take(buf),
411 fenced_code,
412 });
413 }
414}
415
416fn split_blocks(text: &str) -> Vec<RawBlock> {
419 let mut blocks = Vec::new();
420 let mut current: Vec<String> = Vec::new();
421 let mut fence: Vec<String> = Vec::new();
422 let mut in_fence = false;
423
424 for line in text.lines() {
425 if line.trim_start().starts_with("```") {
426 if in_fence {
427 flush_block(&mut fence, true, &mut blocks);
428 in_fence = false;
429 } else {
430 flush_block(&mut current, false, &mut blocks);
431 in_fence = true;
432 }
433 continue;
434 }
435 if in_fence {
436 fence.push(line.to_string());
437 } else if line.trim().is_empty() {
438 flush_block(&mut current, false, &mut blocks);
439 } else {
440 current.push(line.to_string());
441 }
442 }
443 flush_block(&mut fence, in_fence, &mut blocks);
445 flush_block(&mut current, false, &mut blocks);
446 blocks
447}
448
449fn classify(block: &RawBlock) -> SegmentKind {
453 if block.fenced_code {
454 return SegmentKind::Code;
455 }
456 let lines: Vec<&str> = block.lines.iter().map(String::as_str).collect();
457 if lines.len() == 1 && looks_like_path(lines[0]) {
458 return SegmentKind::PathRef;
459 }
460 if looks_like_stack_trace(&lines) {
461 return SegmentKind::StackTrace;
462 }
463 if looks_like_timestamped_log(&lines) {
469 return SegmentKind::Log;
470 }
471 if delimited_table_delimiter(&lines).is_some() {
472 return SegmentKind::Table;
473 }
474 if looks_like_log(&lines) {
475 return SegmentKind::Log;
476 }
477 if aligned_table_delimiter(&lines).is_some() {
481 return SegmentKind::Table;
482 }
483 if looks_like_code(&lines) {
484 return SegmentKind::Code;
485 }
486 SegmentKind::Prose
487}
488
489const PATH_EXTENSIONS: &[&str] = &[
490 "rs", "ts", "tsx", "js", "jsx", "py", "go", "rb", "java", "kt", "c", "h", "cc", "cpp", "hpp",
491 "cs", "md", "toml", "json", "yaml", "yml", "txt", "sh", "sql", "lock", "cfg", "ini",
492];
493
494fn looks_like_path(line: &str) -> bool {
496 let s = line.trim();
497 if s.is_empty() || s.chars().any(char::is_whitespace) {
498 return false;
499 }
500 if s.starts_with("http://") || s.starts_with("https://") {
502 return false;
503 }
504 if s.starts_with("file://") {
505 return true;
506 }
507 let rooted =
508 s.starts_with("./") || s.starts_with("../") || s.starts_with("~/") || s.starts_with('/');
509 let has_extension = s
510 .rsplit('/')
511 .next()
512 .and_then(|name| name.rsplit_once('.'))
513 .is_some_and(|(_, ext)| PATH_EXTENSIONS.contains(&ext));
514 let host_like =
519 !rooted && !has_extension && s.split('/').next().is_some_and(|first| first.contains('.'));
520 if host_like {
521 return false;
522 }
523 (s.contains('/') && (rooted || has_extension || s.matches('/').count() >= 1))
526 || (rooted && !s.contains(' '))
527 || has_extension
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542enum TableDelimiter {
543 Pipe,
545 Tab,
547 Comma,
549 Whitespace,
551}
552
553fn delimited_table_delimiter(lines: &[&str]) -> Option<TableDelimiter> {
560 if lines.len() < 2 {
561 return None;
562 }
563 if rows_agree_on_delimiter_count(lines, '|') {
564 return Some(TableDelimiter::Pipe);
565 }
566 let indented = lines.iter().filter(|l| l.starts_with('\t')).count();
570 if rows_agree_on_delimiter_count(lines, '\t') && indented * 10 < lines.len() * 7 {
571 return Some(TableDelimiter::Tab);
572 }
573 if lines.len() >= MIN_AMBIGUOUS_TABLE_ROWS
574 && rows_agree_on_delimiter_count(lines, ',')
575 && rows_read_as_values(lines, TableDelimiter::Comma)
576 {
577 return Some(TableDelimiter::Comma);
578 }
579 None
580}
581
582fn aligned_table_delimiter(lines: &[&str]) -> Option<TableDelimiter> {
588 if lines.len() < MIN_AMBIGUOUS_TABLE_ROWS {
589 return None;
590 }
591 let counts: Vec<usize> = lines
592 .iter()
593 .map(|l| split_row(l, TableDelimiter::Whitespace).len())
594 .collect();
595 let common = most_common(&counts)?;
596 if common < 2 || !majority_agrees(&counts, common) {
598 return None;
599 }
600 rows_read_as_values(lines, TableDelimiter::Whitespace).then_some(TableDelimiter::Whitespace)
601}
602
603fn table_delimiter(lines: &[&str]) -> Option<TableDelimiter> {
605 delimited_table_delimiter(lines).or_else(|| aligned_table_delimiter(lines))
606}
607
608fn rows_agree_on_delimiter_count(lines: &[&str], delimiter: char) -> bool {
612 let counts: Vec<usize> = lines.iter().map(|l| l.matches(delimiter).count()).collect();
613 most_common(&counts).is_some_and(|common| common >= 1 && majority_agrees(&counts, common))
614}
615
616fn majority_agrees(counts: &[usize], common: usize) -> bool {
618 let agree = counts.iter().filter(|&&c| c == common).count();
619 agree * 10 >= counts.len() * 7
620}
621
622fn rows_read_as_values(lines: &[&str], delimiter: TableDelimiter) -> bool {
630 lines.iter().all(|line| {
631 split_row(line, delimiter)
632 .iter()
633 .all(|cell| cell.split_whitespace().count() <= MAX_CELL_WORDS)
634 })
635}
636
637fn split_row(line: &str, delimiter: TableDelimiter) -> Vec<String> {
645 match delimiter {
646 TableDelimiter::Pipe => {
647 let mut cells: Vec<String> = line.split('|').map(|c| c.trim().to_string()).collect();
648 if cells.first().is_some_and(String::is_empty) {
650 cells.remove(0);
651 }
652 if cells.last().is_some_and(String::is_empty) {
653 cells.pop();
654 }
655 cells
656 }
657 TableDelimiter::Tab => line.split('\t').map(|c| c.trim().to_string()).collect(),
658 TableDelimiter::Comma => line.split(',').map(|c| c.trim().to_string()).collect(),
659 TableDelimiter::Whitespace => line
663 .split(" ")
664 .map(str::trim)
665 .filter(|c| !c.is_empty())
666 .map(str::to_string)
667 .collect(),
668 }
669}
670
671const LOG_LEVELS: &[&str] = &[
672 "ERROR", "ERR", "WARN", "WARNING", "INFO", "DEBUG", "TRACE", "FATAL", "CRITICAL", "CRIT",
673 "PANIC", "PANICKED", "SEVERE", "NOTICE",
674];
675const ALERT_LEVELS: &[&str] = &[
676 "ERROR", "ERR", "WARN", "WARNING", "FATAL", "CRITICAL", "CRIT", "PANIC", "PANICKED", "SEVERE",
677];
678const STACK_MARKERS: &[&str] = &[
682 "at ",
683 "File \"",
684 "Traceback",
685 "panicked at",
686 "-->",
687 "Caused by",
688 "thread '",
689];
690
691fn looks_like_log(lines: &[&str]) -> bool {
693 let non_empty: Vec<&str> = non_empty_lines(lines);
694 if non_empty.is_empty() {
695 return false;
696 }
697 let matched = non_empty.iter().filter(|l| is_log_line(l)).count();
698 matched * 2 >= non_empty.len()
699}
700
701fn looks_like_timestamped_log(lines: &[&str]) -> bool {
708 let non_empty: Vec<&str> = non_empty_lines(lines);
709 if non_empty.is_empty() {
710 return false;
711 }
712 let stamped = non_empty
713 .iter()
714 .filter(|l| leading_timestamp(l).is_some())
715 .count();
716 stamped * 2 >= non_empty.len() && non_empty.iter().any(|l| has_level_token(l, LOG_LEVELS))
717}
718
719fn non_empty_lines<'a>(lines: &[&'a str]) -> Vec<&'a str> {
720 lines
721 .iter()
722 .copied()
723 .filter(|l| !l.trim().is_empty())
724 .collect()
725}
726
727fn is_log_line(line: &str) -> bool {
728 let t = line.trim_start();
729 if t.is_empty() {
730 return false;
731 }
732 if STACK_MARKERS.iter().any(|m| t.starts_with(m)) {
733 return true;
734 }
735 if has_level_token(t, LOG_LEVELS) {
736 return true;
737 }
738 if leading_timestamp(t).is_some() {
739 return true;
740 }
741 let first = t.split_whitespace().next().unwrap_or("");
742 if first.starts_with('[') {
743 return true;
744 }
745 first.chars().next().is_some_and(|c| c.is_ascii_digit())
748 && (first.contains(':') || first.contains('-'))
749}
750
751fn looks_like_stack_trace(lines: &[&str]) -> bool {
763 let non_empty = non_empty_lines(lines).len();
764 if non_empty == 0 {
765 return false;
766 }
767 let frames = lines.iter().filter(|l| is_stack_frame_line(l)).count();
768 frames >= 2 && frames * 4 >= non_empty && lines.iter().any(|l| is_exception_header(l))
769}
770
771fn is_exception_header(line: &str) -> bool {
777 let t = strip_log_prefix(line.trim());
781 if t.starts_with("Traceback (most recent call last)")
782 || t.starts_with("thread '")
783 || t.contains("panicked at")
784 || t.starts_with("Caused by")
785 || (t.starts_with("goroutine ") && t.contains("[running]"))
786 {
787 return true;
788 }
789 let head = t.split_once(':').map_or(t, |(before, _)| before);
795 let words: Vec<&str> = head.split_whitespace().collect();
796 if words.is_empty() || words.len() > 2 {
797 return false;
798 }
799 let name = words[words.len() - 1];
800 let name = name.rsplit('.').next().unwrap_or(name);
802 name.ends_with("Error") || name.ends_with("Exception")
803}
804
805fn strip_log_prefix(line: &str) -> &str {
812 let mut rest = line.trim_start();
813 for _ in 0..4 {
814 let ceremonial = leading_timestamp(rest).is_some()
815 || rest.starts_with('[')
816 || rest
817 .split_whitespace()
818 .next()
819 .is_some_and(|token| has_level_token(token, LOG_LEVELS));
820 if !ceremonial {
821 break;
822 }
823 let Some((_, tail)) = rest.split_once(char::is_whitespace) else {
824 break;
825 };
826 rest = tail.trim_start();
827 }
828 rest
829}
830
831fn is_stack_frame_line(line: &str) -> bool {
833 let t = line.trim_start();
834 if t.starts_with("at ") {
836 return true;
837 }
838 if t.starts_with("File \"") {
840 return true;
841 }
842 if t.starts_with("from ") && t.contains(':') {
844 return true;
845 }
846 if line.starts_with('\t') && t.contains(".go:") {
848 return true;
849 }
850 let digits = t.bytes().take_while(u8::is_ascii_digit).count();
852 digits > 0 && t[digits..].starts_with(": ")
853}
854
855fn is_alert_line(line: &str) -> bool {
856 has_level_token(line.trim_start(), ALERT_LEVELS)
857}
858
859fn has_level_token(s: &str, set: &[&str]) -> bool {
862 s.split(|c: char| !c.is_ascii_alphanumeric())
863 .filter(|w| !w.is_empty())
864 .any(|w| set.contains(&w.to_ascii_uppercase().as_str()))
865}
866
867fn looks_like_code(lines: &[&str]) -> bool {
871 if lines.len() < 3 {
872 return false;
873 }
874 const PREFIXES: &[&str] = &[
875 "fn ",
876 "def ",
877 "class ",
878 "import ",
879 "const ",
880 "let ",
881 "var ",
882 "pub ",
883 "function ",
884 "#include",
885 "package ",
886 "func ",
887 "return ",
888 "if ",
889 "for ",
890 "while ",
891 "@",
892 ];
893 let codey = lines
894 .iter()
895 .filter(|l| {
896 let t = l.trim();
897 let te = l.trim_end();
898 te.ends_with(';')
899 || te.ends_with('{')
900 || te.ends_with('}')
901 || te.ends_with("=>")
902 || te.ends_with("):")
903 || PREFIXES.iter().any(|p| t.starts_with(p))
904 })
905 .count();
906 codey * 2 >= lines.len()
907}
908
909fn most_common(values: &[usize]) -> Option<usize> {
910 let mut best: Option<(usize, usize)> = None; for &v in values {
912 let count = values.iter().filter(|&&x| x == v).count();
913 match best {
914 Some((_, bc)) if bc >= count => {}
915 _ => best = Some((v, count)),
916 }
917 }
918 best.map(|(v, _)| v)
919}
920
921fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
929 if count == 1 { one } else { many }
930}
931
932struct LogRun<'a> {
938 text: &'a str,
939 repeats: usize,
940}
941
942fn collapse_runs<'a>(lines: &[&'a str]) -> Vec<LogRun<'a>> {
943 let mut runs: Vec<LogRun<'a>> = Vec::new();
944 for &line in lines {
945 match runs.last_mut() {
946 Some(run) if run.text == line => run.repeats += 1,
947 _ => runs.push(LogRun {
948 text: line,
949 repeats: 1,
950 }),
951 }
952 }
953 runs
954}
955
956fn distill_log(full: &str) -> (String, Option<String>, Option<String>) {
963 let lines: Vec<&str> = full.lines().collect();
964 let source_lines = lines.len();
965 if source_lines == 0 {
966 return (String::new(), None, None);
967 }
968 let runs = collapse_runs(&lines);
969 let total = runs.len();
970 let alerts: Vec<usize> = (0..total)
971 .filter(|&i| is_alert_line(runs[i].text))
972 .collect();
973
974 let mut keep: BTreeSet<usize> = BTreeSet::new();
975 keep.insert(0);
976 keep.insert(total - 1);
977 if alerts.is_empty() {
978 for i in 0..LOG_HEAD.min(total) {
979 keep.insert(i);
980 }
981 for i in total.saturating_sub(LOG_TAIL)..total {
982 keep.insert(i);
983 }
984 } else {
985 for &a in &alerts {
986 let lo = a.saturating_sub(LOG_CONTEXT);
987 let hi = (a + LOG_CONTEXT).min(total - 1);
988 for i in lo..=hi {
989 keep.insert(i);
990 }
991 }
992 }
993
994 let mut out = String::new();
995 let alert_note = if alerts.is_empty() {
996 String::new()
997 } else {
998 let alert_lines: usize = alerts.iter().map(|&i| runs[i].repeats).sum();
1001 format!(", {alert_lines} error/warn line(s)")
1002 };
1003 out.push_str(&format!("[{source_lines}-line log{alert_note}]\n"));
1004
1005 let mut prev: Option<usize> = None;
1006 for &i in &keep {
1007 if let Some(p) = prev
1008 && i > p + 1
1009 {
1010 let elided: usize = runs[p + 1..i].iter().map(|r| r.repeats).sum();
1011 out.push_str(&format!(
1012 "… ({elided} {} elided) …\n",
1013 plural(elided, "line", "lines")
1014 ));
1015 }
1016 out.push_str(runs[i].text);
1017 out.push('\n');
1018 if runs[i].repeats > 1 {
1019 out.push_str(&format!("… (×{})\n", runs[i].repeats));
1020 }
1021 prev = Some(i);
1022 }
1023
1024 let (valid_from, valid_to) = temporal_window(&lines);
1025 (out.trim_end().to_string(), valid_from, valid_to)
1026}
1027
1028fn distill_stack_trace(full: &str) -> String {
1036 let lines: Vec<&str> = full.lines().collect();
1037 let frames: BTreeSet<usize> = (0..lines.len())
1038 .filter(|&i| is_stack_frame_line(lines[i]))
1039 .collect();
1040 let (Some(&first), Some(&last)) = (frames.first(), frames.last()) else {
1041 return full.to_string();
1042 };
1043 let kept: BTreeSet<usize> = frames.iter().take(STACK_FRAMES).copied().collect();
1044 let elided = frames.len() - kept.len();
1045
1046 let mut out = String::new();
1047 let mut previous_kept = true;
1048 let mut noted = false;
1049 for (i, line) in lines.iter().enumerate() {
1050 let keep = if i < first || i > last {
1051 true
1053 } else if frames.contains(&i) {
1054 kept.contains(&i)
1055 } else {
1056 previous_kept
1059 };
1060 if keep {
1061 out.push_str(line);
1062 out.push('\n');
1063 } else if !noted && elided > 0 {
1064 out.push_str(&format!(
1065 "… ({elided} more {})\n",
1066 plural(elided, "frame", "frames")
1067 ));
1068 noted = true;
1069 }
1070 previous_kept = keep;
1071 }
1072 out.trim_end().to_string()
1073}
1074
1075fn temporal_window(lines: &[&str]) -> (Option<String>, Option<String>) {
1082 let first = leading_instant(lines.first().copied());
1083 let last = leading_instant(lines.last().copied());
1084 match (&first, &last) {
1085 (Some(f), Some(t)) if f > t => (last, first),
1086 _ => (first, last),
1087 }
1088}
1089
1090fn leading_instant(line: Option<&str>) -> Option<String> {
1093 leading_timestamp(line?)?.normalized
1094}
1095
1096struct LeadingTimestamp {
1098 normalized: Option<String>,
1107}
1108
1109fn leading_timestamp(line: &str) -> Option<LeadingTimestamp> {
1117 let t = line.trim_start();
1118 let candidate = match t.strip_prefix('[') {
1122 Some(rest) => rest.split_once(']')?.0,
1123 None => t,
1124 };
1125 let candidate = candidate.trim_start();
1126 if let Some(dated) = parse_dated_timestamp(candidate) {
1127 return Some(dated);
1128 }
1129 if is_syslog_timestamp(candidate) || parse_clock(candidate).is_some() {
1132 return Some(LeadingTimestamp { normalized: None });
1133 }
1134 None
1135}
1136
1137fn parse_dated_timestamp(s: &str) -> Option<LeadingTimestamp> {
1141 let b = s.as_bytes();
1142 if b.len() < 10 {
1143 return None;
1144 }
1145 let separator = b[4];
1146 if (separator != b'-' && separator != b'/') || b[7] != separator {
1147 return None;
1148 }
1149 if !b[..4].iter().all(u8::is_ascii_digit)
1150 || !b[5..7].iter().all(u8::is_ascii_digit)
1151 || !b[8..10].iter().all(u8::is_ascii_digit)
1152 {
1153 return None;
1154 }
1155 let date = format!("{}-{}-{}", &s[..4], &s[5..7], &s[8..10]);
1156 let unnormalized = Some(LeadingTimestamp { normalized: None });
1159
1160 let Some(after_separator) = s[10..].strip_prefix(['T', 't', ' ']) else {
1162 return unnormalized;
1163 };
1164 let Some((clock, tail)) = parse_clock(after_separator) else {
1165 return unnormalized;
1166 };
1167 if !zone_is_utc(tail) {
1168 return unnormalized;
1169 }
1170 let candidate = format!("{date}T{clock}Z");
1171 if is_protocol_timestamp(&candidate) {
1172 return Some(LeadingTimestamp {
1173 normalized: Some(candidate),
1174 });
1175 }
1176 unnormalized
1177}
1178
1179fn parse_clock(s: &str) -> Option<(String, &str)> {
1185 let b = s.as_bytes();
1186 if b.len() < 8 || b[2] != b':' || b[5] != b':' {
1187 return None;
1188 }
1189 if !(b[..2].iter().all(u8::is_ascii_digit)
1190 && b[3..5].iter().all(u8::is_ascii_digit)
1191 && b[6..8].iter().all(u8::is_ascii_digit))
1192 {
1193 return None;
1194 }
1195 let mut clock = s[..8].to_string();
1196 let mut rest = &s[8..];
1197 if let Some(fraction) = rest.strip_prefix(['.', ',']) {
1198 let digits = fraction.bytes().take_while(u8::is_ascii_digit).count();
1199 if digits > 0 {
1200 clock.push('.');
1201 clock.push_str(&fraction[..digits]);
1202 rest = &fraction[digits..];
1203 }
1204 }
1205 Some((clock, rest))
1206}
1207
1208fn zone_is_utc(tail: &str) -> bool {
1221 let t = tail.trim_start();
1222 if t.is_empty() {
1223 return true;
1224 }
1225 let ends_token = |rest: &str| rest.is_empty() || rest.starts_with(char::is_whitespace);
1226 if let Some(rest) = t.strip_prefix(['Z', 'z']) {
1227 return ends_token(rest);
1228 }
1229 for utc in ["+00:00", "-00:00", "+0000", "-0000"] {
1230 if let Some(rest) = t.strip_prefix(utc) {
1231 return ends_token(rest);
1232 }
1233 }
1234 if t.starts_with(['+', '-']) {
1236 return false;
1237 }
1238 if let Some(rest) = t.strip_prefix("UTC").or_else(|| t.strip_prefix("GMT")) {
1239 return ends_token(rest);
1240 }
1241 true
1243}
1244
1245const MONTH_ABBREVIATIONS: &[&str] = &[
1246 "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
1247];
1248
1249fn is_syslog_timestamp(s: &str) -> bool {
1252 let mut tokens = s.split_whitespace();
1253 let Some(month) = tokens.next() else {
1254 return false;
1255 };
1256 if !MONTH_ABBREVIATIONS.contains(&month.to_ascii_lowercase().as_str()) {
1257 return false;
1258 }
1259 let Some(day) = tokens.next() else {
1260 return false;
1261 };
1262 if day.is_empty() || day.len() > 2 || !day.bytes().all(|b| b.is_ascii_digit()) {
1263 return false;
1264 }
1265 tokens
1266 .next()
1267 .is_some_and(|clock| parse_clock(clock).is_some())
1268}
1269
1270fn distill_table(full: &str) -> String {
1273 let lines: Vec<&str> = full.lines().filter(|l| !l.trim().is_empty()).collect();
1274 let delimiter = table_delimiter(&lines).unwrap_or(if lines.iter().any(|l| l.contains('|')) {
1278 TableDelimiter::Pipe
1279 } else {
1280 TableDelimiter::Tab
1281 });
1282
1283 let mut rows: Vec<Vec<String>> = lines.iter().map(|l| split_row(l, delimiter)).collect();
1284 rows.retain(|r| !r.iter().all(|c| is_separator_cell(c)));
1286 if rows.is_empty() {
1287 return full.to_string();
1288 }
1289
1290 let header = rows.remove(0);
1291 let cols = header.len();
1292 let data = rows;
1293
1294 let mut column_summaries: Vec<String> = Vec::with_capacity(cols);
1295 for (idx, name) in header.iter().enumerate() {
1296 let cells: Vec<&str> = data
1300 .iter()
1301 .map(|r| r.get(idx).map_or("", String::as_str))
1302 .collect();
1303 column_summaries.push(format!("{name} ({})", infer_column_type(&cells)));
1304 }
1305
1306 let mut out = String::new();
1307 out.push_str(&format!("[{} rows × {cols} columns]\n", data.len()));
1308 out.push_str(&format!("columns: {}\n", column_summaries.join(", ")));
1309 out.push_str("sample:\n");
1310 out.push_str(&header.join(" | "));
1311 out.push('\n');
1312 for row in data.iter().take(TABLE_SAMPLE) {
1313 out.push_str(&row.join(" | "));
1314 out.push('\n');
1315 }
1316 if data.len() > TABLE_SAMPLE {
1317 out.push_str(&format!("… ({} more rows)", data.len() - TABLE_SAMPLE));
1318 }
1319 out.trim_end().to_string()
1320}
1321
1322fn is_separator_cell(cell: &str) -> bool {
1323 let c = cell.trim();
1324 !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':')
1325}
1326
1327fn infer_column_type(cells: &[&str]) -> String {
1337 let values: Vec<&str> = cells.iter().copied().filter(|c| !is_null_cell(c)).collect();
1338 let nullable = values.len() < cells.len();
1339 if values.is_empty() {
1340 return "empty".to_string();
1341 }
1342 let all = |predicate: fn(&str) -> bool| values.iter().all(|s| predicate(s));
1343 let base = if all(is_percent) {
1344 "percent"
1345 } else if all(is_currency) {
1346 "currency"
1347 } else if all(|s| number_shape(s) == Some(NumberShape::Integer)) {
1348 "int"
1349 } else if all(|s| number_shape(s).is_some()) {
1350 "float"
1351 } else if all(|s| matches!(s.to_ascii_lowercase().as_str(), "true" | "false")) {
1352 "bool"
1353 } else if all(looks_like_datetime) {
1354 "timestamp"
1355 } else {
1356 "text"
1357 };
1358 if nullable {
1359 format!("{base}?")
1360 } else {
1361 base.to_string()
1362 }
1363}
1364
1365fn is_null_cell(cell: &str) -> bool {
1372 let c = cell.trim();
1373 c.is_empty()
1374 || matches!(
1375 c.to_ascii_lowercase().as_str(),
1376 "null" | "nil" | "none" | "n/a" | "na" | "nan" | "-" | "—"
1377 )
1378}
1379
1380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1383enum NumberShape {
1384 Integer,
1385 Fractional,
1386}
1387
1388fn number_shape(s: &str) -> Option<NumberShape> {
1391 let body = s.trim();
1392 let body = body.strip_prefix(['-', '+']).unwrap_or(body);
1393 let (integer, fraction) = match body.split_once('.') {
1394 Some((integer, fraction)) => (integer, Some(fraction)),
1395 None => (body, None),
1396 };
1397 if !is_grouped_digits(integer) {
1398 return None;
1399 }
1400 match fraction {
1401 None => Some(NumberShape::Integer),
1402 Some(f) if !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()) => {
1403 Some(NumberShape::Fractional)
1404 }
1405 Some(_) => None,
1406 }
1407}
1408
1409fn is_grouped_digits(s: &str) -> bool {
1413 if s.is_empty() {
1414 return false;
1415 }
1416 if !s.contains(',') {
1417 return s.bytes().all(|b| b.is_ascii_digit());
1418 }
1419 let mut groups = s.split(',');
1420 let head = groups.next().unwrap_or("");
1421 if head.is_empty() || head.len() > 3 || !head.bytes().all(|b| b.is_ascii_digit()) {
1422 return false;
1423 }
1424 groups.all(|g| g.len() == 3 && g.bytes().all(|b| b.is_ascii_digit()))
1425}
1426
1427fn is_percent(s: &str) -> bool {
1429 s.trim()
1430 .strip_suffix('%')
1431 .is_some_and(|number| number_shape(number).is_some())
1432}
1433
1434const CURRENCY_SYMBOLS: &[char] = &['$', '€', '£', '¥', '₹', '₽'];
1435
1436fn is_currency(s: &str) -> bool {
1440 let t = s.trim();
1441 let t = t
1442 .strip_prefix('(')
1443 .and_then(|inner| inner.strip_suffix(')'))
1444 .unwrap_or(t);
1445 let body = t.strip_prefix(['-', '+']).unwrap_or(t);
1446 if let Some(rest) = body.strip_prefix(CURRENCY_SYMBOLS) {
1447 return number_shape(rest.trim_start()).is_some();
1448 }
1449 if let Some(rest) = body.strip_suffix(CURRENCY_SYMBOLS) {
1450 return number_shape(rest.trim_end()).is_some();
1451 }
1452 body.rsplit_once(' ').is_some_and(|(number, code)| {
1453 code.len() == 3
1454 && code.bytes().all(|b| b.is_ascii_uppercase())
1455 && number_shape(number).is_some()
1456 })
1457}
1458
1459fn looks_like_datetime(s: &str) -> bool {
1460 if is_protocol_timestamp(s) {
1461 return true;
1462 }
1463 let b = s.as_bytes();
1465 b.len() >= 8 && b[..4].iter().all(u8::is_ascii_digit) && b.get(4) == Some(&b'-')
1466}
1467
1468fn distill_code(full: &str) -> String {
1471 let lines: Vec<&str> = full.lines().collect();
1472 let total = lines.len();
1473 if total <= CODE_HEAD + CODE_TAIL {
1474 return full.to_string();
1475 }
1476 let mut out = String::new();
1477 for line in &lines[..CODE_HEAD] {
1478 out.push_str(line);
1479 out.push('\n');
1480 }
1481 out.push_str(&format!(
1482 "… ({} lines elided) …\n",
1483 total - CODE_HEAD - CODE_TAIL
1484 ));
1485 for line in &lines[total - CODE_TAIL..] {
1486 out.push_str(line);
1487 out.push('\n');
1488 }
1489 out.trim_end().to_string()
1490}
1491
1492struct Artifact {
1499 id: String,
1500 kind: FrameKind,
1501 title: String,
1502 citation_label: String,
1503 score: f32,
1504 full_content: String,
1506 address_hash: String,
1508 inline_content: String,
1511 transform: Transform,
1512 fidelity: ContentFidelity,
1513 compacted: bool,
1515 valid_from: Option<String>,
1516 valid_to: Option<String>,
1517 summary: String,
1518}
1519
1520impl Artifact {
1521 fn build(kind: SegmentKind, full_content: String) -> Self {
1522 let frame_kind = kind
1523 .frame_kind()
1524 .expect("PathRef is routed to anchors before build");
1525 let address_hash = sha256_digest(&full_content);
1526 let id = format!("frm_{}", short_hash(&address_hash));
1527
1528 let (distilled, verbatim_transform, distilled_transform, distilled_fidelity, vf, vt) =
1530 match kind {
1531 SegmentKind::Log => {
1532 let (inline, vf, vt) = distill_log(&full_content);
1533 (
1534 inline,
1535 verbatim_transform(),
1536 transform("extractive_summary"),
1537 ContentFidelity::Summarized,
1538 vf,
1539 vt,
1540 )
1541 }
1542 SegmentKind::StackTrace => {
1543 let at = leading_instant(full_content.lines().next());
1546 (
1547 distill_stack_trace(&full_content),
1548 verbatim_transform(),
1549 transform("stack_frame_head"),
1550 ContentFidelity::Summarized,
1551 at.clone(),
1552 at,
1553 )
1554 }
1555 SegmentKind::Table => (
1556 distill_table(&full_content),
1557 verbatim_transform(),
1558 transform("tabular_sample"),
1559 ContentFidelity::Summarized,
1560 None,
1561 None,
1562 ),
1563 SegmentKind::Code => (
1564 distill_code(&full_content),
1565 verbatim_transform(),
1566 transform("truncation"),
1567 ContentFidelity::Summarized,
1568 None,
1569 None,
1570 ),
1571 SegmentKind::Prose | SegmentKind::PathRef => (
1573 full_content.clone(),
1574 verbatim_transform(),
1575 verbatim_transform(),
1576 ContentFidelity::Exact,
1577 None,
1578 None,
1579 ),
1580 };
1581
1582 let full_tokens = budget_tokens(&full_content);
1583 let worth_compacting = kind != SegmentKind::Prose
1584 && full_tokens > COMPACT_MIN_TOKENS
1585 && budget_tokens(&distilled) < full_tokens;
1586
1587 let (inline_content, transform, fidelity, compacted) = if worth_compacting {
1588 (distilled, distilled_transform, distilled_fidelity, true)
1589 } else {
1590 (
1591 full_content.clone(),
1592 verbatim_transform,
1593 ContentFidelity::Exact,
1594 false,
1595 )
1596 };
1597
1598 let line_count = full_content.lines().count();
1599 let title = match kind {
1600 SegmentKind::Log => format!("log · {line_count} lines"),
1601 SegmentKind::StackTrace => format!("stack trace · {line_count} lines"),
1602 SegmentKind::Table => format!("table · {line_count} lines"),
1603 SegmentKind::Code => format!("code · {line_count} lines"),
1604 SegmentKind::Prose => "note".to_string(),
1605 SegmentKind::PathRef => "path".to_string(),
1606 };
1607 let summary = if compacted {
1608 format!(
1609 "{title} · {} → {} tokens",
1610 full_tokens,
1611 budget_tokens(&inline_content)
1612 )
1613 } else {
1614 format!("{title} · {full_tokens} tokens")
1615 };
1616
1617 Self {
1618 id,
1619 kind: frame_kind,
1620 title,
1621 citation_label: kind.citation_label().to_string(),
1622 score: kind.score(),
1623 full_content,
1624 address_hash,
1625 inline_content,
1626 transform,
1627 fidelity,
1628 compacted,
1629 valid_from: vf,
1630 valid_to: vt,
1631 summary,
1632 }
1633 }
1634
1635 fn inline_tokens(&self) -> u32 {
1637 budget_tokens(&self.inline_content)
1638 }
1639
1640 fn content_ref(&self, provider_id: &str) -> ContentRef {
1641 ContentRef {
1642 provider_id: provider_id.to_string(),
1643 uri: format!("context://{provider_id}/artifacts/{}", self.address_hash),
1645 expires_at: None,
1646 }
1647 }
1648
1649 fn provenance(&self) -> Provenance {
1653 Provenance {
1654 kind: "derivation".to_string(),
1655 uri: None,
1656 range: None,
1657 digest: None,
1658 method: Some("paste".to_string()),
1659 by: Some(TRANSFORM_IMPL.to_string()),
1660 }
1661 }
1662
1663 fn served_digests(&self) -> Vec<String> {
1666 let mut digests = vec![self.address_hash.clone()];
1667 if self.compacted {
1668 let inline = sha256_digest(&self.inline_content);
1669 if inline != self.address_hash {
1670 digests.push(inline);
1671 }
1672 }
1673 digests
1674 }
1675
1676 fn apply_common(&self, frame: &mut ContextFrame) {
1677 frame.citation_label = Some(self.citation_label.clone());
1678 frame.provenance = vec![self.provenance()];
1679 frame.inline_content_requirement =
1680 Some(InlineContentRequirement::ResolvableReferenceAllowed);
1681 frame.valid_from = self.valid_from.clone();
1682 frame.valid_to = self.valid_to.clone();
1683 }
1684
1685 fn as_full(&self) -> ContextFrame {
1688 let content = self.full_content.clone();
1689 let cost = budget_tokens(&content);
1690 let mut frame = ContextFrame::full(
1691 self.id.clone(),
1692 self.kind,
1693 self.title.clone(),
1694 content,
1695 self.score,
1696 cost,
1697 );
1698 frame.content_digest = Some(self.address_hash.clone());
1699 frame.content_fidelity = Some(ContentFidelity::Exact);
1700 self.apply_common(&mut frame);
1701 frame
1702 }
1703
1704 fn as_compact(&self, provider_id: &str) -> ContextFrame {
1708 let inline = self.inline_content.clone();
1709 let cost = budget_tokens(&inline);
1710 let mut frame = ContextFrame::full(
1711 self.id.clone(),
1712 self.kind,
1713 self.title.clone(),
1714 inline.clone(),
1715 self.score,
1716 cost,
1717 );
1718 frame.representation = Representation::Compact;
1719 frame.content_digest = Some(sha256_digest(&inline));
1720 frame.canonical_content_hash = Some(self.address_hash.clone());
1721 frame.canonical_token_cost = Some(budget_tokens(&self.full_content));
1722 frame.transform = Some(self.transform.clone());
1723 frame.content_ref = Some(self.content_ref(provider_id));
1724 frame.content_fidelity = Some(self.fidelity);
1725 self.apply_common(&mut frame);
1726 frame
1727 }
1728
1729 fn as_reference(&self, provider_id: &str) -> ContextFrame {
1732 let mut frame = ContextFrame::reference(
1733 self.id.clone(),
1734 self.kind,
1735 self.title.clone(),
1736 self.content_ref(provider_id),
1737 self.address_hash.clone(),
1738 self.score,
1739 );
1740 frame.canonical_token_cost = Some(budget_tokens(&self.full_content));
1741 frame.content_fidelity = Some(ContentFidelity::Omitted);
1742 self.apply_common(&mut frame);
1743 frame
1744 }
1745
1746 fn as_representation(&self, provider_id: &str, representation: Representation) -> ContextFrame {
1747 match representation {
1748 Representation::Full => self.as_full(),
1749 Representation::Compact => self.as_compact(provider_id),
1750 Representation::Reference => self.as_reference(provider_id),
1751 }
1752 }
1753}
1754
1755fn transform(method: &str) -> Transform {
1756 Transform {
1757 method: method.to_string(),
1758 implementation: TRANSFORM_IMPL.to_string(),
1759 version: TRANSFORM_VERSION.to_string(),
1760 }
1761}
1762
1763fn verbatim_transform() -> Transform {
1764 transform("verbatim")
1765}
1766
1767pub struct IngestProvider {
1779 id: String,
1780 info: ProviderInfo,
1781 capabilities: Capabilities,
1782 artifacts: Vec<Artifact>,
1783}
1784
1785impl IngestProvider {
1786 fn new(id: impl Into<String>, artifacts: Vec<Artifact>) -> Self {
1787 let id = id.into();
1788 let mut kinds: Vec<String> = artifacts
1789 .iter()
1790 .map(|a| frame_kind_name(a.kind).to_string())
1791 .collect();
1792 kinds.sort();
1793 kinds.dedup();
1794
1795 let info = ProviderInfo {
1796 name: DEFAULT_PROVIDER_ID.to_string(),
1797 version: env!("CARGO_PKG_VERSION").to_string(),
1798 data_flow: DataFlow {
1801 reads: true,
1802 writes: false,
1803 egress: false,
1804 egress_scopes: vec![EgressScope::LocalOnly],
1805 },
1806 };
1807 let capabilities = Capabilities {
1808 query: QueryCapability { kinds },
1809 correlation: false,
1810 graph: false,
1811 embeddings_fingerprint: None,
1812 verify: true,
1813 representations: vec![
1814 Representation::Full,
1815 Representation::Compact,
1816 Representation::Reference,
1817 ],
1818 resolve: true,
1819 };
1820 Self {
1821 id,
1822 info,
1823 capabilities,
1824 artifacts,
1825 }
1826 }
1827
1828 fn default_budget_tokens(&self) -> u32 {
1831 self.artifacts.iter().map(Artifact::inline_tokens).sum()
1832 }
1833
1834 pub fn len(&self) -> usize {
1836 self.artifacts.len()
1837 }
1838
1839 pub fn is_empty(&self) -> bool {
1840 self.artifacts.is_empty()
1841 }
1842}
1843
1844#[async_trait]
1845impl ContextProvider for IngestProvider {
1846 fn id(&self) -> &str {
1847 &self.id
1848 }
1849
1850 fn info(&self) -> &ProviderInfo {
1851 &self.info
1852 }
1853
1854 fn capabilities(&self) -> &Capabilities {
1855 &self.capabilities
1856 }
1857
1858 async fn query(&self, query: &ContextQuery) -> Result<ContextQueryResult, HostError> {
1859 let representation = query
1862 .select_representation(&[
1863 Representation::Full,
1864 Representation::Compact,
1865 Representation::Reference,
1866 ])
1867 .unwrap_or(Representation::Full);
1868
1869 let mut candidates: Vec<ContextFrame> = self
1870 .artifacts
1871 .iter()
1872 .filter(|a| query.kinds.is_empty() || query.kinds.contains(&a.kind))
1873 .map(|a| a.as_representation(&self.id, representation))
1874 .collect();
1875
1876 candidates.sort_by(|a, b| {
1878 b.score
1879 .partial_cmp(&a.score)
1880 .unwrap_or(std::cmp::Ordering::Equal)
1881 .then_with(|| a.id.cmp(&b.id))
1882 });
1883
1884 let mut frames: Vec<ContextFrame> = Vec::new();
1887 let mut used: u64 = 0;
1888 let mut dropped: u32 = 0;
1889 for frame in candidates {
1890 if frames.len() as u32 >= query.max_frames {
1891 dropped += 1;
1892 continue;
1893 }
1894 let cost = frame.token_cost as u64;
1895 if used + cost > query.max_tokens as u64 {
1896 dropped += 1;
1897 continue;
1898 }
1899 used += cost;
1900 frames.push(frame);
1901 }
1902
1903 Ok(ContextQueryResult {
1904 frames,
1905 truncated: dropped > 0,
1906 dropped_estimate: (dropped > 0).then_some(dropped),
1907 })
1908 }
1909
1910 async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
1911 let verdicts = request
1912 .frames
1913 .iter()
1914 .map(|held| {
1915 let verdict = match self.artifacts.iter().find(|a| a.id == held.frame_id) {
1916 Some(artifact) => match &held.content_digest {
1919 Some(digest) if artifact.served_digests().contains(digest) => {
1920 Verdict::Valid
1921 }
1922 Some(_) => Verdict::Stale {
1923 replacement_digest: Some(artifact.address_hash.clone()),
1924 },
1925 None => Verdict::Unknown,
1928 },
1929 None => Verdict::Gone,
1932 };
1933 FrameVerdict::new(held.clone(), verdict)
1934 })
1935 .collect();
1936 Ok(VerifyResponse::new(verdicts))
1937 }
1938}
1939
1940#[cfg(test)]
1941mod tests;