Skip to main content

vtcode_memory/explanation/
mod.rs

1//! Deterministic, evidence-backed execution views of canonical retained events.
2//! No model calls or additional explanation database are involved.
3mod projection;
4mod render;
5#[cfg(test)]
6mod tests;
7
8use crate::{SessionEventLog, SessionStoreError};
9pub use render::{render_details, render_diagram, render_html, render_html_with_workspace, render_summary};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use vtcode_exec_events::{ThreadEvent, TokenBreakdown, Usage, VersionedThreadEvent};
13
14/// Latest recorded task, or all retained tasks in the session.
15#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum ExplanationScope {
18    /// Latest recorded task.
19    #[default]
20    Task,
21    /// All retained tasks in the session.
22    Session,
23}
24
25/// Content-addressed reference: rewrites cannot retarget it to unrelated bytes.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
27pub struct EvidenceRef {
28    /// Canonical session identity.
29    pub session_id: String,
30    /// Byte offset in the retained event file or evidence page.
31    pub offset: u64,
32    /// Length of the canonical event record in bytes.
33    pub length: u64,
34    /// SHA-256 of the complete event record.
35    pub digest: String,
36    /// Stable item identity, when the event contains an item.
37    pub item_id: Option<String>,
38}
39
40/// One bounded public fact with its canonical source.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct ExplanationEntry {
43    /// Bounded, redacted public description.
44    pub label: String,
45    /// Recorded lifecycle result; unavailable facts remain explicit.
46    pub status: String,
47    /// Canonical source of this fact.
48    pub evidence: EvidenceRef,
49    /// Recorded root task identity, if available.
50    pub task_id: Option<String>,
51    /// Recorded actor identity, if available.
52    pub actor_id: Option<String>,
53    /// Recorded parent actor identity, if available.
54    pub parent_actor_id: Option<String>,
55    /// Recorded RFC3339 time, if available.
56    pub timestamp: Option<String>,
57    /// Captured changed path, if available.
58    pub path: Option<String>,
59    /// Captured new-side hunk location; deletions use the diff.
60    pub line: Option<u64>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64/// Evidence-backed VerificationEntry data for shared renderers.
65pub struct VerificationEntry {
66    /// Public fact and its evidence.
67    pub fact: ExplanationEntry,
68    /// Recorded process exit code; absent is unconfirmed.
69    pub exit_code: Option<i32>,
70    /// Successful check's earliest recorded lifecycle follows all recorded mutations.
71    pub fresh: bool,
72}
73
74/// Recorded request-prefix token attribution with canonical turn evidence.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct TokenBreakdownEntry {
77    /// Turn context and evidence for the recorded request prefix.
78    pub fact: ExplanationEntry,
79    /// Producer-recorded counts; never estimated by this projection.
80    pub breakdown: TokenBreakdown,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84/// Evidence-backed DecisionEntry data for shared renderers.
85pub struct DecisionEntry {
86    /// Public fact and its evidence.
87    pub fact: ExplanationEntry,
88    /// Agent-reported public rationale, never reconstructed from reasoning.
89    pub rationale: String,
90    /// Agent-reported rejected alternatives.
91    pub alternatives: Vec<String>,
92    /// Current-task item references reported by the agent.
93    pub evidence_ids: Vec<String>,
94}
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
97#[serde(rename_all = "snake_case")]
98/// Evidence-backed review ordering; does not assert a bug.
99pub enum ReviewPriority {
100    /// Security, persistence, schema, or dependency boundary.
101    High,
102    /// Compatibility, change size, recovery, or verification signal.
103    Medium,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107/// Evidence-backed ReviewSignal data for shared renderers.
108pub struct ReviewSignal {
109    /// Deterministic review order, not a bug severity.
110    pub priority: ReviewPriority,
111    /// Concrete review signal supported by the fact.
112    pub reason: String,
113    /// Public fact and its evidence.
114    pub fact: ExplanationEntry,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118/// Evidence-backed GraphEdge data for shared renderers.
119pub struct GraphEdge {
120    /// Content-addressed source event identity with task and actor context.
121    pub from: String,
122    /// Content-addressed target event identity or file path.
123    pub to: String,
124    /// Recorded relationship label.
125    pub relation: String,
126}
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
129/// Evidence-backed EvidenceCompleteness data for shared renderers.
130pub struct EvidenceCompleteness {
131    /// Retained records that could not be decoded.
132    pub malformed_records: usize,
133    /// Unsupported canonical event types.
134    pub unknown_records: usize,
135    /// Events missing task context.
136    pub legacy_records: usize,
137    /// Turns preceding the retained range.
138    pub evicted_turns: u64,
139    /// Explicit historical gaps and unavailable information.
140    pub warnings: Vec<String>,
141}
142
143/// Shared facts consumed by terminal, diagrams, browser, and offline report.
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct ExplanationModel {
146    /// Canonical session identity.
147    pub session_id: String,
148    /// Digest of the complete retained snapshot.
149    pub revision: String,
150    /// Requested task or session scope.
151    pub scope: ExplanationScope,
152    /// Recorded root task identity, if available.
153    pub task_id: Option<String>,
154    /// Recorded lifecycle result; unavailable facts remain explicit.
155    pub status: String,
156    /// Original recorded task requests.
157    pub goals: Vec<ExplanationEntry>,
158    /// Lifecycle-reduced actions in recorded order.
159    pub actions: Vec<ExplanationEntry>,
160    /// Distinct paths with successful recorded changes.
161    pub changes: Vec<ExplanationEntry>,
162    /// Number of distinct successful file-change operations.
163    pub edit_operations: usize,
164    /// Explicit public decisions, labeled agent-reported.
165    pub decisions: Vec<DecisionEntry>,
166    /// Verification commands with honest exit status and freshness.
167    pub verification: Vec<VerificationEntry>,
168    /// Recorded request-prefix token attribution for each retained turn.
169    pub token_breakdowns: Vec<TokenBreakdownEntry>,
170    /// Failed, denied, blocked, and unconfirmed operations.
171    pub failures: Vec<ExplanationEntry>,
172    /// Sorted evidence-backed review signals.
173    pub review_priorities: Vec<ReviewSignal>,
174    /// Recorded plans, corrections, and approvals.
175    pub plan_evolution: Vec<ExplanationEntry>,
176    /// Recorded order and action-to-file edges.
177    pub graph: Vec<GraphEdge>,
178    /// Canonical token usage, only when recorded.
179    pub usage: Option<Usage>,
180    /// Recorded total session cost, absent for task scope.
181    pub cost_usd: Option<serde_json::Number>,
182    /// Visible uncertainty about retained evidence.
183    pub completeness: EvidenceCompleteness,
184}
185
186/// Paged evidence, bounded independently of the projection size.
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub struct EvidencePage {
189    /// Canonical content-addressed source.
190    pub reference: EvidenceRef,
191    /// Redacted event evidence for this page.
192    pub text: String,
193    /// Byte offset in the retained event file or evidence page.
194    pub offset: usize,
195    /// Next UTF-8 page boundary, or None at EOF.
196    pub next_offset: Option<usize>,
197    /// Total redacted evidence length.
198    pub total_bytes: usize,
199}
200
201/// Bounded page of each projected collection, with one authoritative revision.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203pub struct ExplanationPage {
204    /// Current Git state, independent of canonical execution and ownership.
205    /// Runtime adapters may attach it on page zero; canonical queries leave it absent.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub workspace_diff: Option<WorkspaceDiffSnapshot>,
208    /// Projected facts for this page; scalar metadata is repeated unchanged.
209    pub model: ExplanationModel,
210    /// Start index applied to each collection.
211    pub offset: usize,
212    /// Next index, or None when every collection is exhausted.
213    pub next_offset: Option<usize>,
214}
215
216/// Bounded current workspace state; never evidence of agent attribution.
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218pub struct WorkspaceDiffSnapshot {
219    /// Runtime capture time, independent of historical event timestamps.
220    pub captured_at: String,
221    /// Redacted tracked-file diff against HEAD, absent when unavailable.
222    pub text: Option<String>,
223    /// Whether the bounded snapshot omits diff content.
224    pub truncated: bool,
225    /// Scope and availability explanation; never a claim of agent ownership.
226    pub note: String,
227}
228
229/// Page large projections within bridge byte limits.
230pub fn page_explanation(model: &ExplanationModel, offset: usize) -> ExplanationPage {
231    fn page<T: Clone>(items: &[T], offset: usize, count: usize) -> Vec<T> {
232        items.iter().skip(offset).take(count).cloned().collect()
233    }
234    let maximum = [
235        model.goals.len(),
236        model.actions.len(),
237        model.changes.len(),
238        model.decisions.len(),
239        model.verification.len(),
240        model.token_breakdowns.len(),
241        model.failures.len(),
242        model.review_priorities.len(),
243        model.plan_evolution.len(),
244        model.graph.len(),
245    ]
246    .into_iter()
247    .max()
248    .unwrap_or(0);
249    let mut count = 32;
250    loop {
251        let result = ExplanationPage {
252            workspace_diff: None,
253            model: ExplanationModel {
254                session_id: model.session_id.clone(),
255                revision: model.revision.clone(),
256                scope: model.scope,
257                task_id: model.task_id.clone(),
258                status: model.status.clone(),
259                goals: page(&model.goals, offset, count),
260                actions: page(&model.actions, offset, count),
261                changes: page(&model.changes, offset, count),
262                edit_operations: model.edit_operations,
263                decisions: page(&model.decisions, offset, count),
264                verification: page(&model.verification, offset, count),
265                token_breakdowns: page(&model.token_breakdowns, offset, count),
266                failures: page(&model.failures, offset, count),
267                review_priorities: page(&model.review_priorities, offset, count),
268                plan_evolution: page(&model.plan_evolution, offset, count),
269                graph: page(&model.graph, offset, count),
270                usage: model.usage.clone(),
271                cost_usd: model.cost_usd.clone(),
272                completeness: model.completeness.clone(),
273            },
274            offset,
275            next_offset: (offset.saturating_add(count) < maximum).then_some(offset.saturating_add(count)),
276        };
277        if count == 1 || serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 64 * 1024) {
278            return result;
279        }
280        count /= 2;
281    }
282}
283
284impl ExplanationModel {
285    /// Unique substantive sources in this projection, sorted by recorded order.
286    pub fn evidence_references(&self) -> Vec<EvidenceRef> {
287        let mut refs: Vec<_> = self
288            .goals
289            .iter()
290            .chain(&self.actions)
291            .chain(&self.changes)
292            .chain(&self.failures)
293            .chain(&self.plan_evolution)
294            .chain(self.decisions.iter().map(|d| &d.fact))
295            .chain(self.verification.iter().map(|v| &v.fact))
296            .chain(self.token_breakdowns.iter().map(|entry| &entry.fact))
297            .chain(self.review_priorities.iter().map(|r| &r.fact))
298            .map(|e| e.evidence.clone())
299            .collect();
300        refs.sort();
301        refs.dedup();
302        refs
303    }
304}
305
306/// Query one ordered retained snapshot. Full payloads are discarded as scanned.
307pub fn query_explanation(
308    log: &SessionEventLog,
309    scope: ExplanationScope,
310) -> Result<ExplanationModel, SessionStoreError> {
311    let session_id = log.manifest().session_id;
312    let mut reducer = projection::Reducer::new(session_id.clone(), scope);
313    let mut revision = Sha256::new();
314    let manifest = log.visit_snapshot(|offset, bytes| {
315        revision.update(bytes);
316        let reference = EvidenceRef {
317            session_id: session_id.clone(),
318            offset,
319            length: bytes.len() as u64,
320            digest: hex(&Sha256::digest(bytes)),
321            item_id: None,
322        };
323        match serde_json::from_slice::<VersionedThreadEvent>(bytes) {
324            Ok(v) => reducer.push(v.into_event(), reference),
325            Err(_) => reducer.malformed(),
326        }
327    })?;
328    let evicted_turns = manifest.evicted_turn_count();
329    Ok(reducer.finish(hex(&revision.finalize()), evicted_turns))
330}
331
332/// Resolve evidence only if its session, offset, length, and digest still match.
333/// An expired/evicted reference is an error, never an empty successful result.
334pub fn query_evidence(
335    log: &SessionEventLog,
336    reference: &EvidenceRef,
337    offset: usize,
338    limit: usize,
339) -> Result<EvidencePage, SessionStoreError> {
340    if reference.session_id != log.manifest().session_id {
341        return Err(evidence_error("evidence belongs to another session"));
342    }
343    let mut found = None;
344    log.visit_snapshot(|position, bytes| {
345        if position == reference.offset
346            && bytes.len() as u64 == reference.length
347            && hex(&Sha256::digest(bytes)) == reference.digest
348        {
349            // Parse then redact individual values so JSON remains valid.
350            if let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) {
351                redact_value(&mut value);
352                found = serde_json::to_string_pretty(&value).ok();
353            }
354        }
355    })?;
356    let text = found.ok_or_else(|| evidence_error("evidence is unavailable, malformed, or expired"))?;
357    if offset > text.len() || !text.is_char_boundary(offset) {
358        return Err(evidence_error("invalid evidence page offset"));
359    }
360    let mut end = offset.saturating_add(limit.clamp(1, 32 * 1024)).min(text.len());
361    while !text.is_char_boundary(end) {
362        end = end.saturating_sub(1);
363    }
364    // Always progress, including a page limit smaller than the next UTF-8 scalar.
365    if end == offset && offset < text.len() {
366        end += text[offset..].chars().next().map_or(0, char::len_utf8);
367    }
368    Ok(EvidencePage {
369        reference: reference.clone(),
370        text: text[offset..end].to_owned(),
371        offset,
372        next_offset: (end < text.len()).then_some(end),
373        total_bytes: text.len(),
374    })
375}
376
377fn evidence_error(message: &str) -> SessionStoreError {
378    SessionStoreError::io("events.jsonl", std::io::Error::new(std::io::ErrorKind::InvalidData, message))
379}
380
381fn hex(bytes: &[u8]) -> String {
382    bytes.iter().map(|b| format!("{b:02x}")).collect()
383}
384
385fn public_text(text: &str, limit: usize) -> String {
386    let clean: String = text.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').collect();
387    let redacted = vtcode_commons::sanitizer::redact_secrets(clean);
388    let mut end = redacted.len().min(limit);
389    while !redacted.is_char_boundary(end) {
390        end -= 1;
391    }
392    let mut result = redacted[..end].to_owned();
393    if redacted.len() > limit {
394        result.push_str(" [truncated; inspect evidence]");
395    }
396    result
397}
398
399fn public_identity(text: &str) -> String {
400    if text
401        .strip_prefix("task-")
402        .or_else(|| text.strip_prefix("turn-"))
403        .is_some_and(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok())
404    {
405        return text.to_owned();
406    }
407    let redacted = public_text(text, 256);
408    if redacted == text {
409        redacted
410    } else {
411        format!("redacted-id-{}", hex(&Sha256::digest(text.as_bytes())))
412    }
413}
414
415fn public_path(text: &str) -> String {
416    let label = public_text(text, 900);
417    if label == text {
418        label
419    } else {
420        format!("{label} [path-{}]", hex(&Sha256::digest(text.as_bytes())))
421    }
422}
423
424fn redact_value(value: &mut serde_json::Value) {
425    match value {
426        serde_json::Value::String(s) => *s = public_text(s, usize::MAX),
427        serde_json::Value::Array(a) => {
428            for value in a {
429                redact_value(value);
430            }
431        }
432        serde_json::Value::Object(o) => {
433            for (key, v) in o {
434                if matches!(
435                    key.to_ascii_lowercase().replace(['_', '-'], "").as_str(),
436                    "token"
437                        | "password"
438                        | "passwd"
439                        | "authorization"
440                        | "apikey"
441                        | "secret"
442                        | "accesstoken"
443                        | "refreshtoken"
444                        | "clientsecret"
445                        | "privatekey"
446                        | "credential"
447                        | "credentials"
448                ) {
449                    *v = serde_json::Value::String("[REDACTED]".into());
450                } else {
451                    redact_value(v);
452                }
453            }
454        }
455        _ => {}
456    }
457}