Files
g3/crates/g3-core/src/session_continuation.rs
Dhanji R. Prasanna e731bc8217 Make remember tool instructions more imperative in system prompts
- Change 'call remember' to 'you MUST call remember' in native prompt
- Change 'IF you discovered' to 'ALWAYS...when you discovered'
- Add explicit list of trigger tools (code_search, rg, grep, find, read_file)
- Add reminder to Response Guidelines section
- Add remember tool and Project Memory section to non-native prompt
- Remove redundant console output from remember tool
- Fix test compilation errors (missing summary parameter, temporary borrow)
2026-01-11 06:49:45 +08:00

542 lines
18 KiB
Rust

//! Session continuation support for long-running interactive sessions.
//!
//! This module provides functionality to save and restore session state,
//! allowing users to resume work across multiple g3 invocations.
//!
//! The session continuation uses a symlink-based approach:
//! - `.g3/session` is a symlink pointing to the current session directory
//! - `latest.json` is stored inside each session directory (`.g3/sessions/<session_id>/latest.json`)
//! - Following the symlink gives access to the current session's continuation data
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, error, warn};
/// Version of the session continuation format
const CONTINUATION_VERSION: &str = "1.0";
/// Name of the continuation file within each session directory
const CONTINUATION_FILENAME: &str = "latest.json";
/// Session continuation artifact containing all information needed to resume a session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionContinuation {
/// Version of the continuation format
pub version: String,
/// Whether this session was running in agent mode
pub is_agent_mode: bool,
/// Name of the agent (e.g., "fowler", "pike") if in agent mode
pub agent_name: Option<String>,
/// Timestamp when the continuation was saved
pub created_at: String,
/// Original session ID
pub session_id: String,
/// Human-readable description (first user message, truncated)
#[serde(default)]
pub description: Option<String>,
/// Session summary (last assistant response)
pub summary: Option<String>,
/// Path to the full session log (g3_session_*.json)
pub session_log_path: String,
/// Context window usage percentage when saved
pub context_percentage: f32,
/// Snapshot of the TODO list content
pub todo_snapshot: Option<String>,
/// Working directory where the session was running
pub working_directory: String,
}
impl SessionContinuation {
/// Create a new session continuation artifact
pub fn new(
is_agent_mode: bool,
agent_name: Option<String>,
session_id: String,
description: Option<String>,
summary: Option<String>,
session_log_path: String,
context_percentage: f32,
todo_snapshot: Option<String>,
working_directory: String,
) -> Self {
Self {
version: CONTINUATION_VERSION.to_string(),
is_agent_mode,
agent_name,
created_at: chrono::Utc::now().to_rfc3339(),
session_id,
description,
summary,
session_log_path,
context_percentage,
todo_snapshot,
working_directory,
}
}
/// Check if the context can be fully restored (< 80% used)
pub fn can_restore_full_context(&self) -> bool {
self.context_percentage < 80.0
}
/// Check if this session has incomplete TODO items
pub fn has_incomplete_todos(&self) -> bool {
match &self.todo_snapshot {
Some(todo) => todo.contains("- [ ]"),
None => false,
}
}
}
/// Get the path to the .g3 directory
fn get_g3_dir() -> PathBuf {
crate::get_g3_dir()
}
/// Get the path to the .g3/session symlink
pub fn get_session_dir() -> PathBuf {
get_g3_dir().join("session")
}
/// Get the path to the .g3/sessions directory (where all sessions are stored)
fn get_sessions_dir() -> PathBuf {
get_g3_dir().join("sessions")
}
/// Get the path to a specific session's directory
fn get_session_path(session_id: &str) -> PathBuf {
get_sessions_dir().join(session_id)
}
/// Get the path to the latest.json continuation file
/// This follows the symlink to get the actual path
pub fn get_latest_continuation_path() -> PathBuf {
get_session_dir().join(CONTINUATION_FILENAME)
}
/// Ensure the .g3 directory exists (but not the session symlink)
pub fn ensure_session_dir() -> Result<PathBuf> {
let g3_dir = get_g3_dir();
if !g3_dir.exists() {
std::fs::create_dir_all(&g3_dir)?;
debug!("Created .g3 directory: {:?}", g3_dir);
}
Ok(get_session_dir())
}
/// Update the .g3/session symlink to point to the given session directory
fn update_session_symlink(session_id: &str) -> Result<()> {
let symlink_path = get_session_dir();
let target_path = get_session_path(session_id);
// Remove existing symlink or directory if it exists
if symlink_path.exists() || symlink_path.is_symlink() {
if symlink_path.is_symlink() {
std::fs::remove_file(&symlink_path)
.context("Failed to remove existing session symlink")?;
} else if symlink_path.is_dir() {
// Migration: if it's an old-style directory, remove it
std::fs::remove_dir_all(&symlink_path)
.context("Failed to remove old session directory")?;
debug!("Migrated old .g3/session directory to symlink");
}
}
// Create the symlink
#[cfg(unix)]
std::os::unix::fs::symlink(&target_path, &symlink_path)
.context("Failed to create session symlink")?;
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&target_path, &symlink_path)
.context("Failed to create session symlink")?;
debug!("Updated session symlink: {:?} -> {:?}", symlink_path, target_path);
Ok(())
}
/// Save a session continuation artifact
/// This saves latest.json in the session's directory and updates the symlink
pub fn save_continuation(continuation: &SessionContinuation) -> Result<PathBuf> {
let session_id = &continuation.session_id;
let session_path = get_session_path(session_id);
// Ensure the session directory exists
if !session_path.exists() {
std::fs::create_dir_all(&session_path)
.context("Failed to create session directory")?;
}
// Save latest.json in the session directory
let latest_path = session_path.join(CONTINUATION_FILENAME);
let json = serde_json::to_string_pretty(continuation)?;
std::fs::write(&latest_path, &json)?;
// Update the symlink to point to this session
update_session_symlink(session_id)?;
debug!("Saved session continuation to {:?}", latest_path);
Ok(latest_path)
}
/// Load the latest session continuation artifact if it exists
pub fn load_continuation() -> Result<Option<SessionContinuation>> {
let symlink_path = get_session_dir();
// Check if the symlink exists and is valid
if !symlink_path.is_symlink() && !symlink_path.exists() {
debug!("No session symlink found at {:?}", symlink_path);
return Ok(None);
}
// If it's a symlink, check if the target exists
if symlink_path.is_symlink() {
let target = std::fs::read_link(&symlink_path)?;
if !target.exists() && !symlink_path.exists() {
debug!("Session symlink target does not exist: {:?}", target);
return Ok(None);
}
}
let latest_path = symlink_path.join(CONTINUATION_FILENAME);
if !latest_path.exists() {
debug!("No continuation file found at {:?}", latest_path);
return Ok(None);
}
let json = std::fs::read_to_string(&latest_path)?;
let continuation: SessionContinuation = serde_json::from_str(&json)?;
// Validate version
if continuation.version != CONTINUATION_VERSION {
warn!(
"Continuation version mismatch: expected {}, got {}",
CONTINUATION_VERSION, continuation.version
);
}
debug!("Loaded session continuation from {:?}", latest_path);
Ok(Some(continuation))
}
/// Clear the session continuation symlink (for /clear command)
/// This only removes the symlink, not the actual session data
pub fn clear_continuation() -> Result<()> {
let symlink_path = get_session_dir();
if symlink_path.is_symlink() {
std::fs::remove_file(&symlink_path)?;
debug!("Removed session symlink: {:?}", symlink_path);
} else if symlink_path.is_dir() {
// Handle old-style directory (migration case)
for entry in std::fs::read_dir(&symlink_path)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
std::fs::remove_file(&path)?;
debug!("Removed session file: {:?}", path);
}
}
std::fs::remove_dir(&symlink_path)?;
debug!("Removed old session directory: {:?}", symlink_path);
}
debug!("Cleared session continuation");
Ok(())
}
/// Check if a continuation exists and is valid
pub fn has_valid_continuation() -> bool {
match load_continuation() {
Ok(Some(continuation)) => {
// Check if the session log still exists
let session_log_path = PathBuf::from(&continuation.session_log_path);
if !session_log_path.exists() {
warn!("Session log no longer exists: {:?}", session_log_path);
return false;
}
// Check if we're in the same working directory
let current_dir = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if current_dir != continuation.working_directory {
debug!(
"Working directory changed: {} -> {}",
continuation.working_directory, current_dir
);
// Still valid, but user should be aware
}
true
}
Ok(None) => false,
Err(e) => {
error!("Error checking continuation: {}", e);
false
}
}
}
/// Load the full context window from a session log file
pub fn load_context_from_session_log(session_log_path: &Path) -> Result<Option<serde_json::Value>> {
if !session_log_path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(session_log_path)?;
let session_data: serde_json::Value = serde_json::from_str(&json)?;
Ok(Some(session_data))
}
/// Find an incomplete agent session for the given agent name.
/// Returns the most recent session that:
/// 1. Was running in agent mode with the matching agent name
/// 2. Has incomplete TODO items (contains "- [ ]")
/// 3. Is in the same working directory
pub fn find_incomplete_agent_session(agent_name: &str) -> Result<Option<SessionContinuation>> {
let sessions_dir = get_sessions_dir();
if !sessions_dir.exists() {
debug!("Sessions directory does not exist: {:?}", sessions_dir);
return Ok(None);
}
let current_dir = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let mut candidates: Vec<SessionContinuation> = Vec::new();
// Scan all session directories
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
// Check for latest.json in this session directory
let latest_path = path.join(CONTINUATION_FILENAME);
if !latest_path.exists() {
continue;
}
// Try to load the continuation
let json = match std::fs::read_to_string(&latest_path) {
Ok(j) => j,
Err(_) => continue,
};
let continuation: SessionContinuation = match serde_json::from_str(&json) {
Ok(c) => c,
Err(_) => continue, // Skip sessions with old format
};
// Check if this is an agent mode session with matching name
if !continuation.is_agent_mode {
continue;
}
if continuation.agent_name.as_deref() != Some(agent_name) {
continue;
}
// Check if in same working directory
if continuation.working_directory != current_dir {
continue;
}
// Check if has incomplete TODOs (either in snapshot or in the actual file)
let has_incomplete = if continuation.has_incomplete_todos() {
true
} else if continuation.todo_snapshot.is_none() {
// Fallback: check the actual todo.g3.md file in the session directory
// This handles sessions created before todo_snapshot was properly saved
let todo_file_path = path.join("todo.g3.md");
if todo_file_path.exists() {
std::fs::read_to_string(&todo_file_path)
.map(|content| content.contains("- [ ]"))
.unwrap_or(false)
} else {
false
}
} else {
false
};
if has_incomplete {
candidates.push(continuation);
}
}
// Sort by created_at descending and return the most recent
candidates.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(candidates.into_iter().next())
}
/// List all available sessions in the current working directory.
/// Returns sessions sorted by creation time (most recent first).
pub fn list_sessions_for_directory() -> Result<Vec<SessionContinuation>> {
let sessions_dir = get_sessions_dir();
if !sessions_dir.exists() {
debug!("Sessions directory does not exist: {:?}", sessions_dir);
return Ok(Vec::new());
}
let current_dir = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let mut sessions: Vec<SessionContinuation> = Vec::new();
// Scan all session directories
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
// Check for latest.json in this session directory
let latest_path = path.join(CONTINUATION_FILENAME);
if !latest_path.exists() {
continue;
}
// Try to load the continuation
let json = match std::fs::read_to_string(&latest_path) {
Ok(j) => j,
Err(_) => continue,
};
let continuation: SessionContinuation = match serde_json::from_str(&json) {
Ok(c) => c,
Err(_) => continue, // Skip sessions with old format
};
// Only include sessions from the current working directory
if continuation.working_directory == current_dir {
sessions.push(continuation);
}
}
// Sort by created_at descending (most recent first)
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(sessions)
}
/// Format a session's created_at timestamp for display
pub fn format_session_time(created_at: &str) -> String {
match chrono::DateTime::parse_from_rfc3339(created_at) {
Ok(dt) => {
let local: chrono::DateTime<chrono::Local> = dt.into();
let now = chrono::Local::now();
let duration = now.signed_duration_since(local);
// Show relative time for recent sessions, absolute for older ones
if duration.num_minutes() < 1 {
"just now".to_string()
} else if duration.num_minutes() < 60 {
format!("{} min ago", duration.num_minutes())
} else if duration.num_hours() < 24 {
let hours = duration.num_hours();
if hours == 1 {
"1 hour ago".to_string()
} else {
format!("{} hours ago", hours)
}
} else if duration.num_days() < 7 {
let days = duration.num_days();
if days == 1 {
"yesterday".to_string()
} else {
format!("{} days ago", days)
}
} else {
// For older sessions, show the date
local.format("%b %d, %Y").to_string()
}
}
Err(_) => created_at.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_continuation_creation() {
let continuation = SessionContinuation::new(
false,
None,
"test_session_123".to_string(),
Some("Task completed successfully".to_string()),
None,
"/path/to/session.json".to_string(),
45.0,
Some("- [x] Task 1\n- [ ] Task 2".to_string()),
"/home/user/project".to_string(),
);
assert_eq!(continuation.version, CONTINUATION_VERSION);
assert_eq!(continuation.session_id, "test_session_123");
assert!(continuation.can_restore_full_context());
}
#[test]
fn test_can_restore_full_context() {
let mut continuation = SessionContinuation::new(
false,
None,
"test".to_string(),
None,
None,
"path".to_string(),
50.0,
None,
".".to_string(),
);
assert!(continuation.can_restore_full_context()); // 50% < 80%
continuation.context_percentage = 80.0;
assert!(!continuation.can_restore_full_context()); // 80% >= 80%
continuation.context_percentage = 95.0;
assert!(!continuation.can_restore_full_context()); // 95% >= 80%
}
#[test]
fn test_has_incomplete_todos() {
let mut continuation = SessionContinuation::new(
true,
Some("fowler".to_string()),
"test".to_string(),
None,
None,
"path".to_string(),
50.0,
Some("- [x] Done\n- [ ] Not done".to_string()),
".".to_string(),
);
assert!(continuation.has_incomplete_todos());
continuation.todo_snapshot = Some("- [x] All done".to_string());
assert!(!continuation.has_incomplete_todos());
continuation.todo_snapshot = None;
assert!(!continuation.has_incomplete_todos());
}
}