Skip to main content

vtcode_memory/
lib.rs

1#![allow(
2    dead_code,
3    unused_imports,
4    reason = "Intentional compatibility, platform, or test-only suppression."
5)]
6#![expect(
7    unused_results,
8    clippy::let_underscore_must_use,
9    clippy::cast_possible_truncation,
10    clippy::cast_possible_wrap,
11    clippy::string_slice,
12    reason = "The memory store uses compact persisted counters, bounded timestamp conversions, and side-effect-only index maintenance."
13)]
14//! Unified per-session state store for VT Code.
15//!
16//! This crate is the single source of truth for an agent session's state,
17//! context, and history. Each session is persisted under
18//! `.vtcode/sessions/<session_id>/` as:
19//!
20//! - `events.jsonl` — the canonical append-only [`ThreadEvent`](vtcode_exec_events::ThreadEvent)
21//!   log (schema-versioned). Everything else is derived from this.
22//! - `manifest.json` — session metadata and counters.
23//! - `index/turns.json` — byte-offset index enabling O(1) turn reconstruction.
24//! - `derived/` — regenerated views (`trajectory.jsonl`, `memory.json`, …).
25//!
26//! The store is intentionally append-only and off the agent's hot path: the
27//! live conversation stays in memory and is never reloaded from disk into
28//! context. Reads happen only for revert, compaction, analytics, and
29//! long-term-learning queries.
30
31pub mod error;
32pub mod event_log;
33pub mod explanation;
34/// Manifest and turn-index persistence helpers.
35pub mod manifest;
36pub mod matrix;
37pub mod migration;
38/// Digest-verified audit packs for sessions.
39pub mod pack;
40pub mod progress;
41pub mod query;
42pub mod retention;
43
44pub use error::SessionStoreError;
45pub use event_log::{
46    DEFAULT_MAX_EVENTS, EvictionSummaryHook, SessionEventLog, SessionManifest, TurnIndex, TurnIndexEntry,
47};
48pub use migration::{MigrationReport, migrate_legacy};
49pub use pack::{
50    AUDIT_PACK_SCHEMA_VERSION, AuditPackEntry, AuditVerification, SessionAuditPack, audit_pack_path, create_audit_pack,
51    read_audit_pack, verify_audit_pack, write_audit_pack,
52};
53pub use progress::{
54    GoalClassifierVerdict, GoalEvent, GoalHistoryEntry, GoalOrchestration, GoalPauseReason, GoalPhase, GoalStatus,
55    GoalTracker, Milestone, MilestoneStatus, ProgressLedger, load_progress, progress_path, save_progress,
56};
57pub use query::{
58    FactRecord, MemorySearchResult, SessionMemoryView, SessionSummary, query_facts, recent_sessions, search_memory,
59    session_memory_facts, write_session_memory_view,
60};
61pub use retention::{
62    RETENTION_PIN_FILE, RetentionPolicy, apply_retention, apply_retention_preserving, evict_zero_turn_completed_store,
63    gc_legacy, mark_abandoned_active_sessions, pin_session_retention, retention_pinned_session_ids,
64    session_retention_pinned, unpin_session_retention,
65};
66
67use std::path::{Path, PathBuf};
68
69/// Directory (relative to the workspace) holding all per-session stores.
70const SESSIONS_DIR: &str = ".vtcode/sessions";
71
72/// Sub-directory inside a session holding regenerated views.
73const DERIVED_DIR: &str = "derived";
74
75/// Schema version for the on-disk session store layout.
76const SESSION_STORE_SCHEMA_VERSION: u32 = 1;
77
78/// Resolve the sessions root directory for a workspace.
79#[must_use]
80pub(crate) fn sessions_root(workspace: &Path) -> PathBuf {
81    workspace.join(SESSIONS_DIR)
82}
83
84/// Resolve the directory for a single session.
85#[must_use]
86pub(crate) fn session_dir(workspace: &Path, session_id: &str) -> PathBuf {
87    sessions_root(workspace).join(sanitize_id(session_id))
88}
89
90/// Return the canonical directory for a session.
91///
92/// Derived exporters and diagnostics must live beneath this directory so the
93/// session store remains the single persistence root for interactive and exec
94/// sessions.
95#[must_use]
96pub fn session_directory(workspace: &Path, session_id: &str) -> PathBuf {
97    session_dir(workspace, session_id)
98}
99
100/// Open (creating if necessary) the event log for a session.
101///
102/// This is the canonical entry point for recording a session's events. Multiple
103/// handles opened for the same session share an `Arc`-backed file and state,
104/// allowing concurrent `append` calls from the runloop's event sink to use one
105/// coordinated turn index.
106pub fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<SessionEventLog, SessionStoreError> {
107    SessionEventLog::open(workspace, session_id, max_events)
108}
109
110/// Open a session log with a callback that persists summaries before cap
111/// eviction. A callback failure leaves the canonical event log unchanged.
112pub fn open_with_eviction_summary(
113    workspace: &Path,
114    session_id: &str,
115    max_events: usize,
116    eviction_summary_hook: EvictionSummaryHook,
117) -> Result<SessionEventLog, SessionStoreError> {
118    SessionEventLog::open_with_eviction_summary(workspace, session_id, max_events, eviction_summary_hook)
119}
120
121/// Ensure a session-store directory exists with private permissions.
122pub(crate) fn ensure_private_directory(path: &Path) -> Result<(), SessionStoreError> {
123    vtcode_commons::VtCodePaths::ensure_user_dir(path).map_err(|error| SessionStoreError::CreateDir {
124        path: path.to_path_buf(),
125        source: std::io::Error::other(error),
126    })?;
127
128    #[cfg(unix)]
129    {
130        use std::os::unix::fs::PermissionsExt;
131
132        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
133            .map_err(|error| SessionStoreError::io(path.to_path_buf(), error))?;
134    }
135
136    Ok(())
137}
138
139/// Sanitize a session id so it is safe to use as a directory name.
140fn sanitize_id(id: &str) -> String {
141    let mut out = String::with_capacity(id.len());
142    for c in id.chars() {
143        if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
144            out.push(c);
145        } else {
146            out.push('_');
147        }
148    }
149    // Strip leading dots to avoid creating hidden directories.
150    let out = out.trim_start_matches('.').to_string();
151    if out.is_empty() { "session".to_string() } else { out }
152}
153
154#[cfg(test)]
155mod tests;