Skip to main content

vtcode_core/compaction/
memory_envelope.rs

1//! Session memory envelope + local compaction helpers, shared by every
2//! compaction path (auto, manual `/compact`, model-switch, recovery, fork).
3//!
4//! This module was extracted from the binary unified runloop so that both the
5//! binary runloop and the `vtcode-core` `AgentRunner` loop use a single
6//! compaction path with identical continuity behavior.
7
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::Context;
13use chrono::Utc;
14use serde::{Deserialize, Serialize};
15use serde_json::{Value, json};
16use tokio::fs as async_fs;
17
18use vtcode_config::context::default_max_context_tokens;
19use vtcode_config::loader::VTCodeConfig;
20
21use crate::compaction::CompactionConfig;
22use crate::config::constants::tools as tool_names;
23use crate::context::history_files::{HistoryFileManager, messages_to_history_messages};
24use crate::core::agent::harness_artifacts::{
25    artifact_is_stale, current_evaluation_path, current_spec_path, current_task_path, read_evaluation_summary_fresh,
26    read_spec_summary_fresh, session_artifact_cutoff,
27};
28use crate::core::agent::steering::{
29    MAX_APPLIED_FOLLOW_UP_INTENT_IDS, MAX_QUEUED_FOLLOW_UP_INTENTS, QueuedFollowUpIntent,
30};
31use crate::llm::provider::{LLMProvider, Message, MessageRole};
32use crate::llm::utils::truncate_to_token_limit;
33use crate::persistent_memory::{GroundedFactRecord, dedup_latest_facts, normalize_whitespace, truncate_for_fact};
34
35pub const MEMORY_ENVELOPE_HEADER: &str = "[Session Memory Envelope]";
36pub const MEMORY_ENVELOPE_SUFFIX: &str = ".memory.json";
37pub const SESSION_MEMORY_ENVELOPE_SCHEMA_VERSION: u32 = 3;
38pub const MEMORY_LIST_LIMIT: usize = 5;
39pub const APPLIED_INTENT_WINDOW: usize = MAX_APPLIED_FOLLOW_UP_INTENT_IDS;
40pub const DEDUPED_FILE_READ_NOTE: &str = "Older duplicate file read omitted during local compaction; a newer read of the same target slice is retained later in history.";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MemoryEnvelopePersistence {
44    PersistToDisk,
45    InMemoryOnly,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum MemoryEnvelopePlacement {
50    Start,
51    BeforeLastUserOrSummary,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
55pub struct SessionMemoryEnvelope {
56    #[serde(default)]
57    pub session_id: String,
58    #[serde(default)]
59    pub schema_version: Option<u32>,
60    pub summary: String,
61    #[serde(default)]
62    pub objective: Option<String>,
63    pub task_summary: Option<String>,
64    pub spec_summary: Option<String>,
65    pub evaluation_summary: Option<String>,
66    #[serde(default)]
67    pub verification_summary: Option<String>,
68    #[serde(default)]
69    pub constraints: Vec<String>,
70    pub grounded_facts: Vec<GroundedFactRecord>,
71    pub touched_files: Vec<String>,
72    #[serde(default)]
73    pub open_questions: Vec<String>,
74    #[serde(default)]
75    pub verification_todo: Vec<String>,
76    #[serde(default)]
77    pub delegation_notes: Vec<String>,
78    /// Follow-up steering intents accepted but not yet represented by a
79    /// tagged user message. The list is bounded to preserve FIFO recovery.
80    #[serde(default)]
81    pub pending_intents: Vec<QueuedFollowUpIntent>,
82    /// Recently applied steering IDs used to avoid replaying an already
83    /// durable instruction after restart or compaction.
84    #[serde(default)]
85    pub applied_intent_ids: Vec<String>,
86    pub history_artifact_path: Option<String>,
87    pub generated_at: String,
88}
89
90impl SessionMemoryEnvelope {
91    /// Returns true if this envelope carries the same meaningful content as
92    /// `other`. Generated timestamps and history artifact paths are ignored
93    /// because they change even when the underlying session state does not.
94    pub fn is_content_equivalent_to(&self, other: &SessionMemoryEnvelope) -> bool {
95        self.session_id == other.session_id
96            && self.schema_version == other.schema_version
97            && self.summary == other.summary
98            && self.objective == other.objective
99            && self.task_summary == other.task_summary
100            && self.spec_summary == other.spec_summary
101            && self.evaluation_summary == other.evaluation_summary
102            && self.verification_summary == other.verification_summary
103            && self.constraints == other.constraints
104            && self.grounded_facts == other.grounded_facts
105            && self.touched_files == other.touched_files
106            && self.open_questions == other.open_questions
107            && self.verification_todo == other.verification_todo
108            && self.delegation_notes == other.delegation_notes
109            && self.pending_intents == other.pending_intents
110            && self.applied_intent_ids == other.applied_intent_ids
111    }
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq)]
115pub struct SessionMemoryEnvelopeUpdate {
116    pub objective: Option<String>,
117    pub constraints: Vec<String>,
118    pub grounded_facts: Vec<GroundedFactRecord>,
119    pub touched_files: Vec<String>,
120    pub open_questions: Vec<String>,
121    pub verification_todo: Vec<String>,
122    pub delegation_notes: Vec<String>,
123    /// Replace the durable pending-intent snapshot when supplied.
124    pub pending_intents: Option<Vec<QueuedFollowUpIntent>>,
125    /// IDs acknowledged since the previous envelope.
126    pub applied_intent_ids: Vec<String>,
127}
128
129#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
130pub struct TaskTrackerSnapshot {
131    summary: Option<String>,
132    objective: Option<String>,
133    verification_summary: Option<String>,
134    verification_todo: Vec<String>,
135}
136
137fn merge_dedup_push<T, K, F>(prior: &[T], updates: impl IntoIterator<Item = T>, limit: usize, key_fn: F) -> Vec<T>
138where
139    K: PartialEq,
140    F: Fn(&T) -> K,
141    T: Clone,
142{
143    let mut merged = prior.to_vec();
144    for item in updates {
145        if let Some(idx) = merged.iter().position(|e| key_fn(e) == key_fn(&item)) {
146            merged.remove(idx);
147        }
148        merged.push(item);
149    }
150    let keep_from = merged.len().saturating_sub(limit);
151    merged.into_iter().skip(keep_from).collect()
152}
153
154fn merge_touched_files(prior_envelope: Option<&SessionMemoryEnvelope>, touched_files: &[String]) -> Vec<String> {
155    let prior = prior_envelope.map(|e| e.touched_files.as_slice()).unwrap_or(&[]);
156    merge_dedup_push(prior, touched_files.iter().cloned(), usize::MAX, |s| s.clone())
157}
158
159fn merge_recent_strings(prior: &[String], updates: &[String], limit: usize) -> Vec<String> {
160    let prior_normalized: Vec<_> = prior
161        .iter()
162        .map(|v| normalize_whitespace(v))
163        .filter(|v| !v.is_empty())
164        .collect();
165    let updates_normalized: Vec<_> = updates
166        .iter()
167        .map(|v| normalize_whitespace(v))
168        .filter(|v| !v.is_empty())
169        .collect();
170    merge_dedup_push(&prior_normalized, updates_normalized, limit, |s| s.to_ascii_lowercase())
171}
172
173fn merge_applied_intent_ids(prior: &[String], updates: &[String]) -> Vec<String> {
174    merge_dedup_push(prior, updates.iter().cloned(), APPLIED_INTENT_WINDOW, |id| id.clone())
175}
176
177fn extract_constraints_from_summary(text: Option<&str>) -> Vec<String> {
178    text.into_iter()
179        .flat_map(|value| value.split(" | "))
180        .map(normalize_whitespace)
181        .filter(|line| !line.is_empty())
182        .filter_map(|line| {
183            let line = line.as_str();
184            // Artifact summaries join their lines as "Spec: - a | - b"; strip
185            // the label only when the payload keeps the bullet so ordinary
186            // "key: value" lines are never misread as constraint bullets.
187            let line = match line.split_once(": ") {
188                Some((_, rest)) if rest.starts_with("- ") || rest.starts_with("* ") => rest,
189                _ => line,
190            };
191            if let Some(rest) = line.strip_prefix("- ") {
192                return Some(rest.trim().to_string());
193            }
194            line.strip_prefix("* ").map(|rest| rest.trim().to_string())
195        })
196        .take(MEMORY_LIST_LIMIT)
197        .collect()
198}
199
200pub fn derive_continuity_summary(
201    history: &[Message],
202    prior_envelope: Option<&SessionMemoryEnvelope>,
203    task_snapshot: &TaskTrackerSnapshot,
204) -> String {
205    let objective = task_snapshot
206        .objective
207        .as_deref()
208        .or_else(|| prior_envelope.and_then(|e| e.objective.as_deref()))
209        .filter(|s| !s.is_empty());
210
211    let latest = history
212        .iter()
213        .rev()
214        .filter(|message| message.role == MessageRole::User || message.role == MessageRole::Assistant)
215        .find_map(|message| {
216            let trimmed = normalize_whitespace(message.content.as_text().as_ref());
217            (!trimmed.is_empty()).then_some((message.role.as_generic_str(), truncate_for_fact(&trimmed, 140)))
218        });
219
220    match (objective, latest) {
221        (Some(obj), Some((role, text))) => {
222            format!("Working on: {obj}. Latest {role} action: {text}.")
223        }
224        (Some(obj), None) => format!("Working on: {obj}. Session continuity preserved."),
225        (None, Some((role, text))) => {
226            format!("Latest {role} action: {text}.")
227        }
228        (None, None) => prior_envelope
229            .map(|envelope| envelope.summary.clone())
230            .unwrap_or_else(|| "Session continuity facts preserved.".to_string()),
231    }
232}
233
234fn merge_grounded_facts(
235    prior_envelope: Option<&SessionMemoryEnvelope>,
236    original_history: &[Message],
237    updates: &[GroundedFactRecord],
238) -> Vec<GroundedFactRecord> {
239    let mut merged = prior_envelope
240        .map(|envelope| envelope.grounded_facts.clone())
241        .unwrap_or_default();
242
243    for fact in dedup_latest_facts(original_history, 5) {
244        let normalized = normalize_whitespace(&fact.fact).to_ascii_lowercase();
245        if let Some(existing_idx) = merged
246            .iter()
247            .position(|entry| normalize_whitespace(&entry.fact).to_ascii_lowercase() == normalized)
248        {
249            merged.remove(existing_idx);
250        }
251        merged.push(fact.clone());
252    }
253
254    for fact in updates {
255        let normalized = normalize_whitespace(&fact.fact).to_ascii_lowercase();
256        if let Some(existing_idx) = merged
257            .iter()
258            .position(|entry| normalize_whitespace(&entry.fact).to_ascii_lowercase() == normalized)
259        {
260            merged.remove(existing_idx);
261        }
262        merged.push(fact.clone());
263    }
264
265    let keep_from = merged.len().saturating_sub(5);
266    merged.into_iter().skip(keep_from).collect()
267}
268
269#[allow(
270    clippy::too_many_arguments,
271    reason = "Intentional compatibility, platform, or test-only suppression."
272)]
273pub fn build_session_memory_envelope(
274    session_id: &str,
275    workspace_root: &Path,
276    original_history: &[Message],
277    touched_files: &[String],
278    summary: String,
279    history_artifact_path: Option<&PathBuf>,
280    prior_envelope: Option<&SessionMemoryEnvelope>,
281    task_snapshot: &TaskTrackerSnapshot,
282    envelope_update: Option<&SessionMemoryEnvelopeUpdate>,
283) -> SessionMemoryEnvelope {
284    let pe = prior_envelope;
285    let artifact_cutoff = session_artifact_cutoff(workspace_root, session_id);
286    // A present-but-stale artifact must not fall back to a prior envelope's
287    // copy (that re-adopts the same leftover). Only inherit prior when the
288    // file is absent.
289    let spec_present = current_spec_path(workspace_root).exists();
290    let evaluation_present = current_evaluation_path(workspace_root).exists();
291    let spec_summary = if spec_present {
292        read_spec_summary_fresh(workspace_root, artifact_cutoff)
293    } else {
294        pe.and_then(|e| e.spec_summary.clone())
295    };
296    let evaluation_summary = if evaluation_present {
297        read_evaluation_summary_fresh(workspace_root, artifact_cutoff)
298    } else {
299        pe.and_then(|e| e.evaluation_summary.clone())
300    };
301    let merge = |prior: &[String], updates: &[String]| merge_recent_strings(prior, updates, MEMORY_LIST_LIMIT);
302    // Constraints extracted from a dropped stale artifact must not re-enter via
303    // the prior envelope: a present artifact that failed the freshness check
304    // resets the channel. Fresh artifacts (or no artifact files) keep the
305    // continuity merge of inherited constraints.
306    let artifact_channel_reset = (spec_present
307        && artifact_is_stale(&current_spec_path(workspace_root), artifact_cutoff))
308        || (evaluation_present && artifact_is_stale(&current_evaluation_path(workspace_root), artifact_cutoff));
309    let prior_constraints: &[String] = if artifact_channel_reset {
310        &[]
311    } else {
312        pe.map(|e| e.constraints.as_slice()).unwrap_or(&[])
313    };
314    let constraints = merge(prior_constraints, &extract_constraints_from_summary(spec_summary.as_deref()));
315    let constraints = merge(&constraints, &extract_constraints_from_summary(evaluation_summary.as_deref()));
316    let update = envelope_update.cloned().unwrap_or_default();
317    let pending_intents = update
318        .pending_intents
319        .map(|intents| intents.into_iter().take(MAX_QUEUED_FOLLOW_UP_INTENTS).collect())
320        .or_else(|| pe.map(|envelope| envelope.pending_intents.clone()))
321        .unwrap_or_default();
322    let applied_intent_ids = merge_applied_intent_ids(
323        pe.map(|envelope| envelope.applied_intent_ids.as_slice()).unwrap_or(&[]),
324        &update.applied_intent_ids,
325    );
326
327    // Live task-tracker identity wins over a prior envelope from another
328    // session/task. Workspace-global `current_task.md` is a live snapshot
329    // source; inheriting a stale prior objective re-labels later sessions.
330    let live_objective = task_snapshot.objective.as_deref().map(str::trim).filter(|s| !s.is_empty());
331    let prior_objective = pe.and_then(|e| e.objective.as_deref()).map(str::trim).filter(|s| !s.is_empty());
332    let objective_changed = match (live_objective, prior_objective) {
333        (Some(live), Some(prior)) => live != prior,
334        (Some(_), None) => true,
335        (None, Some(_)) => false,
336        (None, None) => false,
337    };
338
339    let objective = update
340        .objective
341        .or_else(|| live_objective.map(ToOwned::to_owned))
342        .or_else(|| pe.and_then(|e| e.objective.clone()));
343
344    let task_summary = if objective_changed {
345        // Spec S2: never inherit another task's checklist narrative.
346        task_snapshot.summary.clone().filter(|s| !s.trim().is_empty())
347    } else {
348        pe.and_then(|e| e.task_summary.clone())
349            .or_else(|| task_snapshot.summary.clone())
350    };
351
352    let verification_todo = if objective_changed {
353        // Replace, do not union: prior-task open items are not this session's todos.
354        task_snapshot
355            .verification_todo
356            .iter()
357            .cloned()
358            .chain(update.verification_todo.iter().cloned())
359            .take(MEMORY_LIST_LIMIT)
360            .collect()
361    } else {
362        merge(
363            pe.map(|e| e.verification_todo.as_slice()).unwrap_or(&[]),
364            &task_snapshot
365                .verification_todo
366                .iter()
367                .cloned()
368                .chain(update.verification_todo)
369                .collect::<Vec<_>>(),
370        )
371    };
372
373    SessionMemoryEnvelope {
374        session_id: session_id.to_string(),
375        schema_version: Some(SESSION_MEMORY_ENVELOPE_SCHEMA_VERSION),
376        summary,
377        objective,
378        task_summary,
379        spec_summary,
380        evaluation_summary,
381        verification_summary: task_snapshot
382            .verification_summary
383            .clone()
384            .or_else(|| pe.and_then(|e| e.verification_summary.clone())),
385        constraints: merge(&constraints, &update.constraints),
386        grounded_facts: merge_grounded_facts(pe, original_history, &update.grounded_facts),
387        touched_files: merge_touched_files(
388            pe,
389            &touched_files.iter().cloned().chain(update.touched_files).collect::<Vec<_>>(),
390        ),
391        open_questions: merge(pe.map(|e| e.open_questions.as_slice()).unwrap_or(&[]), &update.open_questions),
392        verification_todo,
393        delegation_notes: merge(pe.map(|e| e.delegation_notes.as_slice()).unwrap_or(&[]), &update.delegation_notes),
394        pending_intents,
395        applied_intent_ids,
396        history_artifact_path: history_artifact_path
397            .map(|p| p.display().to_string())
398            .or_else(|| pe.and_then(|e| e.history_artifact_path.clone())),
399        generated_at: Utc::now().to_rfc3339(),
400    }
401}
402
403/// Persist the recoverable full-history artifact and build + inject a session
404/// memory envelope into the compacted history. Returns the envelope (with the
405/// artifact path) when one was produced.
406pub fn persist_memory_envelope(
407    workspace_root: &Path,
408    session_id: &str,
409    vt_cfg: Option<&VTCodeConfig>,
410    original_history: &[Message],
411    touched_files: &[String],
412    compacted: &mut Vec<Message>,
413    persistence: MemoryEnvelopePersistence,
414    placement: MemoryEnvelopePlacement,
415    seed_envelope: Option<&SessionMemoryEnvelope>,
416) -> anyhow::Result<Option<SessionMemoryEnvelope>> {
417    persist_memory_envelope_with_update(
418        workspace_root,
419        session_id,
420        vt_cfg,
421        original_history,
422        touched_files,
423        compacted,
424        persistence,
425        placement,
426        seed_envelope,
427        None,
428    )
429}
430
431/// Variant of [`persist_memory_envelope`] that applies a live session update
432/// while constructing the envelope. Keeping the compatibility wrapper above
433/// avoids changing synchronous callers that do not track steering state.
434#[allow(
435    clippy::too_many_arguments,
436    reason = "Intentional compatibility, platform, or test-only suppression."
437)]
438pub fn persist_memory_envelope_with_update(
439    workspace_root: &Path,
440    session_id: &str,
441    vt_cfg: Option<&VTCodeConfig>,
442    original_history: &[Message],
443    touched_files: &[String],
444    compacted: &mut Vec<Message>,
445    persistence: MemoryEnvelopePersistence,
446    placement: MemoryEnvelopePlacement,
447    seed_envelope: Option<&SessionMemoryEnvelope>,
448    envelope_update: Option<&SessionMemoryEnvelopeUpdate>,
449) -> anyhow::Result<Option<SessionMemoryEnvelope>> {
450    let should_persist = should_persist_memory_envelope(vt_cfg);
451    if original_history.is_empty() || (!should_persist && persistence == MemoryEnvelopePersistence::PersistToDisk) {
452        return Ok(None);
453    }
454
455    let task_snapshot = read_task_tracker_snapshot(workspace_root);
456    let history_artifact_path = if should_persist && persistence == MemoryEnvelopePersistence::PersistToDisk {
457        let mut hm = HistoryFileManager::new(workspace_root, session_id);
458        let hm2 = messages_to_history_messages(original_history, 0);
459        let hr = hm
460            .write_history_sync(&hm2, original_history.len(), "compaction", touched_files, &[])
461            .context("write compaction history artifact")?;
462        Some(hr.file_path)
463    } else {
464        None
465    };
466    let loaded = if seed_envelope.is_none() {
467        load_latest_memory_envelope(workspace_root, session_id)
468    } else {
469        None
470    };
471    let prior = seed_envelope.or(loaded.as_ref());
472    let envelope = build_session_memory_envelope(
473        session_id,
474        workspace_root,
475        original_history,
476        touched_files,
477        extract_compaction_summary(compacted, original_history),
478        history_artifact_path.as_ref(),
479        prior,
480        &task_snapshot,
481        envelope_update,
482    );
483
484    if let Some(hap) = history_artifact_path.as_ref() {
485        write_memory_envelope_to_path(&memory_envelope_path_from_history_path(workspace_root, hap), &envelope)?;
486    }
487    apply_memory_envelope(compacted, &envelope, placement);
488    Ok(Some(envelope))
489}
490
491/// Async counterpart to [`persist_memory_envelope`]. Compaction runs on the
492/// async agent loop, so filesystem work must not block the runtime while the
493/// recoverable history artifact or envelope is being written.
494pub async fn persist_memory_envelope_async(
495    workspace_root: &Path,
496    session_id: &str,
497    vt_cfg: Option<&VTCodeConfig>,
498    original_history: &[Message],
499    touched_files: &[String],
500    compacted: &mut Vec<Message>,
501    persistence: MemoryEnvelopePersistence,
502    placement: MemoryEnvelopePlacement,
503    seed_envelope: Option<&SessionMemoryEnvelope>,
504) -> anyhow::Result<Option<SessionMemoryEnvelope>> {
505    persist_memory_envelope_async_with_update(
506        workspace_root,
507        session_id,
508        vt_cfg,
509        original_history,
510        touched_files,
511        compacted,
512        persistence,
513        placement,
514        seed_envelope,
515        None,
516    )
517    .await
518}
519
520/// Async variant of [`persist_memory_envelope_async`] that applies a live
521/// session update while constructing the envelope.
522#[allow(
523    clippy::too_many_arguments,
524    reason = "Intentional compatibility, platform, or test-only suppression."
525)]
526pub async fn persist_memory_envelope_async_with_update(
527    workspace_root: &Path,
528    session_id: &str,
529    vt_cfg: Option<&VTCodeConfig>,
530    original_history: &[Message],
531    touched_files: &[String],
532    compacted: &mut Vec<Message>,
533    persistence: MemoryEnvelopePersistence,
534    placement: MemoryEnvelopePlacement,
535    seed_envelope: Option<&SessionMemoryEnvelope>,
536    envelope_update: Option<&SessionMemoryEnvelopeUpdate>,
537) -> anyhow::Result<Option<SessionMemoryEnvelope>> {
538    let should_persist = should_persist_memory_envelope(vt_cfg);
539    if original_history.is_empty() || (!should_persist && persistence == MemoryEnvelopePersistence::PersistToDisk) {
540        return Ok(None);
541    }
542
543    let task_snapshot = read_task_tracker_snapshot_async(workspace_root).await;
544    let history_artifact_path = if should_persist && persistence == MemoryEnvelopePersistence::PersistToDisk {
545        let mut history_manager = HistoryFileManager::new(workspace_root, session_id);
546        let history_messages = messages_to_history_messages(original_history, 0);
547        let history_result = history_manager
548            .write_history(&history_messages, original_history.len(), "compaction", touched_files, &[])
549            .await
550            .context("write compaction history artifact")?;
551        Some(history_result.file_path)
552    } else {
553        None
554    };
555
556    let loaded = if seed_envelope.is_none() {
557        load_latest_memory_envelope_async(workspace_root, session_id).await
558    } else {
559        None
560    };
561    let prior = seed_envelope.or(loaded.as_ref());
562    let envelope = build_session_memory_envelope(
563        session_id,
564        workspace_root,
565        original_history,
566        touched_files,
567        extract_compaction_summary(compacted, original_history),
568        history_artifact_path.as_ref(),
569        prior,
570        &task_snapshot,
571        envelope_update,
572    );
573
574    if let Some(history_path) = history_artifact_path.as_ref() {
575        write_memory_envelope_to_path_async(
576            &memory_envelope_path_from_history_path(workspace_root, history_path),
577            &envelope,
578        )
579        .await?;
580    }
581    apply_memory_envelope(compacted, &envelope, placement);
582    Ok(Some(envelope))
583}
584
585pub fn should_persist_memory_envelope(vt_cfg: Option<&VTCodeConfig>) -> bool {
586    vt_cfg.is_some_and(|cfg| cfg.context.dynamic.enabled && cfg.context.dynamic.persist_history)
587}
588
589fn memory_envelope_message(envelope: &SessionMemoryEnvelope) -> Message {
590    let mut sections = Vec::new();
591    sections.push(format!("{}\nSummary:\n{}", MEMORY_ENVELOPE_HEADER, envelope.summary.trim()));
592
593    fn maybe_section(prefix: &str, content: Option<&str>) -> Option<String> {
594        content
595            .map(str::trim)
596            .filter(|s| !s.is_empty())
597            .map(|s| format!("{prefix}\n{s}"))
598    }
599
600    fn list_section(prefix: &str, items: &[String]) -> Option<String> {
601        (!items.is_empty()).then(|| format!("{prefix}\n- {}", items.join("\n- ")))
602    }
603
604    if let Some(s) = maybe_section("Objective", envelope.objective.as_deref()) {
605        sections.push(s);
606    }
607    if let Some(s) = maybe_section("Task Tracker", envelope.task_summary.as_deref()) {
608        sections.push(s);
609    }
610    if let Some(s) = maybe_section("Spec Summary", envelope.spec_summary.as_deref()) {
611        sections.push(s);
612    }
613    if let Some(s) = maybe_section("Evaluation Summary", envelope.evaluation_summary.as_deref()) {
614        sections.push(s);
615    }
616    if let Some(s) = maybe_section("Verification Status", envelope.verification_summary.as_deref()) {
617        sections.push(s);
618    }
619    if let Some(s) = list_section("Constraints", &envelope.constraints) {
620        sections.push(s);
621    }
622    if let Some(s) = list_section("Touched Files", &envelope.touched_files) {
623        sections.push(s);
624    }
625
626    if !envelope.grounded_facts.is_empty() {
627        let facts: Vec<_> = envelope
628            .grounded_facts
629            .iter()
630            .map(|f| format!("[{}] {}", f.source, f.fact.trim()))
631            .collect();
632        sections.push(format!("Grounded Facts:\n{}", facts.join("\n")));
633    }
634    if let Some(s) = list_section("Open Questions", &envelope.open_questions) {
635        sections.push(s);
636    }
637    if let Some(s) = list_section("Verification Todo", &envelope.verification_todo) {
638        sections.push(s);
639    }
640    if let Some(s) = list_section("Delegation Notes", &envelope.delegation_notes) {
641        sections.push(s);
642    }
643    if let Some(s) = maybe_section("History Artifact", envelope.history_artifact_path.as_deref()) {
644        sections.push(s);
645    }
646
647    Message::system(sections.join("\n\n"))
648}
649
650fn is_compaction_summary_message(message: &Message) -> bool {
651    message.role == MessageRole::System && message.content.as_text().starts_with("Previous conversation summary:\n")
652}
653
654pub fn strip_existing_memory_envelope(history: &mut Vec<Message>) {
655    history.retain(|message| {
656        !(message.role == MessageRole::System && message.content.as_text().starts_with(MEMORY_ENVELOPE_HEADER))
657    });
658}
659
660fn extract_compaction_summary(compacted: &[Message], original_history: &[Message]) -> String {
661    if let Some(summary) = compacted.iter().find_map(|message| {
662        if message.role != MessageRole::System {
663            return None;
664        }
665
666        let text = message.content.as_text();
667        let trimmed = text.trim();
668        trimmed
669            .strip_prefix("Previous conversation summary:\n")
670            .map(str::trim)
671            .filter(|value| !value.is_empty())
672            .map(str::to_string)
673    }) {
674        return summary;
675    }
676
677    if let Some(summary) = compacted.iter().find_map(|message| {
678        if message.role != MessageRole::Assistant {
679            return None;
680        }
681
682        message.reasoning_details.as_ref()?.iter().find_map(|detail| {
683            let value = match detail {
684                Value::String(serialized) => serde_json::from_str::<Value>(serialized).ok()?,
685                value => value.clone(),
686            };
687            let summary = value
688                .get("content")
689                .and_then(Value::as_str)
690                .map(str::trim)
691                .filter(|summary| !summary.is_empty())?;
692            (value.get("type").and_then(Value::as_str) == Some("compaction")).then_some(summary.to_string())
693        })
694    }) {
695        return summary;
696    }
697
698    let mut recent = original_history
699        .iter()
700        .rev()
701        .filter_map(|message| {
702            let text = message.content.as_text();
703            let trimmed = normalize_whitespace(text.as_ref());
704            (!trimmed.is_empty()).then_some(format!(
705                "{}: {}",
706                message.role.as_generic_str(),
707                truncate_for_fact(&trimmed, 160)
708            ))
709        })
710        .take(4)
711        .collect::<Vec<_>>();
712    recent.reverse();
713
714    if recent.is_empty() {
715        "Compacted earlier conversation state and preserved continuity facts.".to_string()
716    } else {
717        format!("Compacted earlier conversation state. Recent preserved context: {}", recent.join(" | "))
718    }
719}
720
721/// Sanitize a session id the same way history envelope filenames do
722/// (32-char ASCII-safe prefix). Callers that match envelope names must use
723/// this, not the raw session id.
724pub fn sanitize_session_id(session_id: &str) -> String {
725    session_id
726        .chars()
727        .map(|c| {
728            if c.is_alphanumeric() || c == '_' || c == '-' {
729                c
730            } else {
731                '_'
732            }
733        })
734        .take(32)
735        .collect()
736}
737
738/// Whether a history envelope filename belongs to `session_id`.
739///
740/// Envelope names use the 32-char sanitized id, with optional `_<n>`
741/// suffixes. Matching must use this exact rule — a loose `starts_with` on
742/// the raw id over-preserves unrelated sessions.
743pub fn memory_envelope_file_matches_session(name: &str, session_id: &str) -> bool {
744    let session_prefix = sanitize_session_id(session_id);
745    name == format!("{session_prefix}{MEMORY_ENVELOPE_SUFFIX}")
746        || (name.starts_with(&format!("{session_prefix}_")) && name.ends_with(MEMORY_ENVELOPE_SUFFIX))
747}
748
749fn parse_task_tracker_snapshot(content: &str) -> TaskTrackerSnapshot {
750    let title = content
751        .lines()
752        .find(|line| line.starts_with("# "))
753        .map(|line| line.trim_start_matches("# ").trim().to_string());
754    let checklist = content
755        .lines()
756        .filter(|line| line.trim_start().starts_with("- ["))
757        .take(5)
758        .map(normalize_whitespace)
759        .collect::<Vec<_>>();
760    let verification_summary = extract_verification_summary(content, &checklist);
761    let verification_todo = content
762        .lines()
763        .filter(|line| line.trim_start().starts_with("- [ ]"))
764        .take(MEMORY_LIST_LIMIT)
765        .map(normalize_whitespace)
766        .collect::<Vec<_>>();
767    let summary = match (title.clone(), checklist.is_empty()) {
768        (Some(title), false) => Some(format!("{title}: {}", checklist.join(" | "))),
769        (Some(title), true) => Some(title),
770        (None, false) => Some(checklist.join(" | ")),
771        (None, true) => None,
772    };
773
774    TaskTrackerSnapshot {
775        summary,
776        objective: title,
777        verification_summary,
778        verification_todo,
779    }
780}
781
782pub fn read_task_tracker_snapshot(workspace_root: &Path) -> TaskTrackerSnapshot {
783    let tracker_path = current_task_path(workspace_root);
784    fs::read_to_string(&tracker_path)
785        .ok()
786        .map(|content| parse_task_tracker_snapshot(&content))
787        .unwrap_or_default()
788}
789
790/// Read the task tracker without blocking the async runtime.
791pub async fn read_task_tracker_snapshot_async(workspace_root: &Path) -> TaskTrackerSnapshot {
792    let tracker_path = current_task_path(workspace_root);
793    async_fs::read_to_string(tracker_path)
794        .await
795        .ok()
796        .map(|content| parse_task_tracker_snapshot(&content))
797        .unwrap_or_default()
798}
799
800fn extract_verification_summary(content: &str, checklist: &[String]) -> Option<String> {
801    let verify_commands = collect_structured_verify_commands(content);
802    if !verify_commands.is_empty() {
803        return Some(render_bullet_list(&verify_commands));
804    }
805
806    let fallback_lines = checklist
807        .iter()
808        .filter(|line| looks_like_verification_line(line))
809        .cloned()
810        .collect::<Vec<_>>();
811    (!fallback_lines.is_empty()).then(|| fallback_lines.join("\n"))
812}
813
814/// Split the tail of a `verify:` line into clean commands.
815///
816/// Plan writers sometimes emit `verify: [a] and verify: [b]` on one line.
817/// Strip at most one matching outer `[`…`]` pair per piece and drop empties so
818/// a spliced marker can never leak into `verification_summary`.
819fn split_verify_command_tail(rest: &str) -> Vec<String> {
820    rest.split(" and verify:")
821        .map(str::trim)
822        .map(strip_one_outer_bracket_pair)
823        .filter(|piece| !piece.is_empty())
824        .map(normalize_whitespace)
825        .filter(|piece| !piece.is_empty())
826        .collect()
827}
828
829fn strip_one_outer_bracket_pair(piece: &str) -> &str {
830    let trimmed = piece.trim();
831    // Plan splices can leave the closing bracket off (`verify: [cmd`).
832    let Some(inner) = trimmed.strip_prefix('[').map(str::trim_start) else {
833        return trimmed;
834    };
835    match inner.strip_suffix(']') {
836        Some(stripped) => stripped.trim_end(),
837        None => inner,
838    }
839}
840
841fn collect_structured_verify_commands(content: &str) -> Vec<String> {
842    let mut commands = Vec::new();
843    let mut in_verify_block = false;
844
845    for line in content.lines() {
846        let trimmed = line.trim_start();
847        if let Some(rest) = trimmed.strip_prefix("verify:") {
848            for command in split_verify_command_tail(rest) {
849                commands.push(command);
850            }
851            in_verify_block = rest.trim().is_empty();
852            continue;
853        }
854
855        if !in_verify_block {
856            continue;
857        }
858
859        if trimmed.is_empty() {
860            continue;
861        }
862
863        if (line.starts_with(' ') || line.starts_with('\t')) && trimmed.starts_with("- ") {
864            commands.push(normalize_whitespace(trimmed.trim_start_matches("- ")));
865            continue;
866        }
867
868        in_verify_block = false;
869    }
870
871    commands
872}
873
874fn render_bullet_list(items: &[String]) -> String {
875    items.iter().map(|item| format!("- {item}")).collect::<Vec<_>>().join("\n")
876}
877
878fn looks_like_verification_line(line: &str) -> bool {
879    let lowered = line.to_ascii_lowercase();
880    [
881        "verify",
882        "verification",
883        "test",
884        "lint",
885        "cargo check",
886        "check-dev.sh",
887        "check.sh",
888    ]
889    .iter()
890    .any(|keyword| lowered.contains(keyword))
891}
892
893fn memory_envelope_path_from_history_path(workspace_root: &Path, history_path: &Path) -> PathBuf {
894    let absolute_history_path = if history_path.is_absolute() {
895        history_path.to_path_buf()
896    } else {
897        workspace_root.join(history_path)
898    };
899
900    let file_name = absolute_history_path
901        .file_name()
902        .and_then(|name| name.to_str())
903        .map(|name| {
904            if let Some(stem) = name.strip_suffix(".jsonl") {
905                format!("{stem}{MEMORY_ENVELOPE_SUFFIX}")
906            } else {
907                format!("{name}{MEMORY_ENVELOPE_SUFFIX}")
908            }
909        })
910        .unwrap_or_else(|| format!("session_memory{MEMORY_ENVELOPE_SUFFIX}"));
911
912    let parent = absolute_history_path
913        .parent()
914        .map(Path::to_path_buf)
915        .unwrap_or_else(|| workspace_root.join(".vtcode").join("history"));
916    parent.join(file_name)
917}
918
919pub fn default_memory_envelope_path_for_session(workspace_root: &Path, session_id: &str) -> PathBuf {
920    workspace_root
921        .join(".vtcode")
922        .join("history")
923        .join(format!("{}{MEMORY_ENVELOPE_SUFFIX}", sanitize_session_id(session_id)))
924}
925
926fn memory_envelope_paths_for_session(workspace_root: &Path, session_id: &str) -> Vec<PathBuf> {
927    let history_dir = workspace_root.join(".vtcode").join("history");
928    let mut candidates = fs::read_dir(history_dir)
929        .ok()
930        .into_iter()
931        .flat_map(|entries| entries.flatten())
932        .map(|entry| entry.path())
933        .filter(|path| {
934            path.file_name()
935                .and_then(|name| name.to_str())
936                .is_some_and(|name| memory_envelope_file_matches_session(name, session_id))
937        })
938        .collect::<Vec<_>>();
939    candidates.sort_by(|left, right| {
940        let left_modified = fs::metadata(left).and_then(|metadata| metadata.modified()).ok();
941        let right_modified = fs::metadata(right).and_then(|metadata| metadata.modified()).ok();
942        right_modified
943            .cmp(&left_modified)
944            .then_with(|| right.file_name().cmp(&left.file_name()))
945    });
946    candidates
947}
948
949pub fn latest_memory_envelope_path_for_session(workspace_root: &Path, session_id: &str) -> Option<PathBuf> {
950    memory_envelope_paths_for_session(workspace_root, session_id)
951        .into_iter()
952        .find(|path| {
953            fs::read_to_string(path)
954                .ok()
955                .and_then(|content| serde_json::from_str::<SessionMemoryEnvelope>(&content).ok())
956                .is_some_and(|envelope| envelope.session_id.is_empty() || envelope.session_id == session_id)
957        })
958}
959
960pub fn load_latest_memory_envelope(workspace_root: &Path, session_id: &str) -> Option<SessionMemoryEnvelope> {
961    let path = latest_memory_envelope_path_for_session(workspace_root, session_id)?;
962    let content = fs::read_to_string(path).ok()?;
963    let envelope: SessionMemoryEnvelope = serde_json::from_str(&content).ok()?;
964    if !envelope.session_id.is_empty() && envelope.session_id != session_id {
965        return None;
966    }
967    Some(envelope)
968}
969
970async fn memory_envelope_paths_for_session_async(workspace_root: &Path, session_id: &str) -> Vec<PathBuf> {
971    let history_dir = workspace_root.join(".vtcode").join("history");
972    let mut candidates = Vec::new();
973    let Ok(mut entries) = async_fs::read_dir(history_dir).await else {
974        return candidates;
975    };
976
977    while let Ok(Some(entry)) = entries.next_entry().await {
978        let path = entry.path();
979        let matches = path
980            .file_name()
981            .and_then(|name| name.to_str())
982            .is_some_and(|name| memory_envelope_file_matches_session(name, session_id));
983        if matches {
984            candidates.push(path);
985        }
986    }
987
988    let mut modified = Vec::with_capacity(candidates.len());
989    for path in candidates {
990        let timestamp = async_fs::metadata(&path)
991            .await
992            .ok()
993            .and_then(|metadata| metadata.modified().ok());
994        modified.push((timestamp, path));
995    }
996    modified.sort_by(|(left_time, left_path), (right_time, right_path)| {
997        right_time
998            .cmp(left_time)
999            .then_with(|| right_path.file_name().cmp(&left_path.file_name()))
1000    });
1001    modified.into_iter().map(|(_, path)| path).collect()
1002}
1003
1004/// Find and deserialize the newest valid envelope without synchronously
1005/// scanning historical candidates on the runtime thread.
1006pub async fn latest_memory_envelope_path_for_session_async(workspace_root: &Path, session_id: &str) -> Option<PathBuf> {
1007    for path in memory_envelope_paths_for_session_async(workspace_root, session_id).await {
1008        let Ok(content) = async_fs::read_to_string(&path).await else {
1009            continue;
1010        };
1011        let Ok(envelope) = serde_json::from_str::<SessionMemoryEnvelope>(&content) else {
1012            continue;
1013        };
1014        if envelope.session_id.is_empty() || envelope.session_id == session_id {
1015            return Some(path);
1016        }
1017    }
1018    None
1019}
1020
1021pub async fn load_latest_memory_envelope_async(
1022    workspace_root: &Path,
1023    session_id: &str,
1024) -> Option<SessionMemoryEnvelope> {
1025    let path = latest_memory_envelope_path_for_session_async(workspace_root, session_id).await?;
1026    let content = async_fs::read_to_string(path).await.ok()?;
1027    let envelope: SessionMemoryEnvelope = serde_json::from_str(&content).ok()?;
1028    if !envelope.session_id.is_empty() && envelope.session_id != session_id {
1029        return None;
1030    }
1031    Some(envelope)
1032}
1033
1034pub fn insert_memory_envelope_message(
1035    history: &mut Vec<Message>,
1036    envelope: &SessionMemoryEnvelope,
1037    placement: MemoryEnvelopePlacement,
1038) {
1039    let message = memory_envelope_message(envelope);
1040    match placement {
1041        MemoryEnvelopePlacement::Start => history.insert(0, message),
1042        MemoryEnvelopePlacement::BeforeLastUserOrSummary => {
1043            let insert_at = history
1044                .iter()
1045                .rposition(|item| item.role == MessageRole::User || is_compaction_summary_message(item))
1046                .unwrap_or(0);
1047            history.insert(insert_at, message);
1048        }
1049    }
1050}
1051
1052fn apply_memory_envelope(
1053    compacted: &mut Vec<Message>,
1054    envelope: &SessionMemoryEnvelope,
1055    placement: MemoryEnvelopePlacement,
1056) {
1057    strip_existing_memory_envelope(compacted);
1058    insert_memory_envelope_message(compacted, envelope, placement);
1059}
1060
1061pub fn inject_latest_memory_envelope(workspace_root: &Path, session_id: &str, history: &mut Vec<Message>) -> bool {
1062    let Some(envelope) = load_latest_memory_envelope(workspace_root, session_id) else {
1063        return false;
1064    };
1065
1066    strip_existing_memory_envelope(history);
1067    insert_memory_envelope_message(history, &envelope, MemoryEnvelopePlacement::Start);
1068    true
1069}
1070
1071pub fn write_memory_envelope_to_path(path: &Path, envelope: &SessionMemoryEnvelope) -> anyhow::Result<()> {
1072    if let Some(parent) = path.parent() {
1073        fs::create_dir_all(parent).with_context(|| format!("create memory envelope directory {}", parent.display()))?;
1074    }
1075    let serialized = serde_json::to_string_pretty(envelope)?;
1076    let temporary_path = memory_envelope_temporary_path(path);
1077    fs::write(&temporary_path, serialized).with_context(|| format!("write memory envelope {}", path.display()))?;
1078    replace_memory_envelope_file(&temporary_path, path)
1079        .with_context(|| format!("replace memory envelope {}", path.display()))?;
1080    Ok(())
1081}
1082
1083/// Atomically replace an envelope from an async context. The temporary file is
1084/// created beside the destination so rename remains atomic on the same volume.
1085pub async fn write_memory_envelope_to_path_async(path: &Path, envelope: &SessionMemoryEnvelope) -> anyhow::Result<()> {
1086    if let Some(parent) = path.parent() {
1087        async_fs::create_dir_all(parent)
1088            .await
1089            .with_context(|| format!("create memory envelope directory {}", parent.display()))?;
1090    }
1091    let serialized = serde_json::to_string_pretty(envelope)?;
1092    let temporary_path = memory_envelope_temporary_path(path);
1093    async_fs::write(&temporary_path, serialized)
1094        .await
1095        .with_context(|| format!("write memory envelope {}", path.display()))?;
1096    replace_memory_envelope_file_async(&temporary_path, path)
1097        .await
1098        .with_context(|| format!("replace memory envelope {}", path.display()))?;
1099    Ok(())
1100}
1101
1102fn memory_envelope_temporary_path(path: &Path) -> PathBuf {
1103    let suffix = format!("{}.tmp-{}", std::process::id(), Utc::now().timestamp_nanos_opt().unwrap_or_default());
1104    let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("session.memory.json");
1105    path.with_file_name(format!("{file_name}.{suffix}"))
1106}
1107
1108fn replace_memory_envelope_file(temporary_path: &Path, destination: &Path) -> std::io::Result<()> {
1109    #[cfg(windows)]
1110    match fs::remove_file(destination) {
1111        Ok(()) => {}
1112        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1113        Err(error) => return Err(error),
1114    }
1115
1116    fs::rename(temporary_path, destination)
1117}
1118
1119async fn replace_memory_envelope_file_async(temporary_path: &Path, destination: &Path) -> std::io::Result<()> {
1120    #[cfg(windows)]
1121    match async_fs::remove_file(destination).await {
1122        Ok(()) => {}
1123        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1124        Err(error) => return Err(error),
1125    }
1126
1127    async_fs::rename(temporary_path, destination).await
1128}
1129
1130pub fn has_latest_memory_envelope(workspace_root: &Path, session_id: &str) -> bool {
1131    latest_memory_envelope_path_for_session(workspace_root, session_id).is_some()
1132}
1133
1134// ---------------------------------------------------------------------------
1135// Local compaction configuration + zero-cost fork history
1136// ---------------------------------------------------------------------------
1137
1138pub fn configured_retained_user_messages(vt_cfg: Option<&VTCodeConfig>) -> usize {
1139    vt_cfg.map(|cfg| cfg.context.dynamic.retained_user_messages).unwrap_or(4)
1140}
1141
1142pub fn local_compaction_config(vt_cfg: Option<&VTCodeConfig>, always_summarize: bool) -> CompactionConfig {
1143    CompactionConfig {
1144        always_summarize,
1145        retained_user_messages: configured_retained_user_messages(vt_cfg),
1146        ..CompactionConfig::default()
1147    }
1148}
1149
1150fn collect_zero_cost_retained_user_messages(
1151    history: &[Message],
1152    token_budget: usize,
1153    max_messages: usize,
1154) -> Vec<Message> {
1155    if token_budget == 0 || max_messages == 0 {
1156        return Vec::new();
1157    }
1158
1159    let mut kept = Vec::new();
1160    let mut remaining = token_budget;
1161
1162    for message in history.iter().rev() {
1163        if kept.len() >= max_messages {
1164            break;
1165        }
1166        if message.role != MessageRole::User || message.content.trim().is_empty() {
1167            continue;
1168        }
1169
1170        let estimated = message.estimate_tokens();
1171        if estimated <= remaining {
1172            kept.push(message.clone());
1173            remaining = remaining.saturating_sub(estimated);
1174            continue;
1175        }
1176
1177        if remaining > 4 {
1178            let truncated = truncate_to_token_limit(message.content.as_text().as_ref(), remaining.saturating_sub(4));
1179            let trimmed = truncated.trim();
1180            if !trimmed.is_empty() {
1181                kept.push(Message::user(trimmed.to_string()));
1182            }
1183        }
1184        break;
1185    }
1186
1187    kept.reverse();
1188    kept
1189}
1190
1191pub fn build_zero_cost_summarized_fork_history(
1192    source_history: &[Message],
1193    source_envelope: Option<&SessionMemoryEnvelope>,
1194    retained_user_messages: usize,
1195) -> Vec<Message> {
1196    let summary = source_envelope
1197        .map(|envelope| normalize_whitespace(&envelope.summary))
1198        .filter(|summary| !summary.is_empty())
1199        .unwrap_or_else(|| derive_continuity_summary(source_history, source_envelope, &TaskTrackerSnapshot::default()));
1200
1201    let retained_users = collect_zero_cost_retained_user_messages(
1202        source_history,
1203        CompactionConfig::default().retained_user_message_tokens,
1204        retained_user_messages,
1205    );
1206
1207    let mut compacted = Vec::with_capacity(retained_users.len().saturating_add(1));
1208    compacted.push(Message::system(format!("Previous conversation summary:\n{}", summary.trim())));
1209    compacted.extend(retained_users);
1210    compacted
1211}
1212
1213// ---------------------------------------------------------------------------
1214// File-read de-duplication for local compaction
1215// ---------------------------------------------------------------------------
1216
1217#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1218struct FileReadDedupKey {
1219    target: String,
1220    start_line: Option<u64>,
1221    end_line: Option<u64>,
1222    spool_path: Option<String>,
1223}
1224
1225#[derive(Debug, Clone, PartialEq, Eq)]
1226struct FileReadDedupCandidate {
1227    key: FileReadDedupKey,
1228    placeholder_content: String,
1229}
1230
1231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1232enum FileReadToolKind {
1233    ReadFile,
1234    UnifiedFileRead,
1235}
1236
1237fn is_read_file_tool_name(tool_name: &str) -> bool {
1238    tool_name == tool_names::READ_FILE || tool_name.ends_with(".read_file")
1239}
1240
1241fn collect_file_read_tool_kinds(history: &[Message]) -> HashMap<String, FileReadToolKind> {
1242    let mut kinds = HashMap::new();
1243    for message in history {
1244        let Some(tool_calls) = message.tool_calls.as_ref() else {
1245            continue;
1246        };
1247        for tc in tool_calls {
1248            let Some(tn) = tc.tool_name() else {
1249                continue;
1250            };
1251            let kind = if is_read_file_tool_name(tn) {
1252                Some(FileReadToolKind::ReadFile)
1253            } else if tn == tool_names::UNIFIED_FILE {
1254                tc.execution_arguments().ok().and_then(|args| {
1255                    args.get("action")
1256                        .and_then(Value::as_str)
1257                        .filter(|a| *a == "read")
1258                        .map(|_| FileReadToolKind::UnifiedFileRead)
1259                })
1260            } else {
1261                None
1262            };
1263            if let Some(k) = kind {
1264                kinds.insert(tc.id.clone(), k);
1265            }
1266        }
1267    }
1268    kinds
1269}
1270
1271fn normalize_file_read_target(value: &str) -> Option<String> {
1272    let trimmed = value.trim();
1273    (!trimmed.is_empty()).then(|| trimmed.replace('\\', "/"))
1274}
1275
1276fn build_file_read_dedup_key(payload: &Value) -> Option<FileReadDedupKey> {
1277    let obj = payload.as_object()?;
1278    if obj.get("items").is_some()
1279        || obj.get("error").is_some()
1280        || obj.get("spool_chunked").and_then(Value::as_bool).unwrap_or(false)
1281        || obj.get("has_more").and_then(Value::as_bool).unwrap_or(false)
1282    {
1283        return None;
1284    }
1285    let target = obj
1286        .get("file_path")
1287        .and_then(Value::as_str)
1288        .or_else(|| obj.get("path").and_then(Value::as_str))
1289        .and_then(normalize_file_read_target)?;
1290    Some(FileReadDedupKey {
1291        target,
1292        start_line: obj.get("start_line").and_then(Value::as_u64),
1293        end_line: obj.get("end_line").and_then(Value::as_u64),
1294        spool_path: obj
1295            .get("spool_path")
1296            .and_then(Value::as_str)
1297            .and_then(normalize_file_read_target),
1298    })
1299}
1300
1301fn build_file_read_placeholder_content(payload: &Value, key: &FileReadDedupKey) -> String {
1302    let mut p = serde_json::Map::new();
1303    p.insert("deduped_read".into(), Value::Bool(true));
1304    p.insert("note".into(), Value::String(DEDUPED_FILE_READ_NOTE.to_string()));
1305
1306    fn maybe_str(p: &mut serde_json::Map<String, Value>, payload: &Value, key: &str) {
1307        if let Some(s) = payload
1308            .get(key)
1309            .and_then(Value::as_str)
1310            .map(str::trim)
1311            .filter(|s| !s.is_empty())
1312        {
1313            p.insert(key.into(), Value::String(s.to_string()));
1314        }
1315    }
1316
1317    maybe_str(&mut p, payload, "file_path");
1318    maybe_str(&mut p, payload, "path");
1319    if let Some(sl) = key.start_line {
1320        p.insert("start_line".into(), json!(sl));
1321    }
1322    if let Some(el) = key.end_line {
1323        p.insert("end_line".into(), json!(el));
1324    }
1325    if let Some(sp) = key.spool_path.as_deref() {
1326        p.insert("spool_path".into(), json!(sp));
1327    }
1328    Value::Object(p).to_string()
1329}
1330
1331fn file_read_dedup_candidate(
1332    message: &Message,
1333    tool_kinds: &HashMap<String, FileReadToolKind>,
1334) -> Option<FileReadDedupCandidate> {
1335    if message.role != MessageRole::Tool {
1336        return None;
1337    }
1338
1339    let kind = message
1340        .tool_call_id
1341        .as_deref()
1342        .and_then(|tool_call_id| tool_kinds.get(tool_call_id).copied())
1343        .or_else(|| {
1344            message
1345                .origin_tool
1346                .as_deref()
1347                .and_then(|tool_name| is_read_file_tool_name(tool_name).then_some(FileReadToolKind::ReadFile))
1348        })?;
1349
1350    if !matches!(kind, FileReadToolKind::ReadFile | FileReadToolKind::UnifiedFileRead) {
1351        return None;
1352    }
1353
1354    let payload: Value = serde_json::from_str(message.content.as_text().as_ref()).ok()?;
1355    let key = build_file_read_dedup_key(&payload)?;
1356
1357    Some(FileReadDedupCandidate {
1358        placeholder_content: build_file_read_placeholder_content(&payload, &key),
1359        key,
1360    })
1361}
1362
1363pub fn dedup_repeated_file_reads_for_local_compaction(history: &[Message]) -> Vec<Message> {
1364    let tool_kinds = collect_file_read_tool_kinds(history);
1365    let mut last_idx = HashMap::new();
1366    let mut candidates = Vec::new();
1367    for (i, msg) in history.iter().enumerate() {
1368        let Some(c) = file_read_dedup_candidate(msg, &tool_kinds) else {
1369            continue;
1370        };
1371        last_idx.insert(c.key.clone(), i);
1372        candidates.push((i, c));
1373    }
1374    let mut deduped = history.to_vec();
1375    let mut changed = false;
1376    for (idx, c) in candidates {
1377        if last_idx.get(&c.key).copied() == Some(idx) {
1378            continue;
1379        }
1380        if let Some(msg) = deduped.get_mut(idx) {
1381            msg.content = c.placeholder_content.into();
1382            changed = true;
1383        }
1384    }
1385    if changed { deduped } else { history.to_vec() }
1386}
1387
1388// ---------------------------------------------------------------------------
1389// Threshold resolution (shared by every compaction trigger)
1390// ---------------------------------------------------------------------------
1391
1392/// Conservative output reservation when the request does not declare a limit.
1393pub const DEFAULT_OUTPUT_RESERVE_TOKENS: usize = 4096;
1394
1395/// Resolve a prompt threshold while preserving room for the next response.
1396pub fn resolve_compaction_threshold(configured_threshold: Option<u64>, context_size: usize) -> Option<u64> {
1397    resolve_compaction_threshold_with_reserve(configured_threshold, context_size, DEFAULT_OUTPUT_RESERVE_TOKENS)
1398}
1399
1400pub fn resolve_compaction_threshold_with_reserve(
1401    configured_threshold: Option<u64>,
1402    context_size: usize,
1403    reserved_output_tokens: usize,
1404) -> Option<u64> {
1405    let configured = configured_threshold.filter(|value| *value > 0);
1406    if context_size == 0 {
1407        return configured;
1408    }
1409    let prompt_budget = context_size.saturating_sub(reserved_output_tokens).max(1) as u64;
1410    // Default trigger is a ratio of the prompt budget so long runs compact
1411    // before the expensive near-full zone (HarnessTax / session-efficiency).
1412    // An explicit `auto_compaction_threshold_tokens` still wins, capped at the
1413    // prompt budget so compaction never exceeds the usable window.
1414    // Integer percent avoids f64→u64 cast lint; `default_compaction_trigger_uses_ratio_of_prompt_budget`
1415    // pins this to `DEFAULT_COMPACTION_TRIGGER_RATIO`.
1416    const TRIGGER_RATIO_PERCENT: u64 = 75;
1417    let default_trigger = prompt_budget.saturating_mul(TRIGGER_RATIO_PERCENT) / 100;
1418    let default_trigger = default_trigger.clamp(1, prompt_budget);
1419    Some(configured.map_or(default_trigger, |value| value.min(prompt_budget)))
1420}
1421
1422/// Explicit trigger overrides can reduce, but never bypass, the session ceiling.
1423pub fn resolve_effective_compaction_threshold(
1424    configured_threshold: Option<u64>,
1425    provider_context_size: usize,
1426    session_context_budget: usize,
1427) -> Option<u64> {
1428    resolve_compaction_threshold(
1429        configured_threshold,
1430        effective_session_context_budget(provider_context_size, session_context_budget),
1431    )
1432}
1433
1434/// Zero means unknown/unconfigured; positive limits are intersected.
1435#[must_use]
1436pub fn effective_session_context_budget(provider_context_size: usize, session_context_budget: usize) -> usize {
1437    match (provider_context_size > 0, session_context_budget > 0) {
1438        (true, true) => provider_context_size.min(session_context_budget),
1439        (true, false) => provider_context_size,
1440        (false, true) => session_context_budget,
1441        (false, false) => 0,
1442    }
1443}
1444
1445/// Resolve catalog/dynamic model capacity and the provider's route-specific ceiling.
1446#[must_use]
1447pub fn effective_context_budget(vt_cfg: Option<&VTCodeConfig>, provider: &dyn LLMProvider, model: &str) -> usize {
1448    use crate::llm::model_resolver::{DynamicModelMeta, ModelResolver};
1449
1450    let provider_capacity = provider.effective_context_size(model);
1451    // Resolve through the shared model capability path so catalog entries and
1452    // discovered model metadata participate in exactly the same calculation as
1453    // request construction. The provider remains a hard route-specific ceiling
1454    // because a catalog can describe a larger platform window than this endpoint
1455    // exposes (or an explicit `ContextWindowProvider` override can narrow it).
1456    let resolved_capacity = ModelResolver::resolve(
1457        Some(provider.name()),
1458        model,
1459        &[],
1460        Some(DynamicModelMeta {
1461            display_name: model.to_owned(),
1462            description: None,
1463            context_window: (provider_capacity > 0).then_some(provider_capacity),
1464        }),
1465    )
1466    .and_then(|resolved| resolved.context_window())
1467    // Custom provider names and dynamic model ids are intentionally not
1468    // required to exist in the built-in catalog. The provider trait has
1469    // already resolved the route-specific capacity, so keep that value when
1470    // catalog resolution cannot identify the route.
1471    .unwrap_or(provider_capacity);
1472    let capacity = effective_session_context_budget(resolved_capacity, provider_capacity);
1473    let session_budget = vt_cfg.map_or_else(default_max_context_tokens, |cfg| cfg.context.max_context_tokens);
1474    effective_session_context_budget(capacity, session_budget)
1475}
1476
1477pub fn effective_compaction_threshold_with_reserve(
1478    vt_cfg: Option<&VTCodeConfig>,
1479    provider: &dyn LLMProvider,
1480    model: &str,
1481    reserved_output_tokens: usize,
1482) -> Option<usize> {
1483    resolve_compaction_threshold_with_reserve(
1484        vt_cfg.and_then(|cfg| cfg.agent.harness.auto_compaction_threshold_tokens),
1485        effective_context_budget(vt_cfg, provider, model),
1486        reserved_output_tokens,
1487    )
1488    .and_then(|value| usize::try_from(value).ok())
1489}
1490
1491pub fn effective_compaction_threshold(
1492    vt_cfg: Option<&VTCodeConfig>,
1493    provider: &dyn LLMProvider,
1494    model: &str,
1495) -> Option<usize> {
1496    let reserve = provider
1497        .sampling_overrides(model)
1498        .max_tokens
1499        .map_or(DEFAULT_OUTPUT_RESERVE_TOKENS, |value| value as usize);
1500    effective_compaction_threshold_with_reserve(vt_cfg, provider, model, reserve)
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use super::extract_compaction_summary;
1506    use super::{
1507        SessionMemoryEnvelope, TaskTrackerSnapshot, build_session_memory_envelope, parse_task_tracker_snapshot,
1508        resolve_compaction_threshold_with_reserve,
1509    };
1510    use crate::llm::provider::Message;
1511    use std::path::Path;
1512
1513    #[test]
1514    fn default_compaction_trigger_uses_ratio_of_prompt_budget() {
1515        // 100_000 context, 4_096 reserve → prompt budget 95_904.
1516        // Default trigger is 75% of the prompt budget, not the full budget.
1517        let threshold = resolve_compaction_threshold_with_reserve(None, 100_000, 4_096).expect("threshold");
1518        let prompt_budget = 100_000u64 - 4_096;
1519        let expected = prompt_budget * 75 / 100;
1520        assert_eq!(threshold, expected);
1521        // Keep the integer percent in sync with the shared ratio constant.
1522        assert!((vtcode_config::constants::context::DEFAULT_COMPACTION_TRIGGER_RATIO - 0.75).abs() < f64::EPSILON);
1523        assert!(threshold < prompt_budget, "default trigger must fire before the full prompt budget");
1524        // Explicit config still wins and is capped at the prompt budget.
1525        assert_eq!(resolve_compaction_threshold_with_reserve(Some(10_000), 100_000, 4_096), Some(10_000));
1526        assert_eq!(resolve_compaction_threshold_with_reserve(Some(200_000), 100_000, 4_096), Some(prompt_budget));
1527    }
1528
1529    fn tracker_snapshot(summary: &str, objective: &str, todo: &[&str]) -> TaskTrackerSnapshot {
1530        let markdown = format!("# {objective}\n\n- [ ] {}\n", todo.join("\n- [ ] "));
1531        let mut snap = parse_task_tracker_snapshot(&markdown);
1532        snap.summary = Some(summary.to_string());
1533        snap.objective = Some(objective.to_string());
1534        snap.verification_todo = todo.iter().map(|s| (*s).to_string()).collect();
1535        snap
1536    }
1537
1538    #[test]
1539    fn envelope_prefers_live_objective_over_stale_prior() {
1540        let prior = SessionMemoryEnvelope {
1541            session_id: "sess-a".to_string(),
1542            objective: Some("1789-mighty-harbor".to_string()),
1543            task_summary: Some("mighty-harbor: README checklist".to_string()),
1544            verification_todo: vec!["mighty todo".to_string()],
1545            ..Default::default()
1546        };
1547        let live = tracker_snapshot("bright-squid analyze fix", "1789-bright-squid", &["cargo check analyze"]);
1548
1549        let envelope = build_session_memory_envelope(
1550            "sess-b",
1551            Path::new("."),
1552            &[],
1553            &[],
1554            "Working on bright-squid".to_string(),
1555            None,
1556            Some(&prior),
1557            &live,
1558            None,
1559        );
1560
1561        assert_eq!(envelope.objective.as_deref(), Some("1789-bright-squid"));
1562        assert!(
1563            envelope.task_summary.as_deref().unwrap_or_default().contains("bright-squid"),
1564            "task_summary must not keep prior-task narrative: {:?}",
1565            envelope.task_summary
1566        );
1567        assert_eq!(envelope.verification_todo, vec!["cargo check analyze".to_string()]);
1568    }
1569
1570    #[test]
1571    fn envelope_objective_change_drops_prior_task_summary_when_live_empty() {
1572        let prior = SessionMemoryEnvelope {
1573            session_id: "sess-a".to_string(),
1574            objective: Some("task-a".to_string()),
1575            task_summary: Some("task-a: stale checklist".to_string()),
1576            verification_todo: vec!["stale".to_string()],
1577            ..Default::default()
1578        };
1579        let live = TaskTrackerSnapshot {
1580            objective: Some("task-b".to_string()),
1581            summary: None,
1582            ..Default::default()
1583        };
1584
1585        let envelope = build_session_memory_envelope(
1586            "sess-b",
1587            Path::new("."),
1588            &[],
1589            &[],
1590            "summary".to_string(),
1591            None,
1592            Some(&prior),
1593            &live,
1594            None,
1595        );
1596
1597        assert_eq!(envelope.objective.as_deref(), Some("task-b"));
1598        assert_eq!(envelope.task_summary, None);
1599        assert!(envelope.verification_todo.is_empty());
1600    }
1601
1602    #[test]
1603    fn envelope_same_objective_keeps_todo_merge() {
1604        let prior = SessionMemoryEnvelope {
1605            session_id: "sess-a".to_string(),
1606            objective: Some("task-1".to_string()),
1607            task_summary: Some("task-1: shared".to_string()),
1608            verification_todo: vec!["keep me".to_string()],
1609            ..Default::default()
1610        };
1611        let live = tracker_snapshot("task-1 continued", "task-1", &["new item"]);
1612
1613        let envelope = build_session_memory_envelope(
1614            "sess-a",
1615            Path::new("."),
1616            &[],
1617            &[],
1618            "summary".to_string(),
1619            None,
1620            Some(&prior),
1621            &live,
1622            None,
1623        );
1624
1625        assert_eq!(envelope.objective.as_deref(), Some("task-1"));
1626        assert!(envelope.verification_todo.contains(&"keep me".to_string()));
1627        assert!(envelope.verification_todo.contains(&"new item".to_string()));
1628    }
1629
1630    #[test]
1631    fn envelope_stale_artifact_resets_prior_constraints() {
1632        let workspace = tempfile::tempdir().expect("tempdir");
1633        let tasks_dir = workspace.path().join(".vtcode").join("tasks");
1634        std::fs::create_dir_all(&tasks_dir).expect("tasks dir");
1635        std::fs::create_dir_all(workspace.path().join(".vtcode").join("sessions").join("sess-stale"))
1636            .expect("session dir anchors the artifact cutoff");
1637        let spec_path = tasks_dir.join("current_spec.md");
1638        std::fs::write(&spec_path, "# Spec\n- Do not touch prod config\n").expect("write stale spec");
1639        let stale = std::time::SystemTime::now() - std::time::Duration::from_secs(48 * 60 * 60);
1640        let file = std::fs::File::options().write(true).open(&spec_path).expect("open");
1641        file.set_modified(stale).expect("set mtime");
1642
1643        let prior = SessionMemoryEnvelope {
1644            session_id: "sess-stale".to_string(),
1645            constraints: vec!["Do not touch prod config".to_string(), "Keep user budget".to_string()],
1646            ..Default::default()
1647        };
1648
1649        let envelope = build_session_memory_envelope(
1650            "sess-stale",
1651            workspace.path(),
1652            &[],
1653            &[],
1654            "summary".to_string(),
1655            None,
1656            Some(&prior),
1657            &tracker_snapshot("continuing", "sess-stale", &["cargo check"]),
1658            None,
1659        );
1660
1661        assert!(envelope.spec_summary.is_none(), "stale spec must not describe the session");
1662        assert!(
1663            envelope.constraints.is_empty(),
1664            "a stale artifact resets the constraints channel so its lines cannot re-enter: {:?}",
1665            envelope.constraints
1666        );
1667    }
1668
1669    #[test]
1670    fn envelope_fresh_artifact_merges_prior_constraints() {
1671        let workspace = tempfile::tempdir().expect("tempdir");
1672        let tasks_dir = workspace.path().join(".vtcode").join("tasks");
1673        std::fs::create_dir_all(&tasks_dir).expect("tasks dir");
1674        std::fs::create_dir_all(workspace.path().join(".vtcode").join("sessions").join("sess-fresh"))
1675            .expect("session dir anchors the artifact cutoff");
1676        std::fs::write(tasks_dir.join("current_spec.md"), "# Spec\n- Keep coverage at 70%\n")
1677            .expect("write fresh spec");
1678
1679        let prior = SessionMemoryEnvelope {
1680            session_id: "sess-fresh".to_string(),
1681            constraints: vec!["Do not redesign the harness".to_string()],
1682            ..Default::default()
1683        };
1684
1685        let envelope = build_session_memory_envelope(
1686            "sess-fresh",
1687            workspace.path(),
1688            &[],
1689            &[],
1690            "summary".to_string(),
1691            None,
1692            Some(&prior),
1693            &tracker_snapshot("continuing", "sess-fresh", &["cargo check"]),
1694            None,
1695        );
1696
1697        assert!(
1698            envelope.constraints.contains(&"Do not redesign the harness".to_string()),
1699            "fresh artifacts keep the continuity merge of inherited constraints: {:?}",
1700            envelope.constraints
1701        );
1702        assert!(
1703            envelope.constraints.contains(&"Keep coverage at 70%".to_string()),
1704            "fresh artifact constraints are extracted: {:?}",
1705            envelope.constraints
1706        );
1707    }
1708
1709    #[test]
1710    fn local_summary_takes_precedence_over_retained_provider_detail() {
1711        let compacted = vec![
1712            Message::system("Previous conversation summary:\nnew local summary".to_string()),
1713            Message::user("latest request".to_string()),
1714            Message::assistant(String::new()).with_reasoning_details(Some(vec![serde_json::json!({
1715                "type": "compaction",
1716                "content": "stale provider summary",
1717            })])),
1718        ];
1719
1720        assert_eq!(extract_compaction_summary(&compacted, &[]), "new local summary");
1721    }
1722
1723    #[test]
1724    fn split_verify_command_tail_splits_and_strips_brackets() {
1725        let tail = " [cargo nextest run -E 'test(turn_loop_helpers) or test(tool_outcomes) or test(blocked_handoff)'] and verify: [cargo check --locked";
1726        let commands = super::split_verify_command_tail(tail);
1727        assert_eq!(
1728            commands,
1729            vec![
1730                "cargo nextest run -E 'test(turn_loop_helpers) or test(tool_outcomes) or test(blocked_handoff)'"
1731                    .to_string(),
1732                "cargo check --locked".to_string(),
1733            ]
1734        );
1735    }
1736
1737    #[test]
1738    fn split_verify_command_tail_keeps_legitimate_inner_brackets() {
1739        // Only one outer pair is stripped; array/subscript syntax inside the
1740        // command must survive.
1741        let commands = super::split_verify_command_tail(" cargo test --features 'a[b]' ");
1742        assert_eq!(commands, vec!["cargo test --features 'a[b]'".to_string()]);
1743    }
1744
1745    #[test]
1746    fn structured_verify_commands_do_not_splice_bracketed_pairs() {
1747        let content =
1748            "# Fix\n\n- [x] step\n  files: src/x.rs\n  verify: [cargo check --locked] and verify: [cargo fmt --check\n";
1749        let commands = super::collect_structured_verify_commands(content);
1750        assert_eq!(commands, vec!["cargo check --locked".to_string(), "cargo fmt --check".to_string()]);
1751        let summary = super::extract_verification_summary(content, &[]).expect("summary");
1752        assert!(!summary.contains("] and verify:"), "splice marker must not leak: {summary}");
1753    }
1754}