1use 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 #[serde(default)]
81 pub pending_intents: Vec<QueuedFollowUpIntent>,
82 #[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 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 pub pending_intents: Option<Vec<QueuedFollowUpIntent>>,
125 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 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 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 let artifact_channel_reset = (spec_present
307 && artifact_is_stale(¤t_spec_path(workspace_root), artifact_cutoff))
308 || (evaluation_present && artifact_is_stale(¤t_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 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 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 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
403pub 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#[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
491pub 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#[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
721pub 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
738pub 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
790pub 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
814fn 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 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
1004pub 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
1083pub 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
1134pub 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#[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
1388pub const DEFAULT_OUTPUT_RESERVE_TOKENS: usize = 4096;
1394
1395pub 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 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
1422pub 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#[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#[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 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 .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 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 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 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 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}