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 let serialized = serde_json::to_vec_pretty(envelope).context("serialize memory envelope")?;
1073 vtcode_commons::VtCodePaths::write_private_file_atomic(path, &serialized)
1074 .with_context(|| format!("write memory envelope {}", path.display()))
1075}
1076
1077pub async fn write_memory_envelope_to_path_async(path: &Path, envelope: &SessionMemoryEnvelope) -> anyhow::Result<()> {
1081 let serialized = serde_json::to_vec_pretty(envelope).context("serialize memory envelope")?;
1082 vtcode_commons::fs::write_private_file_atomic(path, &serialized)
1083 .await
1084 .with_context(|| format!("write memory envelope {}", path.display()))
1085}
1086
1087pub fn has_latest_memory_envelope(workspace_root: &Path, session_id: &str) -> bool {
1088 latest_memory_envelope_path_for_session(workspace_root, session_id).is_some()
1089}
1090
1091pub fn configured_retained_user_messages(vt_cfg: Option<&VTCodeConfig>) -> usize {
1096 vt_cfg.map(|cfg| cfg.context.dynamic.retained_user_messages).unwrap_or(4)
1097}
1098
1099pub fn local_compaction_config(vt_cfg: Option<&VTCodeConfig>, always_summarize: bool) -> CompactionConfig {
1100 CompactionConfig {
1101 always_summarize,
1102 retained_user_messages: configured_retained_user_messages(vt_cfg),
1103 ..CompactionConfig::default()
1104 }
1105}
1106
1107fn collect_zero_cost_retained_user_messages(
1108 history: &[Message],
1109 token_budget: usize,
1110 max_messages: usize,
1111) -> Vec<Message> {
1112 if token_budget == 0 || max_messages == 0 {
1113 return Vec::new();
1114 }
1115
1116 let mut kept = Vec::new();
1117 let mut remaining = token_budget;
1118
1119 for message in history.iter().rev() {
1120 if kept.len() >= max_messages {
1121 break;
1122 }
1123 if message.role != MessageRole::User || message.content.trim().is_empty() {
1124 continue;
1125 }
1126
1127 let estimated = message.estimate_tokens();
1128 if estimated <= remaining {
1129 kept.push(message.clone());
1130 remaining = remaining.saturating_sub(estimated);
1131 continue;
1132 }
1133
1134 if remaining > 4 {
1135 let truncated = truncate_to_token_limit(message.content.as_text().as_ref(), remaining.saturating_sub(4));
1136 let trimmed = truncated.trim();
1137 if !trimmed.is_empty() {
1138 kept.push(Message::user(trimmed.to_string()));
1139 }
1140 }
1141 break;
1142 }
1143
1144 kept.reverse();
1145 kept
1146}
1147
1148pub fn build_zero_cost_summarized_fork_history(
1149 source_history: &[Message],
1150 source_envelope: Option<&SessionMemoryEnvelope>,
1151 retained_user_messages: usize,
1152) -> Vec<Message> {
1153 let summary = source_envelope
1154 .map(|envelope| normalize_whitespace(&envelope.summary))
1155 .filter(|summary| !summary.is_empty())
1156 .unwrap_or_else(|| derive_continuity_summary(source_history, source_envelope, &TaskTrackerSnapshot::default()));
1157
1158 let retained_users = collect_zero_cost_retained_user_messages(
1159 source_history,
1160 CompactionConfig::default().retained_user_message_tokens,
1161 retained_user_messages,
1162 );
1163
1164 let mut compacted = Vec::with_capacity(retained_users.len().saturating_add(1));
1165 compacted.push(Message::system(format!("Previous conversation summary:\n{}", summary.trim())));
1166 compacted.extend(retained_users);
1167 compacted
1168}
1169
1170#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1175struct FileReadDedupKey {
1176 target: String,
1177 start_line: Option<u64>,
1178 end_line: Option<u64>,
1179 spool_path: Option<String>,
1180}
1181
1182#[derive(Debug, Clone, PartialEq, Eq)]
1183struct FileReadDedupCandidate {
1184 key: FileReadDedupKey,
1185 placeholder_content: String,
1186}
1187
1188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1189enum FileReadToolKind {
1190 ReadFile,
1191 UnifiedFileRead,
1192}
1193
1194fn is_read_file_tool_name(tool_name: &str) -> bool {
1195 tool_name == tool_names::READ_FILE || tool_name.ends_with(".read_file")
1196}
1197
1198fn collect_file_read_tool_kinds(history: &[Message]) -> HashMap<String, FileReadToolKind> {
1199 let mut kinds = HashMap::new();
1200 for message in history {
1201 let Some(tool_calls) = message.tool_calls.as_ref() else {
1202 continue;
1203 };
1204 for tc in tool_calls {
1205 let Some(tn) = tc.tool_name() else {
1206 continue;
1207 };
1208 let kind = if is_read_file_tool_name(tn) {
1209 Some(FileReadToolKind::ReadFile)
1210 } else if tn == tool_names::UNIFIED_FILE {
1211 tc.execution_arguments().ok().and_then(|args| {
1212 args.get("action")
1213 .and_then(Value::as_str)
1214 .filter(|a| *a == "read")
1215 .map(|_| FileReadToolKind::UnifiedFileRead)
1216 })
1217 } else {
1218 None
1219 };
1220 if let Some(k) = kind {
1221 kinds.insert(tc.id.clone(), k);
1222 }
1223 }
1224 }
1225 kinds
1226}
1227
1228fn normalize_file_read_target(value: &str) -> Option<String> {
1229 let trimmed = value.trim();
1230 (!trimmed.is_empty()).then(|| trimmed.replace('\\', "/"))
1231}
1232
1233fn build_file_read_dedup_key(payload: &Value) -> Option<FileReadDedupKey> {
1234 let obj = payload.as_object()?;
1235 if obj.get("items").is_some()
1236 || obj.get("error").is_some()
1237 || obj.get("spool_chunked").and_then(Value::as_bool).unwrap_or(false)
1238 || obj.get("has_more").and_then(Value::as_bool).unwrap_or(false)
1239 {
1240 return None;
1241 }
1242 let target = obj
1243 .get("file_path")
1244 .and_then(Value::as_str)
1245 .or_else(|| obj.get("path").and_then(Value::as_str))
1246 .and_then(normalize_file_read_target)?;
1247 Some(FileReadDedupKey {
1248 target,
1249 start_line: obj.get("start_line").and_then(Value::as_u64),
1250 end_line: obj.get("end_line").and_then(Value::as_u64),
1251 spool_path: obj
1252 .get("spool_path")
1253 .and_then(Value::as_str)
1254 .and_then(normalize_file_read_target),
1255 })
1256}
1257
1258fn build_file_read_placeholder_content(payload: &Value, key: &FileReadDedupKey) -> String {
1259 let mut p = serde_json::Map::new();
1260 p.insert("deduped_read".into(), Value::Bool(true));
1261 p.insert("note".into(), Value::String(DEDUPED_FILE_READ_NOTE.to_string()));
1262
1263 fn maybe_str(p: &mut serde_json::Map<String, Value>, payload: &Value, key: &str) {
1264 if let Some(s) = payload
1265 .get(key)
1266 .and_then(Value::as_str)
1267 .map(str::trim)
1268 .filter(|s| !s.is_empty())
1269 {
1270 p.insert(key.into(), Value::String(s.to_string()));
1271 }
1272 }
1273
1274 maybe_str(&mut p, payload, "file_path");
1275 maybe_str(&mut p, payload, "path");
1276 if let Some(sl) = key.start_line {
1277 p.insert("start_line".into(), json!(sl));
1278 }
1279 if let Some(el) = key.end_line {
1280 p.insert("end_line".into(), json!(el));
1281 }
1282 if let Some(sp) = key.spool_path.as_deref() {
1283 p.insert("spool_path".into(), json!(sp));
1284 }
1285 Value::Object(p).to_string()
1286}
1287
1288fn file_read_dedup_candidate(
1289 message: &Message,
1290 tool_kinds: &HashMap<String, FileReadToolKind>,
1291) -> Option<FileReadDedupCandidate> {
1292 if message.role != MessageRole::Tool {
1293 return None;
1294 }
1295
1296 let kind = message
1297 .tool_call_id
1298 .as_deref()
1299 .and_then(|tool_call_id| tool_kinds.get(tool_call_id).copied())
1300 .or_else(|| {
1301 message
1302 .origin_tool
1303 .as_deref()
1304 .and_then(|tool_name| is_read_file_tool_name(tool_name).then_some(FileReadToolKind::ReadFile))
1305 })?;
1306
1307 if !matches!(kind, FileReadToolKind::ReadFile | FileReadToolKind::UnifiedFileRead) {
1308 return None;
1309 }
1310
1311 let payload: Value = serde_json::from_str(message.content.as_text().as_ref()).ok()?;
1312 let key = build_file_read_dedup_key(&payload)?;
1313
1314 Some(FileReadDedupCandidate {
1315 placeholder_content: build_file_read_placeholder_content(&payload, &key),
1316 key,
1317 })
1318}
1319
1320pub fn dedup_repeated_file_reads_for_local_compaction(history: &[Message]) -> Vec<Message> {
1321 let tool_kinds = collect_file_read_tool_kinds(history);
1322 let mut last_idx = HashMap::new();
1323 let mut candidates = Vec::new();
1324 for (i, msg) in history.iter().enumerate() {
1325 let Some(c) = file_read_dedup_candidate(msg, &tool_kinds) else {
1326 continue;
1327 };
1328 last_idx.insert(c.key.clone(), i);
1329 candidates.push((i, c));
1330 }
1331 let mut deduped = history.to_vec();
1332 let mut changed = false;
1333 for (idx, c) in candidates {
1334 if last_idx.get(&c.key).copied() == Some(idx) {
1335 continue;
1336 }
1337 if let Some(msg) = deduped.get_mut(idx) {
1338 msg.content = c.placeholder_content.into();
1339 changed = true;
1340 }
1341 }
1342 if changed { deduped } else { history.to_vec() }
1343}
1344
1345pub const DEFAULT_OUTPUT_RESERVE_TOKENS: usize = 4096;
1351
1352pub fn resolve_compaction_threshold(configured_threshold: Option<u64>, context_size: usize) -> Option<u64> {
1354 resolve_compaction_threshold_with_reserve(configured_threshold, context_size, DEFAULT_OUTPUT_RESERVE_TOKENS)
1355}
1356
1357pub fn resolve_compaction_threshold_with_reserve(
1358 configured_threshold: Option<u64>,
1359 context_size: usize,
1360 reserved_output_tokens: usize,
1361) -> Option<u64> {
1362 let configured = configured_threshold.filter(|value| *value > 0);
1363 if context_size == 0 {
1364 return configured;
1365 }
1366 let prompt_budget = context_size.saturating_sub(reserved_output_tokens).max(1) as u64;
1367 const TRIGGER_RATIO_PERCENT: u64 = 75;
1374 let default_trigger = prompt_budget.saturating_mul(TRIGGER_RATIO_PERCENT) / 100;
1375 let default_trigger = default_trigger.clamp(1, prompt_budget);
1376 Some(configured.map_or(default_trigger, |value| value.min(prompt_budget)))
1377}
1378
1379pub fn resolve_effective_compaction_threshold(
1381 configured_threshold: Option<u64>,
1382 provider_context_size: usize,
1383 session_context_budget: usize,
1384) -> Option<u64> {
1385 resolve_compaction_threshold(
1386 configured_threshold,
1387 effective_session_context_budget(provider_context_size, session_context_budget),
1388 )
1389}
1390
1391#[must_use]
1393pub fn effective_session_context_budget(provider_context_size: usize, session_context_budget: usize) -> usize {
1394 match (provider_context_size > 0, session_context_budget > 0) {
1395 (true, true) => provider_context_size.min(session_context_budget),
1396 (true, false) => provider_context_size,
1397 (false, true) => session_context_budget,
1398 (false, false) => 0,
1399 }
1400}
1401
1402#[must_use]
1404pub fn effective_context_budget(vt_cfg: Option<&VTCodeConfig>, provider: &dyn LLMProvider, model: &str) -> usize {
1405 use crate::llm::model_resolver::{DynamicModelMeta, ModelResolver};
1406
1407 let provider_capacity = provider.effective_context_size(model);
1408 let resolved_capacity = ModelResolver::resolve(
1414 Some(provider.name()),
1415 model,
1416 &[],
1417 Some(DynamicModelMeta {
1418 display_name: model.to_owned(),
1419 description: None,
1420 context_window: (provider_capacity > 0).then_some(provider_capacity),
1421 }),
1422 )
1423 .and_then(|resolved| resolved.context_window())
1424 .unwrap_or(provider_capacity);
1429 let capacity = effective_session_context_budget(resolved_capacity, provider_capacity);
1430 let session_budget = vt_cfg.map_or_else(default_max_context_tokens, |cfg| cfg.context.max_context_tokens);
1431 effective_session_context_budget(capacity, session_budget)
1432}
1433
1434pub fn effective_compaction_threshold_with_reserve(
1435 vt_cfg: Option<&VTCodeConfig>,
1436 provider: &dyn LLMProvider,
1437 model: &str,
1438 reserved_output_tokens: usize,
1439) -> Option<usize> {
1440 resolve_compaction_threshold_with_reserve(
1441 vt_cfg.and_then(|cfg| cfg.agent.harness.auto_compaction_threshold_tokens),
1442 effective_context_budget(vt_cfg, provider, model),
1443 reserved_output_tokens,
1444 )
1445 .and_then(|value| usize::try_from(value).ok())
1446}
1447
1448pub fn effective_compaction_threshold(
1449 vt_cfg: Option<&VTCodeConfig>,
1450 provider: &dyn LLMProvider,
1451 model: &str,
1452) -> Option<usize> {
1453 let reserve = provider
1454 .sampling_overrides(model)
1455 .max_tokens
1456 .map_or(DEFAULT_OUTPUT_RESERVE_TOKENS, |value| value as usize);
1457 effective_compaction_threshold_with_reserve(vt_cfg, provider, model, reserve)
1458}
1459
1460#[cfg(test)]
1461mod persistence_tests;
1462
1463#[cfg(test)]
1464mod tests {
1465 use super::extract_compaction_summary;
1466 use super::{
1467 SessionMemoryEnvelope, TaskTrackerSnapshot, build_session_memory_envelope, parse_task_tracker_snapshot,
1468 resolve_compaction_threshold_with_reserve,
1469 };
1470 use crate::llm::provider::Message;
1471 use std::path::Path;
1472
1473 #[test]
1474 fn default_compaction_trigger_uses_ratio_of_prompt_budget() {
1475 let threshold = resolve_compaction_threshold_with_reserve(None, 100_000, 4_096).expect("threshold");
1478 let prompt_budget = 100_000u64 - 4_096;
1479 let expected = prompt_budget * 75 / 100;
1480 assert_eq!(threshold, expected);
1481 assert!((vtcode_config::constants::context::DEFAULT_COMPACTION_TRIGGER_RATIO - 0.75).abs() < f64::EPSILON);
1483 assert!(threshold < prompt_budget, "default trigger must fire before the full prompt budget");
1484 assert_eq!(resolve_compaction_threshold_with_reserve(Some(10_000), 100_000, 4_096), Some(10_000));
1486 assert_eq!(resolve_compaction_threshold_with_reserve(Some(200_000), 100_000, 4_096), Some(prompt_budget));
1487 }
1488
1489 fn tracker_snapshot(summary: &str, objective: &str, todo: &[&str]) -> TaskTrackerSnapshot {
1490 let markdown = format!("# {objective}\n\n- [ ] {}\n", todo.join("\n- [ ] "));
1491 let mut snap = parse_task_tracker_snapshot(&markdown);
1492 snap.summary = Some(summary.to_string());
1493 snap.objective = Some(objective.to_string());
1494 snap.verification_todo = todo.iter().map(|s| (*s).to_string()).collect();
1495 snap
1496 }
1497
1498 #[test]
1499 fn envelope_prefers_live_objective_over_stale_prior() {
1500 let prior = SessionMemoryEnvelope {
1501 session_id: "sess-a".to_string(),
1502 objective: Some("1789-mighty-harbor".to_string()),
1503 task_summary: Some("mighty-harbor: README checklist".to_string()),
1504 verification_todo: vec!["mighty todo".to_string()],
1505 ..Default::default()
1506 };
1507 let live = tracker_snapshot("bright-squid analyze fix", "1789-bright-squid", &["cargo check analyze"]);
1508
1509 let envelope = build_session_memory_envelope(
1510 "sess-b",
1511 Path::new("."),
1512 &[],
1513 &[],
1514 "Working on bright-squid".to_string(),
1515 None,
1516 Some(&prior),
1517 &live,
1518 None,
1519 );
1520
1521 assert_eq!(envelope.objective.as_deref(), Some("1789-bright-squid"));
1522 assert!(
1523 envelope.task_summary.as_deref().unwrap_or_default().contains("bright-squid"),
1524 "task_summary must not keep prior-task narrative: {:?}",
1525 envelope.task_summary
1526 );
1527 assert_eq!(envelope.verification_todo, vec!["cargo check analyze".to_string()]);
1528 }
1529
1530 #[test]
1531 fn envelope_objective_change_drops_prior_task_summary_when_live_empty() {
1532 let prior = SessionMemoryEnvelope {
1533 session_id: "sess-a".to_string(),
1534 objective: Some("task-a".to_string()),
1535 task_summary: Some("task-a: stale checklist".to_string()),
1536 verification_todo: vec!["stale".to_string()],
1537 ..Default::default()
1538 };
1539 let live = TaskTrackerSnapshot {
1540 objective: Some("task-b".to_string()),
1541 summary: None,
1542 ..Default::default()
1543 };
1544
1545 let envelope = build_session_memory_envelope(
1546 "sess-b",
1547 Path::new("."),
1548 &[],
1549 &[],
1550 "summary".to_string(),
1551 None,
1552 Some(&prior),
1553 &live,
1554 None,
1555 );
1556
1557 assert_eq!(envelope.objective.as_deref(), Some("task-b"));
1558 assert_eq!(envelope.task_summary, None);
1559 assert!(envelope.verification_todo.is_empty());
1560 }
1561
1562 #[test]
1563 fn envelope_same_objective_keeps_todo_merge() {
1564 let prior = SessionMemoryEnvelope {
1565 session_id: "sess-a".to_string(),
1566 objective: Some("task-1".to_string()),
1567 task_summary: Some("task-1: shared".to_string()),
1568 verification_todo: vec!["keep me".to_string()],
1569 ..Default::default()
1570 };
1571 let live = tracker_snapshot("task-1 continued", "task-1", &["new item"]);
1572
1573 let envelope = build_session_memory_envelope(
1574 "sess-a",
1575 Path::new("."),
1576 &[],
1577 &[],
1578 "summary".to_string(),
1579 None,
1580 Some(&prior),
1581 &live,
1582 None,
1583 );
1584
1585 assert_eq!(envelope.objective.as_deref(), Some("task-1"));
1586 assert!(envelope.verification_todo.contains(&"keep me".to_string()));
1587 assert!(envelope.verification_todo.contains(&"new item".to_string()));
1588 }
1589
1590 #[test]
1591 fn envelope_stale_artifact_resets_prior_constraints() {
1592 let workspace = tempfile::tempdir().expect("tempdir");
1593 let tasks_dir = workspace.path().join(".vtcode").join("tasks");
1594 std::fs::create_dir_all(&tasks_dir).expect("tasks dir");
1595 std::fs::create_dir_all(workspace.path().join(".vtcode").join("sessions").join("sess-stale"))
1596 .expect("session dir anchors the artifact cutoff");
1597 let spec_path = tasks_dir.join("current_spec.md");
1598 std::fs::write(&spec_path, "# Spec\n- Do not touch prod config\n").expect("write stale spec");
1599 let stale = std::time::SystemTime::now() - std::time::Duration::from_secs(48 * 60 * 60);
1600 let file = std::fs::File::options().write(true).open(&spec_path).expect("open");
1601 file.set_modified(stale).expect("set mtime");
1602
1603 let prior = SessionMemoryEnvelope {
1604 session_id: "sess-stale".to_string(),
1605 constraints: vec!["Do not touch prod config".to_string(), "Keep user budget".to_string()],
1606 ..Default::default()
1607 };
1608
1609 let envelope = build_session_memory_envelope(
1610 "sess-stale",
1611 workspace.path(),
1612 &[],
1613 &[],
1614 "summary".to_string(),
1615 None,
1616 Some(&prior),
1617 &tracker_snapshot("continuing", "sess-stale", &["cargo check"]),
1618 None,
1619 );
1620
1621 assert!(envelope.spec_summary.is_none(), "stale spec must not describe the session");
1622 assert!(
1623 envelope.constraints.is_empty(),
1624 "a stale artifact resets the constraints channel so its lines cannot re-enter: {:?}",
1625 envelope.constraints
1626 );
1627 }
1628
1629 #[test]
1630 fn envelope_fresh_artifact_merges_prior_constraints() {
1631 let workspace = tempfile::tempdir().expect("tempdir");
1632 let tasks_dir = workspace.path().join(".vtcode").join("tasks");
1633 std::fs::create_dir_all(&tasks_dir).expect("tasks dir");
1634 std::fs::create_dir_all(workspace.path().join(".vtcode").join("sessions").join("sess-fresh"))
1635 .expect("session dir anchors the artifact cutoff");
1636 std::fs::write(tasks_dir.join("current_spec.md"), "# Spec\n- Keep coverage at 70%\n")
1637 .expect("write fresh spec");
1638
1639 let prior = SessionMemoryEnvelope {
1640 session_id: "sess-fresh".to_string(),
1641 constraints: vec!["Do not redesign the harness".to_string()],
1642 ..Default::default()
1643 };
1644
1645 let envelope = build_session_memory_envelope(
1646 "sess-fresh",
1647 workspace.path(),
1648 &[],
1649 &[],
1650 "summary".to_string(),
1651 None,
1652 Some(&prior),
1653 &tracker_snapshot("continuing", "sess-fresh", &["cargo check"]),
1654 None,
1655 );
1656
1657 assert!(
1658 envelope.constraints.contains(&"Do not redesign the harness".to_string()),
1659 "fresh artifacts keep the continuity merge of inherited constraints: {:?}",
1660 envelope.constraints
1661 );
1662 assert!(
1663 envelope.constraints.contains(&"Keep coverage at 70%".to_string()),
1664 "fresh artifact constraints are extracted: {:?}",
1665 envelope.constraints
1666 );
1667 }
1668
1669 #[test]
1670 fn local_summary_takes_precedence_over_retained_provider_detail() {
1671 let compacted = vec![
1672 Message::system("Previous conversation summary:\nnew local summary".to_string()),
1673 Message::user("latest request".to_string()),
1674 Message::assistant(String::new()).with_reasoning_details(Some(vec![serde_json::json!({
1675 "type": "compaction",
1676 "content": "stale provider summary",
1677 })])),
1678 ];
1679
1680 assert_eq!(extract_compaction_summary(&compacted, &[]), "new local summary");
1681 }
1682
1683 #[test]
1684 fn split_verify_command_tail_splits_and_strips_brackets() {
1685 let tail = " [cargo nextest run -E 'test(turn_loop_helpers) or test(tool_outcomes) or test(blocked_handoff)'] and verify: [cargo check --locked";
1686 let commands = super::split_verify_command_tail(tail);
1687 assert_eq!(
1688 commands,
1689 vec![
1690 "cargo nextest run -E 'test(turn_loop_helpers) or test(tool_outcomes) or test(blocked_handoff)'"
1691 .to_string(),
1692 "cargo check --locked".to_string(),
1693 ]
1694 );
1695 }
1696
1697 #[test]
1698 fn split_verify_command_tail_keeps_legitimate_inner_brackets() {
1699 let commands = super::split_verify_command_tail(" cargo test --features 'a[b]' ");
1702 assert_eq!(commands, vec!["cargo test --features 'a[b]'".to_string()]);
1703 }
1704
1705 #[test]
1706 fn structured_verify_commands_do_not_splice_bracketed_pairs() {
1707 let content =
1708 "# Fix\n\n- [x] step\n files: src/x.rs\n verify: [cargo check --locked] and verify: [cargo fmt --check\n";
1709 let commands = super::collect_structured_verify_commands(content);
1710 assert_eq!(commands, vec!["cargo check --locked".to_string(), "cargo fmt --check".to_string()]);
1711 let summary = super::extract_verification_summary(content, &[]).expect("summary");
1712 assert!(!summary.contains("] and verify:"), "splice marker must not leak: {summary}");
1713 }
1714}