| 1 | //! One typed, capped, marker-stable ModelContext fragment. |
| 2 | //! |
| 3 | //! Caps and identities are unified with `codewhale_core::fragments` — the |
| 4 | //! single `crates/core` owner for the bounded fragment system (issue #5264). |
| 5 | //! This crate re-exports the core caps so every injection goes through a |
| 6 | //! typed fragment with a stable marker and the hard caps |
| 7 | //! (per-fragment size, 10K-token ceiling, injected-item count) are enforced |
| 8 | //! in one place. |
| 9 | |
| 10 | use std::collections::hash_map::DefaultHasher; |
| 11 | use std::hash::{Hash, Hasher}; |
| 12 | |
| 13 | /// Re-export the core hard caps — single source of truth. |
| 14 | pub use codewhale_core::fragments::{ |
| 15 | DEFAULT_FRAGMENT_MAX_BYTES, MAX_FRAGMENT_BYTES, MAX_FRAGMENT_TOKENS, MAX_FRAGMENTS_PER_CONTEXT, |
| 16 | }; |
| 17 | |
| 18 | /// Stable identity for a WorldState concern. Markers are public contract — |
| 19 | /// do not rename without a migration note (prefix-cache + tests pin them). |
| 20 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 21 | pub enum FragmentId { |
| 22 | Workspace, |
| 23 | Permissions, |
| 24 | Route, |
| 25 | AgentTopology, |
| 26 | SkillsTools, |
| 27 | TokenBudget, |
| 28 | ProjectInstructions, |
| 29 | Constitution, |
| 30 | } |
| 31 | |
| 32 | impl FragmentId { |
| 33 | #[must_use] |
| 34 | #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011) |
| 35 | pub fn as_str(self) -> &'static str { |
| 36 | match self { |
| 37 | Self::Workspace => "workspace", |
| 38 | Self::Permissions => "permissions", |
| 39 | Self::Route => "route", |
| 40 | Self::AgentTopology => "agent_topology", |
| 41 | Self::SkillsTools => "skills_tools", |
| 42 | Self::TokenBudget => "token_budget", |
| 43 | Self::ProjectInstructions => "project_instructions", |
| 44 | Self::Constitution => "constitution", |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /// Stable HTML-comment marker wrapping the fragment body. |
| 49 | #[must_use] |
| 50 | pub fn marker(self) -> &'static str { |
| 51 | match self { |
| 52 | Self::Workspace => "<!-- cw:ctx:workspace -->", |
| 53 | Self::Permissions => "<!-- cw:ctx:permissions -->", |
| 54 | Self::Route => "<!-- cw:ctx:route -->", |
| 55 | Self::AgentTopology => "<!-- cw:ctx:agent_topology -->", |
| 56 | Self::SkillsTools => "<!-- cw:ctx:skills_tools -->", |
| 57 | Self::TokenBudget => "<!-- cw:ctx:token_budget -->", |
| 58 | Self::ProjectInstructions => "<!-- cw:ctx:project_instructions -->", |
| 59 | Self::Constitution => "<!-- cw:ctx:constitution -->", |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | #[must_use] |
| 64 | #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011) |
| 65 | pub fn role(self) -> FragmentRole { |
| 66 | match self { |
| 67 | Self::Workspace => FragmentRole::Workspace, |
| 68 | Self::Permissions => FragmentRole::Permissions, |
| 69 | Self::Route => FragmentRole::Route, |
| 70 | Self::AgentTopology => FragmentRole::AgentTopology, |
| 71 | Self::SkillsTools => FragmentRole::SkillsTools, |
| 72 | Self::TokenBudget => FragmentRole::TokenBudget, |
| 73 | Self::ProjectInstructions => FragmentRole::ProjectInstructions, |
| 74 | Self::Constitution => FragmentRole::Constitution, |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | #[must_use] |
| 79 | #[allow(dead_code)] // ordered enumeration for host rebuilds / inspectors (TUI-DOG-011) |
| 80 | pub fn all() -> &'static [FragmentId] { |
| 81 | &[ |
| 82 | Self::Workspace, |
| 83 | Self::Permissions, |
| 84 | Self::Route, |
| 85 | Self::AgentTopology, |
| 86 | Self::SkillsTools, |
| 87 | Self::TokenBudget, |
| 88 | Self::ProjectInstructions, |
| 89 | Self::Constitution, |
| 90 | ] |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /// Explicit role of a fragment relative to the cache-stable constitution. |
| 95 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 96 | pub enum FragmentRole { |
| 97 | /// Workspace / repo working-set facts (volatile across sessions). |
| 98 | Workspace, |
| 99 | /// Approval / permission posture. |
| 100 | Permissions, |
| 101 | /// Active route, model, and app mode. |
| 102 | Route, |
| 103 | /// Sub-agent topology and recent completion notices. |
| 104 | AgentTopology, |
| 105 | /// Skills, plugins, and tool availability summary. |
| 106 | SkillsTools, |
| 107 | /// Token budget and compaction status. |
| 108 | TokenBudget, |
| 109 | /// Project instructions (AGENTS.md + imported instruction files). |
| 110 | ProjectInstructions, |
| 111 | /// Codewhale-specific repo constitution. |
| 112 | Constitution, |
| 113 | } |
| 114 | |
| 115 | impl FragmentRole { |
| 116 | #[must_use] |
| 117 | #[allow(dead_code)] // public role labels for inspectors / diffs (TUI-DOG-011) |
| 118 | pub fn as_str(self) -> &'static str { |
| 119 | match self { |
| 120 | Self::Workspace => "workspace", |
| 121 | Self::Permissions => "permissions", |
| 122 | Self::Route => "route", |
| 123 | Self::AgentTopology => "agent_topology", |
| 124 | Self::SkillsTools => "skills_tools", |
| 125 | Self::TokenBudget => "token_budget", |
| 126 | Self::ProjectInstructions => "project_instructions", |
| 127 | Self::Constitution => "constitution", |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /// Result of comparing a fragment against its previous render. |
| 133 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 134 | pub enum FragmentRender { |
| 135 | /// Content hash matches previous — retain bytes; do not reinject. |
| 136 | Unchanged { marker: String, content_hash: u64 }, |
| 137 | /// New or changed content — inject the capped body. |
| 138 | Updated { fragment: ModelContextFragment }, |
| 139 | /// Fragment was present before and is now absent. |
| 140 | #[allow(dead_code)] // produced by WorldState::clear; hosts wire clear next (TUI-DOG-011) |
| 141 | Cleared { marker: String }, |
| 142 | } |
| 143 | |
| 144 | /// One capped WorldState section with a stable marker and content hash. |
| 145 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 146 | pub struct ModelContextFragment { |
| 147 | pub id: FragmentId, |
| 148 | pub role: FragmentRole, |
| 149 | pub marker: &'static str, |
| 150 | pub max_bytes: usize, |
| 151 | pub content: String, |
| 152 | pub content_hash: u64, |
| 153 | } |
| 154 | |
| 155 | impl ModelContextFragment { |
| 156 | #[must_use] |
| 157 | pub fn new(id: FragmentId, role: FragmentRole, raw: impl Into<String>) -> Self { |
| 158 | Self::with_max_bytes(id, role, raw, DEFAULT_FRAGMENT_MAX_BYTES) |
| 159 | } |
| 160 | |
| 161 | #[must_use] |
| 162 | pub fn with_max_bytes( |
| 163 | id: FragmentId, |
| 164 | role: FragmentRole, |
| 165 | raw: impl Into<String>, |
| 166 | max_bytes: usize, |
| 167 | ) -> Self { |
| 168 | // Clamp to the global 10K-token ceiling so callers cannot opt out. |
| 169 | let clamped_max = max_bytes.min(MAX_FRAGMENT_BYTES); |
| 170 | let mut content = enforce_byte_cap(raw.into(), clamped_max); |
| 171 | // Token-ceiling safety net (4 bytes ≈ 1 token). |
| 172 | if content.len().div_ceil(4) > MAX_FRAGMENT_TOKENS { |
| 173 | content = enforce_byte_cap(content, MAX_FRAGMENT_BYTES); |
| 174 | } |
| 175 | let content_hash = hash_content(&content); |
| 176 | Self { |
| 177 | id, |
| 178 | role, |
| 179 | marker: id.marker(), |
| 180 | max_bytes: clamped_max, |
| 181 | content, |
| 182 | content_hash, |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | /// Return whether `haystack` contains this fragment's stable marker. |
| 187 | #[must_use] |
| 188 | pub fn matches_text(&self, haystack: &str) -> bool { |
| 189 | haystack.contains(self.marker) |
| 190 | } |
| 191 | |
| 192 | /// Compare against a previous fragment of the same id. |
| 193 | #[must_use] |
| 194 | pub fn render_diff(&self, previous: Option<&Self>) -> FragmentRender { |
| 195 | match previous { |
| 196 | Some(prev) if prev.content_hash == self.content_hash && prev.marker == self.marker => { |
| 197 | FragmentRender::Unchanged { |
| 198 | marker: self.marker.to_string(), |
| 199 | content_hash: self.content_hash, |
| 200 | } |
| 201 | } |
| 202 | _ => FragmentRender::Updated { |
| 203 | fragment: self.clone(), |
| 204 | }, |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | /// Full render including the stable marker header. |
| 209 | #[must_use] |
| 210 | pub fn render_marked(&self) -> String { |
| 211 | let rendered = format!("{}\n{}", self.marker, self.content.trim_end()); |
| 212 | debug_assert!(self.matches_text(&rendered)); |
| 213 | rendered |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | fn hash_content(content: &str) -> u64 { |
| 218 | let mut hasher = DefaultHasher::new(); |
| 219 | content.hash(&mut hasher); |
| 220 | hasher.finish() |
| 221 | } |
| 222 | |
| 223 | fn enforce_byte_cap(raw: String, max_bytes: usize) -> String { |
| 224 | if max_bytes == 0 { |
| 225 | return String::new(); |
| 226 | } |
| 227 | if raw.len() <= max_bytes { |
| 228 | return raw; |
| 229 | } |
| 230 | let omitted = raw.len().saturating_sub(max_bytes); |
| 231 | let marker = format!("\n[…truncated: {omitted} bytes omitted]"); |
| 232 | if marker.len() >= max_bytes { |
| 233 | return marker.chars().take(max_bytes).collect(); |
| 234 | } |
| 235 | let keep = max_bytes.saturating_sub(marker.len()); |
| 236 | // Truncate on a char boundary. |
| 237 | let mut end = keep; |
| 238 | while end > 0 && !raw.is_char_boundary(end) { |
| 239 | end -= 1; |
| 240 | } |
| 241 | let mut out = raw[..end].to_string(); |
| 242 | out.push_str(&marker); |
| 243 | out |
| 244 | } |
| 245 | |
| 246 | #[cfg(test)] |
| 247 | mod tests { |
| 248 | use super::*; |
| 249 | |
| 250 | #[test] |
| 251 | fn render_diff_detects_change_and_retain() { |
| 252 | let a = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a"); |
| 253 | let b = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a"); |
| 254 | let c = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=b"); |
| 255 | assert!(matches!( |
| 256 | b.render_diff(Some(&a)), |
| 257 | FragmentRender::Unchanged { .. } |
| 258 | )); |
| 259 | assert!(matches!( |
| 260 | c.render_diff(Some(&a)), |
| 261 | FragmentRender::Updated { .. } |
| 262 | )); |
| 263 | assert!(matches!( |
| 264 | a.render_diff(None), |
| 265 | FragmentRender::Updated { .. } |
| 266 | )); |
| 267 | } |
| 268 | } |
| 269 |