//! Workspace- and Memory-independent exploration of an immutable ordered session capture. //! //! Hosts construct a capture from committed session items. The capture excludes reasoning, //! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded //! range/search, read, and generic evidence projections without granting mutation authority. use std::sync::Arc; use llm_engine::{Item, Role}; use serde::{Deserialize, Serialize}; const DEFAULT_SEARCH_LIMIT: usize = 20; const MAX_SEARCH_LIMIT: usize = 50; const DEFAULT_READ_MAX_ITEMS: usize = 40; const MAX_READ_MAX_ITEMS: usize = 80; const DEFAULT_READ_MAX_BYTES: usize = 32 * 1024; const OVERVIEW_ANCHOR_STRIDE: usize = 8; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub(crate) struct SessionEntryRef(String); impl SessionEntryRef { pub(crate) fn new(source_index: usize) -> Self { Self(format!("E{source_index:08}")) } pub(crate) fn parse(value: &str) -> Option { let reference = Self(value.to_string()); reference.source_index()?; Some(reference) } pub(crate) fn as_str(&self) -> &str { &self.0 } pub(crate) fn source_index(&self) -> Option { self.0.strip_prefix('E')?.parse().ok() } } impl std::fmt::Display for SessionEntryRef { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(&self.0) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ReferenceKind { User, Assistant, Tool, } impl ReferenceKind { pub(crate) fn as_str(self) -> &'static str { match self { Self::User => "user", Self::Assistant => "assistant", Self::Tool => "tool", } } pub(crate) fn parse(value: &str) -> Option { match value { "user" => Some(Self::User), "assistant" | "agent" => Some(Self::Assistant), "tool" => Some(Self::Tool), _ => None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ToolPart { Input, Output, Both, } impl ToolPart { pub(crate) fn parse(value: &str) -> Option { match value { "input" => Some(Self::Input), "output" => Some(Self::Output), "both" => Some(Self::Both), _ => None, } } fn matches(self, actual: ToolPart) -> bool { matches!(self, ToolPart::Both) || self == actual } } #[derive(Debug, Clone)] pub(crate) struct OverviewItem { pub id: SessionEntryRef, pub entry_range: [u64; 2], pub kind: ReferenceKind, pub label: String, pub text: String, pub intervening_entries: usize, } #[derive(Debug, Clone)] pub(crate) struct ReferenceEntry { pub id: SessionEntryRef, pub entry_range: [u64; 2], pub kind: ReferenceKind, pub tool_part: Option, pub tool_name: Option, pub label: String, pub summary: String, search_text: String, } #[derive(Debug, Clone, Default)] pub(crate) struct SearchOptions { pub query: String, pub kind: Option, pub tool_part: Option, pub tool_name: Option, pub limit: Option, pub min_entry_index: Option, pub from: Option, pub through: Option, pub offset: usize, } #[derive(Debug, Clone)] pub(crate) struct SearchHit { pub id: SessionEntryRef, pub kind: ReferenceKind, pub tool_part: Option, pub tool_name: Option, pub entry_range: [u64; 2], pub label: String, pub summary: String, } #[derive(Debug, Clone, Copy)] pub(crate) enum ReadSelector<'a> { Id(&'a str), EntryRange([u64; 2]), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ReadDetail { Compact, Full, } #[derive(Debug, Clone)] pub(crate) struct ReadOptions { pub include_tools: bool, pub tool_part: ToolPart, pub detail: ReadDetail, pub max_items: usize, pub max_bytes: usize, } impl Default for ReadOptions { fn default() -> Self { Self { include_tools: true, tool_part: ToolPart::Both, detail: ReadDetail::Compact, max_items: DEFAULT_READ_MAX_ITEMS, max_bytes: DEFAULT_READ_MAX_BYTES, } } } #[derive(Debug, Clone)] pub(crate) struct ReadEntry { pub id: SessionEntryRef, pub kind: ReferenceKind, pub tool_part: Option, pub tool_name: Option, pub entry_range: [u64; 2], pub label: String, pub text: String, } #[derive(Debug, Clone)] pub(crate) struct ReadResult { pub entries: Vec, pub truncated: bool, } #[derive(Debug, Clone)] pub(crate) struct SessionEntryEvidence { pub segment_id: String, pub entry_ref: SessionEntryRef, pub entry_range: [u64; 2], pub kind: ReferenceKind, pub tool_part: Option, pub label: String, pub summary: String, pub excerpt: String, } #[derive(Debug, Clone)] pub(crate) struct SessionCapture { segment_id: String, items: Arc>, overview: Vec, index: Vec, } impl SessionCapture { pub(crate) fn new(segment_id: impl Into, items: Vec) -> Self { let segment_id = segment_id.into(); let items = Arc::new(items); let mut overview = Vec::new(); let mut index = Vec::new(); for (idx, item) in items.iter().enumerate() { let entry_range = [idx as u64, idx as u64]; match item { Item::Message { role, content, .. } => { let kind = match role { Role::User => ReferenceKind::User, Role::Assistant => ReferenceKind::Assistant, Role::System => continue, }; let text = content .iter() .map(|p| p.as_text()) .collect::>() .join(""); let label = format!("{} message", kind.as_str()); let summary = truncate_chars(&text, 240); let id = SessionEntryRef::new(idx); index.push(ReferenceEntry { id: id.clone(), entry_range, kind, tool_part: None, tool_name: None, label: label.clone(), summary: summary.clone(), search_text: text.clone(), }); if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) { overview.push(OverviewItem { id: id.clone(), entry_range, kind, label, text, intervening_entries: 0, }); } } Item::ToolCall { name, arguments, .. } => { let text = format!("{name}\n{arguments}"); index.push(ReferenceEntry { id: SessionEntryRef::new(idx), entry_range, kind: ReferenceKind::Tool, tool_part: Some(ToolPart::Input), tool_name: Some(name.clone()), label: format!("tool input: {name}"), summary: format!("Tool call {name}"), search_text: text, }); } Item::ToolResult { summary, content, attachments, .. } => { let attachment_marker = if attachments.is_empty() { String::new() } else { format!("\n[{} image attachment(s)]", attachments.len()) }; let text = format!( "{summary}\n{}{attachment_marker}", content.as_deref().unwrap_or_default(), ); index.push(ReferenceEntry { id: SessionEntryRef::new(idx), entry_range, kind: ReferenceKind::Tool, tool_part: Some(ToolPart::Output), tool_name: None, label: "tool output".to_string(), summary: truncate_chars(summary, 240), search_text: text, }); } Item::Reasoning { .. } => {} } } if overview.len() > 2 { let last = overview.len() - 1; overview = overview .into_iter() .enumerate() .filter_map(|(index, entry)| { (index == 0 || index == last || index % OVERVIEW_ANCHOR_STRIDE == 0) .then_some(entry) }) .collect(); } for overview_index in 0..overview.len().saturating_sub(1) { let current_entry = overview[overview_index].entry_range[0]; let next_entry = overview[overview_index + 1].entry_range[0]; overview[overview_index].intervening_entries = index .iter() .filter(|entry| { let entry_index = entry.entry_range[0]; entry_index > current_entry && entry_index < next_entry }) .count(); } Self { segment_id, items, overview, index, } } pub(crate) fn overview(&self) -> &[OverviewItem] { &self.overview } pub(crate) fn search(&self, options: &SearchOptions) -> Vec { let query = options.query.trim().to_lowercase(); let limit = options .limit .unwrap_or(DEFAULT_SEARCH_LIMIT) .clamp(1, MAX_SEARCH_LIMIT); let tool_name = options.tool_name.as_deref(); let min_entry_index = options .from .as_ref() .and_then(SessionEntryRef::source_index) .unwrap_or_else(|| options.min_entry_index.unwrap_or(0)); let max_entry_index = options .through .as_ref() .and_then(SessionEntryRef::source_index) .unwrap_or(u64::MAX); let mut skipped = 0usize; let mut hits = Vec::new(); for entry in &self.index { if entry.entry_range[0] < min_entry_index || entry.entry_range[0] > max_entry_index { continue; } if let Some(kind) = options.kind { if entry.kind != kind { continue; } } if let Some(part) = options.tool_part { if entry.kind != ReferenceKind::Tool { continue; } let Some(actual) = entry.tool_part else { continue; }; if !part.matches(actual) { continue; } } if let Some(name) = tool_name { if entry.tool_name.as_deref() != Some(name) { continue; } } if !query.is_empty() && !entry.search_text.to_lowercase().contains(&query) { continue; } if skipped < options.offset { skipped += 1; continue; } hits.push(SearchHit { id: entry.id.clone(), kind: entry.kind, tool_part: entry.tool_part, tool_name: entry.tool_name.clone(), entry_range: entry.entry_range, label: entry.label.clone(), summary: entry.summary.clone(), }); if hits.len() >= limit { break; } } hits } pub(crate) fn read(&self, selector: ReadSelector<'_>, options: ReadOptions) -> ReadResult { let max_items = options.max_items.clamp(1, MAX_READ_MAX_ITEMS); let max_bytes = options.max_bytes.max(1); let mut entries = Vec::new(); let mut bytes = 0usize; let mut truncated = false; let selected: Vec<&ReferenceEntry> = match selector { ReadSelector::Id(id) => self .index .iter() .filter(|entry| entry.id.as_str() == id) .collect(), ReadSelector::EntryRange([start, end]) => self .index .iter() .filter(|entry| entry.entry_range[0] >= start && entry.entry_range[0] <= end) .collect(), }; for entry in selected { if entries.len() >= max_items { truncated = true; break; } if entry.kind == ReferenceKind::Tool { if !options.include_tools { continue; } if let Some(actual) = entry.tool_part { if !options.tool_part.matches(actual) { continue; } } } let Some(item) = self.items.get(entry.entry_range[0] as usize) else { continue; }; let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes)); bytes = bytes.saturating_add(text.len()); entries.push(ReadEntry { id: entry.id.clone(), kind: entry.kind, tool_part: entry.tool_part, tool_name: entry.tool_name.clone(), entry_range: entry.entry_range, label: entry.label.clone(), text, }); if bytes >= max_bytes { truncated = true; break; } } ReadResult { entries, truncated } } pub(crate) fn evidence_for(&self, id: &str) -> Option { let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?; let excerpt = self .read( ReadSelector::Id(id), ReadOptions { include_tools: true, tool_part: ToolPart::Both, detail: ReadDetail::Compact, max_items: 1, max_bytes: 2 * 1024, }, ) .entries .first() .map(|entry| entry.text.clone()) .unwrap_or_else(|| entry.summary.clone()); Some(SessionEntryEvidence { segment_id: self.segment_id.clone(), entry_ref: entry.id.clone(), entry_range: entry.entry_range, kind: entry.kind, tool_part: entry.tool_part, label: entry.label.clone(), summary: entry.summary.clone(), excerpt, }) } } fn render_item( item: &Item, entry: &ReferenceEntry, detail: ReadDetail, max_bytes: usize, ) -> String { let text = match item { Item::Message { role, content, .. } => { let text = content .iter() .map(|p| p.as_text()) .collect::>() .join(""); format!("[{} {:?}] {text}", entry.id, role) } Item::ToolCall { name, arguments, .. } => match detail { ReadDetail::Compact => format!("[{} ToolInput {name}] (arguments omitted)", entry.id), ReadDetail::Full => format!("[{} ToolInput {name}]\narguments: {arguments}", entry.id), }, Item::ToolResult { summary, content, attachments, is_error, .. } => { let attachment_line = if attachments.is_empty() { String::new() } else { format!("\nattachments: {} image(s)", attachments.len()) }; match detail { ReadDetail::Compact => format!( "[{} ToolOutput{}]\nsummary: {summary}\ncontent: (omitted){attachment_line}", entry.id, if *is_error { " error" } else { "" }, ), ReadDetail::Full => format!( "[{} ToolOutput{}]\nsummary: {summary}\ncontent: {}{attachment_line}", entry.id, if *is_error { " error" } else { "" }, content.as_deref().unwrap_or_default(), ), } } Item::Reasoning { .. } => format!("[{} Reasoning omitted]", entry.id), }; truncate_chars(&text, max_bytes) } fn truncate_chars(text: &str, max_chars: usize) -> String { if max_chars == 0 { return "… [truncated]".to_string(); } if text.chars().count() <= max_chars { return text.to_string(); } let mut out = text.chars().take(max_chars).collect::(); out.push_str("… [truncated]"); out } #[cfg(test)] mod tests { use super::*; #[test] fn overview_contains_user_and_assistant_only() { let view = SessionCapture::new( "segment-1", vec![ Item::system_message("sys"), Item::user_message("hello"), Item::assistant_message("progress"), Item::tool_call("c1", "Read", "{\"file\":\"x\"}"), Item::tool_result("c1", "read ok"), ], ); let overview = view.overview(); assert_eq!(overview.len(), 2); assert_eq!(overview[0].kind, ReferenceKind::User); assert_eq!(overview[1].kind, ReferenceKind::Assistant); assert!(overview[1].text.contains("progress")); } #[test] fn search_filters_tool_input_and_output() { let view = SessionCapture::new( "segment-1", vec![ Item::tool_call("c1", "Read", "{\"file\":\"Cargo.toml\"}"), Item::tool_result_with_content("c1", "read ok", "package metadata"), ], ); let input_hits = view.search(&SearchOptions { query: "Cargo.toml".into(), kind: Some(ReferenceKind::Tool), tool_part: Some(ToolPart::Input), tool_name: Some("Read".into()), limit: None, min_entry_index: None, from: None, through: None, offset: 0, }); assert_eq!(input_hits.len(), 1); assert_eq!(input_hits[0].tool_part, Some(ToolPart::Input)); let output_hits = view.search(&SearchOptions { query: "package metadata".into(), kind: Some(ReferenceKind::Tool), tool_part: Some(ToolPart::Output), tool_name: None, limit: None, min_entry_index: None, from: None, through: None, offset: 0, }); assert_eq!(output_hits.len(), 1); assert_eq!(output_hits[0].tool_part, Some(ToolPart::Output)); } #[test] fn read_by_entry_range_is_bounded_and_can_skip_tools() { let view = SessionCapture::new( "segment-1", vec![ Item::user_message("one"), Item::tool_call("c1", "Read", "{}"), Item::tool_result_with_content("c1", "ok", "large-content".repeat(100)), Item::assistant_message("two"), ], ); let result = view.read( ReadSelector::EntryRange([0, 3]), ReadOptions { include_tools: false, detail: ReadDetail::Compact, max_items: 10, max_bytes: 1_000, ..ReadOptions::default() }, ); assert_eq!(result.entries.len(), 2); assert!( result .entries .iter() .all(|entry| entry.kind != ReferenceKind::Tool) ); } #[test] fn tool_image_projection_exposes_only_bounded_metadata() { let view = SessionCapture::new( "segment-1", vec![Item::tool_result_item_with_attachments( "c1", "attached", None, false, vec![llm_engine::tool::Attachment::Image( llm_engine::tool::ImageAttachment::new( "image/png", b"private-image-body".to_vec(), ), )], )], ); let result = view.read( ReadSelector::Id("E00000000"), ReadOptions { include_tools: true, detail: ReadDetail::Full, ..ReadOptions::default() }, ); assert_eq!(result.entries.len(), 1); assert!(result.entries[0].text.contains("attachments: 1 image(s)")); assert!(!result.entries[0].text.contains("private-image-body")); assert!(!result.entries[0].text.contains("cHJpdmF0ZS")); } #[test] fn system_prompt_and_reasoning_are_excluded_from_every_projection() { let view = SessionCapture::new( "segment-1", vec![ Item::system_message("raw secret system prompt"), Item::reasoning("private chain of thought"), Item::user_message("visible user entry"), ], ); let hits = view.search(&SearchOptions { query: String::new(), kind: None, tool_part: None, tool_name: None, limit: None, min_entry_index: None, from: None, through: None, offset: 0, }); assert_eq!(hits.len(), 1); assert_eq!(hits[0].id.as_str(), "E00000002"); assert!(!hits[0].summary.contains("secret")); assert!(!hits[0].summary.contains("chain of thought")); assert!( view.read(ReadSelector::Id("E00000000"), ReadOptions::default()) .entries .is_empty() ); assert!(view.evidence_for("E00000000").is_none()); assert_eq!(view.overview().len(), 1); assert_eq!(view.overview()[0].id.as_str(), "E00000002"); } #[test] fn overview_is_sparse_and_reports_intervening_non_reasoning_entries() { let items = (0..20) .map(|index| Item::user_message(format!("message-{index}"))) .collect::>(); let view = SessionCapture::new("segment-1", items); let refs = view .overview() .iter() .map(|entry| entry.id.as_str()) .collect::>(); assert_eq!( refs, vec!["E00000000", "E00000008", "E00000016", "E00000019"] ); assert_eq!(view.overview()[0].intervening_entries, 7); assert_eq!(view.overview()[1].intervening_entries, 7); assert_eq!(view.overview()[2].intervening_entries, 2); } #[test] fn append_preserves_existing_session_entry_refs() { let first = SessionCapture::new( "segment-1", vec![ Item::user_message("first"), Item::assistant_message("second"), ], ); let appended = SessionCapture::new( "segment-1", vec![ Item::user_message("first"), Item::assistant_message("second"), Item::user_message("third"), ], ); let first_refs = first .search(&SearchOptions { query: String::new(), kind: None, tool_part: None, tool_name: None, limit: None, min_entry_index: None, from: None, through: None, offset: 0, }) .into_iter() .map(|entry| entry.id) .collect::>(); let appended_refs = appended .search(&SearchOptions { query: String::new(), kind: None, tool_part: None, tool_name: None, limit: None, min_entry_index: None, from: None, through: None, offset: 0, }) .into_iter() .take(2) .map(|entry| entry.id) .collect::>(); assert_eq!(first_refs, appended_refs); } #[test] fn evidence_projection_uses_entry_range_and_session_entry_ref() { let view = SessionCapture::new("segment-1", vec![Item::user_message("hello")]); let source = view.evidence_for("E00000000").unwrap(); assert_eq!(source.segment_id, "segment-1"); assert_eq!(source.entry_range, [0, 0]); assert_eq!(source.entry_ref.as_str(), "E00000000"); } }