Skip to main content

thndrs_agent/context/
control.rs

1//! Context control ledger.
2//!
3//! Pure typed model of the context working set: candidate/selected items,
4//! model context limits, token budgets, and diagnostics.
5
6use std::collections::hash_map::DefaultHasher;
7use std::hash::{Hash, Hasher};
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use super::support::ratio_of;
13
14/// Conservative bytes-per-token divisor used until provider tokenizers exist.
15///
16/// `ceil(utf8_bytes / 3)` approximates tokens for mixed English/code content.
17pub const TOKEN_BYTES_DIVISOR: usize = 3;
18
19/// Fixed overhead added to every item's token estimate.
20///
21/// Covers structural framing (XML tags, role markers, separators) the provider
22/// adds around each context block.
23pub const TOKEN_ITEM_OVERHEAD: usize = 16;
24
25/// Fraction of the available input budget that selection targets.
26///
27/// Selection aims to stay at or below this ratio so headroom remains for the
28/// final user turn, tool schemas, and provider wrapper overhead.
29pub const TARGET_BUDGET_RATIO: f64 = 0.80;
30
31/// Fraction of the available input budget above which auto-compaction may
32/// trigger after normal eviction and summary candidates.
33pub const AUTO_COMPACTION_RATIO: f64 = 0.92;
34
35/// Reserved provider overhead (tokens) subtracted from the input budget.
36///
37/// Covers system framing, tool-schema wrappers, and safety policy blocks the
38/// harness always sends.
39pub const PROVIDER_OVERHEAD_TOKENS: u64 = 1_024;
40
41/// Conservative fallback context window when no model metadata is available.
42pub const FALLBACK_CONTEXT_WINDOW: u64 = 32_768;
43
44/// Conservative fallback max completion tokens when no metadata is available.
45pub const FALLBACK_MAX_COMPLETION_TOKENS: u64 = 4_096;
46
47/// Conservative fallback recommended completion tokens when no metadata is
48/// available.
49pub const FALLBACK_RECOMMENDED_COMPLETION_TOKENS: u64 = 4_096;
50
51/// Kind of context a [`ContextItem`] represents.
52///
53/// Drives selection policy and grouping.
54/// Kinds are stable labels an adding a variant is a backwards-compatible ledger extension.
55#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ContextItemKind {
58    /// Always-loaded harness prompt fragments, environment metadata, and tool
59    /// schemas. System-owned and not directly editable by context controls.
60    Harness,
61    /// Read-only `AGENTS.md` project instructions (root or nested scope).
62    ProjectInstruction,
63    /// Task-local pinned file, file range, tool result, or note.
64    PinnedFile,
65    /// Activated Agent Skill instructions.
66    Skill,
67    /// Recent transcript entries (user, assistant, reasoning, settled tool).
68    Transcript,
69    /// Compaction summary standing in for older transcript entries.
70    Summary,
71    /// Recoverable archived tool output or transcript payload.
72    ToolArchive,
73}
74
75impl ContextItemKind {
76    /// Stable lowercase label used in dashboards, session records, and ids.
77    pub fn label(&self) -> &'static str {
78        match self {
79            ContextItemKind::Harness => "harness",
80            ContextItemKind::ProjectInstruction => "project_instruction",
81            ContextItemKind::PinnedFile => "pinned_file",
82            ContextItemKind::Skill => "skill",
83            ContextItemKind::Transcript => "transcript",
84            ContextItemKind::Summary => "summary",
85            ContextItemKind::ToolArchive => "tool_archive",
86        }
87    }
88}
89
90/// Inclusion status of a [`ContextItem`] in the current working set.
91///
92/// `Visible` and `Pinned` items are rendered into the prompt. The remaining
93/// states describe why an item is omitted and how it can be recovered.
94#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum ContextVisibility {
97    /// Selected and rendered into the prompt this turn.
98    Visible,
99    /// User-pinned; always rendered until dropped or expired.
100    Pinned,
101    /// Replaced by a compaction summary; original detail is recoverable.
102    SummaryOnly,
103    /// Moved out of the active working set; recoverable by id.
104    Archived,
105    /// Discovered but not yet selected (e.g. nested `AGENTS.md` out of scope).
106    Candidate,
107    /// Explicitly excluded by the user until source change or `/drop --reset`.
108    Dropped,
109    /// Excluded because it would exceed budget even after eviction; recoverable.
110    Blocked,
111}
112
113impl ContextVisibility {
114    /// Whether the item is rendered into the model prompt this turn.
115    pub fn is_rendered(&self) -> bool {
116        matches!(self, ContextVisibility::Visible | ContextVisibility::Pinned)
117    }
118
119    /// Stable lowercase label used in dashboards and session records.
120    pub fn label(&self) -> &'static str {
121        match self {
122            ContextVisibility::Visible => "visible",
123            ContextVisibility::Pinned => "pinned",
124            ContextVisibility::SummaryOnly => "summary_only",
125            ContextVisibility::Archived => "archived",
126            ContextVisibility::Candidate => "candidate",
127            ContextVisibility::Dropped => "dropped",
128            ContextVisibility::Blocked => "blocked",
129        }
130    }
131
132    /// Human-readable reason for a visibility state.
133    pub fn reason(&self, base: &str) -> String {
134        match self {
135            ContextVisibility::Visible => base.to_string(),
136            ContextVisibility::Pinned => format!("{base}: pinned"),
137            ContextVisibility::SummaryOnly => format!("{base}: summary-only"),
138            ContextVisibility::Archived => format!("{base}: archived"),
139            ContextVisibility::Candidate => format!("{base}: candidate (not selected)"),
140            ContextVisibility::Dropped => format!("{base}: dropped"),
141            ContextVisibility::Blocked => format!("{base}: blocked (oversized)"),
142        }
143    }
144}
145
146/// Provenance of a [`ModelContextLimits`] value.
147///
148/// Resolution order is: user override, then live metadata, then static
149/// provider metadata, then conservative fallback. See
150/// [`ModelContextLimits::resolve`].
151#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum ModelLimitSource {
154    /// Conservative default when nothing else is known.
155    Fallback = 0,
156    /// Built-in static provider metadata (no live fetch).
157    Static = 1,
158    /// Live provider metadata fetched at runtime.
159    LiveMetadata = 2,
160    /// User-supplied config override under `[model_limits."provider/model-id"]`.
161    UserOverride = 3,
162}
163
164impl ModelLimitSource {
165    /// Stable lowercase source label.
166    pub fn label(&self) -> &'static str {
167        match self {
168            ModelLimitSource::Fallback => "fallback",
169            ModelLimitSource::Static => "static",
170            ModelLimitSource::LiveMetadata => "live-metadata",
171            ModelLimitSource::UserOverride => "user-override",
172        }
173    }
174}
175
176/// Confidence in a [`ModelContextLimits`] value.
177#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum ModelLimitConfidence {
180    /// Conservative guess; surface uncertainty in `/doctor` and `/context`.
181    Conservative,
182    /// Built-in static provider defaults; may lag the live model.
183    ProviderReported,
184    /// Reported by the provider at runtime.
185    Exact,
186    /// Supplied by the user; trusted but flagged by `/doctor`.
187    UserSupplied,
188}
189
190impl ModelLimitConfidence {
191    /// Stable lowercase confidence label.
192    pub fn label(&self) -> &'static str {
193        match self {
194            ModelLimitConfidence::Conservative => "conservative",
195            ModelLimitConfidence::ProviderReported => "provider-reported",
196            ModelLimitConfidence::Exact => "exact",
197            ModelLimitConfidence::UserSupplied => "user-supplied",
198        }
199    }
200}
201
202/// Severity of a [`ContextDiagnostic`].
203#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum DiagnosticSeverity {
206    /// Informational note (e.g. fallback limits in use).
207    Info,
208    /// Recoverable issue that may degrade context quality.
209    Warning,
210    /// Blocks correct context selection until resolved.
211    Error,
212}
213
214impl DiagnosticSeverity {
215    /// Stable lowercase severity label.
216    pub fn label(&self) -> &'static str {
217        match self {
218            DiagnosticSeverity::Info => "info",
219            DiagnosticSeverity::Warning => "warning",
220            DiagnosticSeverity::Error => "error",
221        }
222    }
223}
224
225/// Provider-neutral model context limits.
226///
227/// Adapters translate provider-specific metadata into this shape so the ledger
228/// stays free of provider-client types. See [`ModelContextLimits::resolve`].
229#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct ModelContextLimits {
231    /// Provider label (e.g. `"umans"`, `"opencode-go"`).
232    pub provider: String,
233    /// Model id as selected by the user (e.g. `"umans-coder"`).
234    pub model: String,
235    /// Total context window in tokens (input + output).
236    pub context_window: u64,
237    /// Maximum completion tokens the model accepts.
238    pub max_completion_tokens: u64,
239    /// Recommended completion tokens for typical turns.
240    pub recommended_completion_tokens: u64,
241    /// Where the limits came from.
242    pub source: ModelLimitSource,
243    /// How trustworthy the limits are.
244    pub confidence: ModelLimitConfidence,
245}
246
247impl ModelContextLimits {
248    /// Available input budget = context window minus reserved completion budget
249    /// and provider overhead.
250    ///
251    /// Selection targets [`TARGET_BUDGET_RATIO`] of this value.
252    ///
253    /// Auto-compaction may trigger above [`AUTO_COMPACTION_RATIO`].
254    pub fn available_input_budget(&self) -> u64 {
255        let reserved_completion = self.recommended_completion_tokens.max(self.max_completion_tokens);
256        self.context_window
257            .saturating_sub(reserved_completion)
258            .saturating_sub(PROVIDER_OVERHEAD_TOKENS)
259    }
260
261    /// Token budget the selection policy targets (80% of available input).
262    pub fn target_budget(&self) -> u64 {
263        ratio_of(self.available_input_budget(), TARGET_BUDGET_RATIO)
264    }
265
266    /// Token budget above which auto-compaction may trigger (92% of available
267    /// input).
268    pub fn auto_compaction_threshold(&self) -> u64 {
269        ratio_of(self.available_input_budget(), AUTO_COMPACTION_RATIO)
270    }
271
272    /// Resolve limits from candidate sources in precedence order:
273    /// user override → live metadata → static provider metadata → fallback.
274    ///
275    /// Returns the chosen limits plus diagnostics for invalid overrides and
276    /// fallback usage.
277    pub fn resolve(
278        provider: &str, model: &str, override_entry: Option<ModelLimitOverride>, live: Option<&LiveModelMetadata>,
279    ) -> (ModelContextLimits, Vec<ContextDiagnostic>) {
280        let mut diagnostics = Vec::new();
281
282        if let Some(entry) = override_entry {
283            match entry.validate() {
284                Ok(()) => {
285                    return (
286                        ModelContextLimits {
287                            provider: provider.to_string(),
288                            model: model.to_string(),
289                            context_window: entry.context_window,
290                            max_completion_tokens: entry.max_completion_tokens,
291                            recommended_completion_tokens: entry.recommended_completion_tokens,
292                            source: ModelLimitSource::UserOverride,
293                            confidence: ModelLimitConfidence::UserSupplied,
294                        },
295                        diagnostics,
296                    );
297                }
298                Err(reason) => {
299                    diagnostics.push(ContextDiagnostic::invalid_model_override(provider, model, &reason));
300                }
301            }
302        }
303
304        if let Some(live) = live
305            && let Some(limits) = live.to_limits(provider, model)
306        {
307            return (limits, diagnostics);
308        }
309
310        if let Some(static_limits) = static_provider_limits(provider, model) {
311            return (static_limits, diagnostics);
312        }
313
314        diagnostics.push(ContextDiagnostic::fallback_model_limits(provider, model));
315        (
316            ModelContextLimits {
317                provider: provider.to_string(),
318                model: model.to_string(),
319                context_window: FALLBACK_CONTEXT_WINDOW,
320                max_completion_tokens: FALLBACK_MAX_COMPLETION_TOKENS,
321                recommended_completion_tokens: FALLBACK_RECOMMENDED_COMPLETION_TOKENS,
322                source: ModelLimitSource::Fallback,
323                confidence: ModelLimitConfidence::Conservative,
324            },
325            diagnostics,
326        )
327    }
328}
329
330/// Live provider metadata translated into the neutral limits shape at the
331/// adapter boundary.
332///
333/// Construct this from a provider's `ModelInfo`/metadata before calling [`ModelContextLimits::resolve`].
334///
335/// Fields are `Option` so adapters can report partial metadata.
336///
337/// A `None` context window or completion budget falls through to the next precedence source.
338#[derive(Clone, Debug, Default, PartialEq, Eq)]
339pub struct LiveModelMetadata {
340    /// Total model context window in tokens, when reported by the provider.
341    pub context_window: Option<u64>,
342    /// Maximum completion size in tokens, when reported by the provider.
343    pub max_completion_tokens: Option<u64>,
344    /// Recommended completion size in tokens, when reported by the provider.
345    pub recommended_completion_tokens: Option<u64>,
346}
347
348impl LiveModelMetadata {
349    /// Build from explicit live values.
350    pub fn new(context_window: u64, max_completion_tokens: u64, recommended_completion_tokens: u64) -> Self {
351        Self {
352            context_window: Some(context_window),
353            max_completion_tokens: Some(max_completion_tokens),
354            recommended_completion_tokens: Some(recommended_completion_tokens),
355        }
356    }
357
358    /// Convert to [`ModelContextLimits`] only when a context window and a
359    /// completion budget are present. Returns `None` to fall through to the
360    /// next precedence source otherwise.
361    fn to_limits(&self, provider: &str, model: &str) -> Option<ModelContextLimits> {
362        let context_window = self.context_window?;
363        let max_completion_tokens = self.max_completion_tokens?;
364        let recommended = self
365            .recommended_completion_tokens
366            .unwrap_or_else(|| max_completion_tokens.min(context_window / 2));
367        if context_window == 0 || max_completion_tokens == 0 {
368            return None;
369        }
370        Some(ModelContextLimits {
371            provider: provider.to_string(),
372            model: model.to_string(),
373            context_window,
374            max_completion_tokens,
375            recommended_completion_tokens: recommended,
376            source: ModelLimitSource::LiveMetadata,
377            confidence: ModelLimitConfidence::Exact,
378        })
379    }
380}
381
382/// User-supplied model limit override parsed from
383/// `[model_limits."provider/model-id"]`.
384#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(default, deny_unknown_fields)]
386pub struct ModelLimitOverride {
387    /// Total model context window in tokens.
388    pub context_window: u64,
389    /// Maximum completion size in tokens.
390    pub max_completion_tokens: u64,
391    /// Recommended completion size in tokens.
392    pub recommended_completion_tokens: u64,
393}
394
395impl ModelLimitOverride {
396    /// Validate the override by confirming that all fields must be positive integers
397    /// and the recommended completion tokens must not exceed max completion tokens or
398    /// the context window.
399    ///
400    /// Returns a human-readable reason on failure.
401    pub fn validate(&self) -> Result<(), String> {
402        if self.context_window == 0 {
403            return Err("context_window must be a positive integer".to_string());
404        }
405        if self.max_completion_tokens == 0 {
406            return Err("max_completion_tokens must be a positive integer".to_string());
407        }
408        if self.recommended_completion_tokens == 0 {
409            return Err("recommended_completion_tokens must be a positive integer".to_string());
410        }
411        if self.recommended_completion_tokens > self.max_completion_tokens {
412            return Err(format!(
413                "recommended_completion_tokens ({}) must not exceed max_completion_tokens ({})",
414                self.recommended_completion_tokens, self.max_completion_tokens
415            ));
416        }
417        if self.max_completion_tokens >= self.context_window {
418            return Err(format!(
419                "max_completion_tokens ({}) must be less than context_window ({})",
420                self.max_completion_tokens, self.context_window
421            ));
422        }
423        Ok(())
424    }
425}
426
427/// A single context source considered for the working set.
428#[derive(Clone, Debug, Eq, PartialEq)]
429pub struct ContextItem {
430    /// Stable id (see [`item_id_for_path`] / [`item_id_for_session_range`]).
431    pub id: String,
432    /// What kind of context this is.
433    pub kind: ContextItemKind,
434    /// Short human-readable label (e.g. a file path or transcript label).
435    pub label: String,
436    /// Absolute source path when the item is file-backed.
437    pub source_path: Option<PathBuf>,
438    /// Scope label — `"."` for root, or a relative subtree path.
439    pub scope: String,
440    /// Content hash of the full original content, when applicable.
441    pub content_hash: Option<u64>,
442    /// Stable handle for bounded redacted recovery, when this item has one.
443    pub artifact_handle: Option<String>,
444    /// Original byte count of the source content.
445    pub byte_count: usize,
446    /// Renderable content for the prompt projection, when this item is
447    /// selected as normal context content. `None` for metadata-only items
448    /// (pins rendered as handles, candidates, dropped items) and for items
449    /// whose content is projected through other bundle fields (transcript
450    /// entries are lowered as messages, not inlined here).
451    ///
452    /// The model-visible dashboard deliberately omits this field.
453    pub content: Option<String>,
454    /// Conservative estimated token cost (`ceil(utf8_bytes / 3) + 16`).
455    pub token_estimate: usize,
456    /// Inclusion status this turn.
457    pub visibility: ContextVisibility,
458    /// Stable policy code for why this state was assigned.
459    pub reason_code: String,
460    /// Why the item is visible, omitted, archived, dropped, blocked, or summary-only.
461    pub reason: String,
462}
463
464impl ContextItem {
465    /// Render a compact one-line summary for `/context` and transcript rows.
466    pub fn summary(&self) -> String {
467        format!(
468            "{}  {}  {}  {} est. tokens  [{}]",
469            self.id,
470            self.kind.label(),
471            self.visibility.label(),
472            self.token_estimate,
473            self.label,
474        )
475    }
476}
477
478/// Token budget derived from [`ModelContextLimits`].
479#[derive(Clone, Debug, Eq, PartialEq)]
480pub struct ContextBudget {
481    /// Limits the budget was derived from.
482    pub limits: ModelContextLimits,
483    /// Available input budget (context window − reserved completion − overhead).
484    pub available_input: u64,
485    /// Target selection budget (80% of available input).
486    pub target: u64,
487    /// Auto-compaction trigger threshold (92% of available input).
488    pub auto_compaction_threshold: u64,
489    /// Estimated tokens of currently rendered (`Visible` + `Pinned`) items.
490    pub used: u64,
491}
492
493impl ContextBudget {
494    /// Build a budget from resolved limits and the currently rendered items.
495    pub fn from_limits(limits: ModelContextLimits, items: &[ContextItem]) -> Self {
496        let available_input = limits.available_input_budget();
497        let target = limits.target_budget();
498        let auto_compaction_threshold = limits.auto_compaction_threshold();
499        let used = items
500            .iter()
501            .filter(|item| item.visibility.is_rendered())
502            .map(|item| item.token_estimate as u64)
503            .sum();
504        ContextBudget { limits, available_input, target, auto_compaction_threshold, used }
505    }
506
507    /// Whether rendered items exceed the target selection budget.
508    pub fn exceeds_target(&self) -> bool {
509        self.used > self.target
510    }
511
512    /// Whether rendered items exceed the auto-compaction threshold.
513    pub fn exceeds_auto_compaction(&self) -> bool {
514        self.used > self.auto_compaction_threshold
515    }
516
517    /// Remaining tokens before the target budget is reached.
518    pub fn remaining_to_target(&self) -> u64 {
519        self.target.saturating_sub(self.used)
520    }
521}
522
523/// A diagnostic about context or model-limit state.
524#[derive(Clone, Debug, Eq, PartialEq)]
525pub struct ContextDiagnostic {
526    /// Severity of the condition reported by this diagnostic.
527    pub severity: DiagnosticSeverity,
528    /// Short code (e.g. `"fallback_model_limits"`, `"invalid_model_override"`).
529    pub code: String,
530    /// Human-readable explanation.
531    pub message: String,
532}
533
534impl ContextDiagnostic {
535    /// Fallback limits are in use because no metadata was available.
536    pub fn fallback_model_limits(provider: &str, model: &str) -> Self {
537        ContextDiagnostic {
538            severity: DiagnosticSeverity::Warning,
539            code: "fallback_model_limits".to_string(),
540            message: format!("no model metadata for {provider}/{model}; using conservative fallback context window"),
541        }
542    }
543
544    /// A user override was rejected as invalid.
545    pub fn invalid_model_override(provider: &str, model: &str, reason: &str) -> Self {
546        ContextDiagnostic {
547            severity: DiagnosticSeverity::Error,
548            code: "invalid_model_override".to_string(),
549            message: format!("model_limits override for {provider}/{model} rejected: {reason}"),
550        }
551    }
552
553    /// Render a compact one-line summary.
554    pub fn summary(&self) -> String {
555        format!("{}  {}  {}", self.severity.label(), self.code, self.message)
556    }
557}
558
559/// The context ledger: all candidate/selected items, the budget, and
560/// diagnostics for a turn.
561#[derive(Clone, Debug, Eq, PartialEq)]
562pub struct ContextLedger {
563    /// Every candidate item and its selected, omitted, or blocked state.
564    pub items: Vec<ContextItem>,
565    /// Resolved token limits and current budget usage.
566    pub budget: ContextBudget,
567    /// Diagnostics produced while resolving limits or selecting context.
568    pub diagnostics: Vec<ContextDiagnostic>,
569}
570
571impl ContextLedger {
572    /// Items rendered into the prompt this turn.
573    pub fn rendered(&self) -> Vec<&ContextItem> {
574        self.items.iter().filter(|item| item.visibility.is_rendered()).collect()
575    }
576
577    /// Count of items by visibility label.
578    pub fn counts(&self) -> ContextCounts {
579        let mut counts = ContextCounts::default();
580        for item in &self.items {
581            match item.visibility {
582                ContextVisibility::Visible => counts.visible += 1,
583                ContextVisibility::Pinned => counts.pinned += 1,
584                ContextVisibility::SummaryOnly => counts.summary_only += 1,
585                ContextVisibility::Archived => counts.archived += 1,
586                ContextVisibility::Candidate => counts.candidate += 1,
587                ContextVisibility::Dropped => counts.dropped += 1,
588                ContextVisibility::Blocked => counts.blocked += 1,
589            }
590        }
591        counts
592    }
593
594    /// Find an item by id.
595    pub fn find(&self, id: &str) -> Option<&ContextItem> {
596        self.items.iter().find(|item| item.id == id)
597    }
598}
599
600/// Visibility counts for a [`ContextLedger`].
601#[derive(Clone, Debug, Default, Eq, PartialEq)]
602pub struct ContextCounts {
603    /// Number of visible items.
604    pub visible: usize,
605    /// Number of pinned items.
606    pub pinned: usize,
607    /// Number of summary-only items.
608    pub summary_only: usize,
609    /// Number of archived items.
610    pub archived: usize,
611    /// Number of unselected candidate items.
612    pub candidate: usize,
613    /// Number of explicitly dropped items.
614    pub dropped: usize,
615    /// Number of items blocked by the budget.
616    pub blocked: usize,
617}
618
619/// Conservative token estimate: `ceil(utf8_bytes / 3) + 16`.
620///
621/// Approximate until provider-specific tokenizers exist. Operates on UTF-8
622/// bytes so multibyte content is not undercounted.
623pub fn estimate_tokens(bytes: usize) -> usize {
624    bytes.div_ceil(TOKEN_BYTES_DIVISOR) + TOKEN_ITEM_OVERHEAD
625}
626
627/// Generate a stable context item id for a file-backed source.
628///
629/// The id is `ctx_<kind>:<hash>` where the hash is derived from the kind and
630/// the canonical path string. This is kept stable across turns for the same path and kind.
631pub fn item_id_for_path(kind: &ContextItemKind, path: &Path) -> String {
632    let mut hasher = DefaultHasher::new();
633    kind.label().hash(&mut hasher);
634    path.hash(&mut hasher);
635    format!("ctx_{}_{:016x}", kind.label(), hasher.finish())
636}
637
638/// Generate a stable context item id for a transcript/session source range.
639///
640/// `session_id` scopes the range to a session; `start` and `end` are inclusive
641/// sequence numbers.
642///
643/// The id is stable for the same session and range.
644pub fn item_id_for_session_range(kind: &ContextItemKind, session_id: &str, start: u64, end: u64) -> String {
645    let mut hasher = DefaultHasher::new();
646    kind.label().hash(&mut hasher);
647    session_id.hash(&mut hasher);
648    start.hash(&mut hasher);
649    end.hash(&mut hasher);
650    format!("ctx_{}_{:016x}", kind.label(), hasher.finish())
651}
652
653/// Render a compact user-visible ledger summary line.
654///
655/// Example: `context  9 visible · 3 pinned · 2 archived · 18k est. tokens`.
656pub fn render_ledger_summary(ledger: &ContextLedger) -> String {
657    let counts = ledger.counts();
658    let mut parts = vec![format!("{} visible", counts.visible)];
659    if counts.pinned > 0 {
660        parts.push(format!("{} pinned", counts.pinned));
661    }
662    if counts.archived > 0 {
663        parts.push(format!("{} archived", counts.archived));
664    }
665    if counts.summary_only > 0 {
666        parts.push(format!("{} summary", counts.summary_only));
667    }
668    if counts.blocked > 0 {
669        parts.push(format!("{} blocked", counts.blocked));
670    }
671    parts.push(format!("{} est. tokens", compact_token_count(ledger.budget.used)));
672    format!("context  {}", parts.join(" · "))
673}
674
675/// Render a compact model-visible context dashboard.
676///
677/// Excludes full content: only ids, kinds, visibility, token estimates, and
678/// budget pressure.
679///
680///  Intended for inclusion in the self-knowledge snapshot.
681pub fn render_model_dashboard(ledger: &ContextLedger) -> String {
682    let mut out = String::new();
683    out.push_str("<context_dashboard>\n");
684    out.push_str("  <budget>\n");
685    element(
686        &mut out,
687        4,
688        "available_input",
689        &compact_token_count(ledger.budget.available_input),
690    );
691    element(&mut out, 4, "target", &compact_token_count(ledger.budget.target));
692    element(
693        &mut out,
694        4,
695        "auto_compaction_threshold",
696        &compact_token_count(ledger.budget.auto_compaction_threshold),
697    );
698    element(&mut out, 4, "used", &compact_token_count(ledger.budget.used));
699    element(
700        &mut out,
701        4,
702        "exceeds_target",
703        &ledger.budget.exceeds_target().to_string(),
704    );
705    element(
706        &mut out,
707        4,
708        "exceeds_auto_compaction",
709        &ledger.budget.exceeds_auto_compaction().to_string(),
710    );
711    element(&mut out, 4, "limit_source", ledger.budget.limits.source.label());
712    element(&mut out, 4, "limit_confidence", ledger.budget.limits.confidence.label());
713    out.push_str("  </budget>\n");
714
715    out.push_str("  <items>\n");
716    for item in &ledger.items {
717        out.push_str("    <item>\n");
718        element(&mut out, 6, "id", &item.id);
719        element(&mut out, 6, "kind", item.kind.label());
720        element(&mut out, 6, "visibility", item.visibility.label());
721        element(&mut out, 6, "tokens", &item.token_estimate.to_string());
722        element(&mut out, 6, "label", &item.label);
723        if let Some(handle) = &item.artifact_handle {
724            element(&mut out, 6, "recovery_handle", handle);
725        }
726        out.push_str("    </item>\n");
727    }
728    out.push_str("  </items>\n");
729
730    if !ledger.diagnostics.is_empty() {
731        out.push_str("  <diagnostics>\n");
732        for diagnostic in &ledger.diagnostics {
733            element(&mut out, 4, "diagnostic", &diagnostic.summary());
734        }
735        out.push_str("  </diagnostics>\n");
736    }
737    out.push_str("</context_dashboard>");
738    out
739}
740
741/// Conservative static provider fallbacks for providers without live
742/// context-window metadata.
743///
744/// Returns `None` for unknown providers so the caller falls through to the
745/// conservative default.
746fn static_provider_limits(provider: &str, model: &str) -> Option<ModelContextLimits> {
747    let (context_window, max_completion, recommended) = match provider {
748        "umans" => (200_000, 32_768, 8_192),
749        "opencode-go" => (200_000, 32_768, 8_192),
750        "opencode-zen" => (200_000, 32_768, 8_192),
751        "chatgpt-codex" => (200_000, 32_768, 8_192),
752        _ => return None,
753    };
754    Some(ModelContextLimits {
755        provider: provider.to_string(),
756        model: model.to_string(),
757        context_window,
758        max_completion_tokens: max_completion,
759        recommended_completion_tokens: recommended,
760        source: ModelLimitSource::Static,
761        confidence: ModelLimitConfidence::ProviderReported,
762    })
763}
764
765/// Compact token count for display (e.g. `18k`, `1M`, `1234`).
766fn compact_token_count(tokens: u64) -> String {
767    const K: u64 = 1_000;
768    const M: u64 = K * 1_000;
769    if tokens >= M && tokens.is_multiple_of(M) {
770        format!("{}M", tokens / M)
771    } else if tokens >= K && tokens.is_multiple_of(K) {
772        format!("{}k", tokens / K)
773    } else {
774        tokens.to_string()
775    }
776}
777
778/// Append an indented XML element `<name>value</name>` to `out`.
779fn element(out: &mut String, indent: usize, name: &str, value: &str) {
780    let pad = " ".repeat(indent);
781    out.push_str(&format!("{pad}<{name}>{value}</{name}>\n"));
782}