@setoelkahfi / sigit / commits / 51d281a

Add durable sessions and context compaction

Conversation history lived in RAM, so a restart lost everything and the hard cap of 10 tool rounds was the only defense against blowing the context window. Both problems hurt most on-device, where context is smallest. The InferenceBackend trait gains history_snapshot, restore_history, and compact_history. A new session_store module writes one JSONL file per session under $SIGIT_CONFIG_DIR/sessions/, saved after every completed turn on both surfaces. ACP session/load restores from the store, the TUI gets /resume, and /clear deletes the file. Compaction rebuilds history as system prompt, a summary produced by the backend itself, and the last six messages. It runs automatically before a tool round once the estimate crosses 24k tokens, and on demand via /compact. A failed summarization rolls history back untouched, and the rebuilt tail drops tool results whose assistant message was summarized away, since strict endpoints reject orphans. With compaction in place MAX_TOOL_ROUNDS rises from 10 to 24.

paydii committed Jul 4, 2026 at 22:31 UTC 51d281add943a0c545c8a74af87c0cf0fbd9db17
4 files changed +656 -6
src/backend.rs
+333 -1
index a004c11..3b23f20 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use async_trait::async_trait; -use onde::inference::{ChatEngine, ToolDefinition}; +use onde::inference::{ChatEngine, ChatMessage, ChatRole, ToolDefinition}; use serde::Deserialize; use tokio::sync::Mutex; @@ -62,6 +62,30 @@ pub struct TurnResult { /// Backend errors are plain strings. Callers map them to ACP errors. pub type BackendError = String; +/// Rough context budget for a conversation, in estimated tokens (see +/// [`estimate_tokens`]). When a snapshot exceeds this, the agent loops compact +/// history before the next tool round. +pub const DEFAULT_CONTEXT_TOKEN_BUDGET: usize = 24_000; + +/// How many trailing messages survive a compaction verbatim (the rest are +/// folded into the summary). +pub const COMPACT_KEEP_LAST: usize = 6; + +/// The summarization request sent to the model when compacting history. +const SUMMARIZE_PROMPT: &str = "Summarize this coding session so far: decisions made, \ + files touched, current state, open items. Be concise and factual."; + +/// Crude token estimate for a history snapshot: serialized characters / 4. +/// Deliberately model-agnostic — it only needs to be in the right ballpark to +/// decide when compaction is worth an extra inference round. +pub fn estimate_tokens(history: &[serde_json::Value]) -> usize { + let chars: usize = history + .iter() + .map(|message| message.to_string().chars().count()) + .sum(); + chars / 4 +} + /// A sink for streaming assistant text deltas to the UI as they are produced. /// /// When a caller passes `Some(sink)`, a streaming-capable backend forwards each @@ -110,6 +134,23 @@ pub trait InferenceBackend: Send + Sync { /// than on-device. Drives UI labelling so the displayed model can't claim a /// local model while requests actually go to the cloud. fn is_remote(&self) -> bool; + + /// A serializable snapshot of the conversation history, one JSON object per + /// message (`{"role": ..., "content": ...}` at minimum). The snapshot is + /// what the session store persists; it includes any seeded system message + /// so [`InferenceBackend::restore_history`] can replace state wholesale. + async fn history_snapshot(&self) -> Vec<serde_json::Value>; + + /// Replace the conversation history with a previously saved snapshot. + /// Backends that cannot represent every entry (e.g. on-device history has + /// no tool-call structure) flatten what they can and drop the rest. + async fn restore_history(&self, history: Vec<serde_json::Value>); + + /// Shrink the conversation history: summarize everything so far with one + /// extra (non-streaming) inference round, then rebuild history as + /// `[system message, summary, last keep_last non-system messages]`. On + /// error the original history is left in place. + async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError>; } // ── Local backend (onde ChatEngine) ────────────────────────────────────────────── @@ -213,6 +254,82 @@ impl InferenceBackend for LocalBackend { fn is_remote(&self) -> bool { false } + + async fn history_snapshot(&self) -> Vec<serde_json::Value> { + // onde's `history()` already flattens tool entries: assistant tool + // calls become plain assistant text and tool results are omitted, so + // the snapshot is lossy for tool-heavy turns (acceptable in this MVP). + self.engine + .history() + .await + .iter() + .map(|message| { + serde_json::json!({ + "role": message.role.to_string(), + "content": message.content, + }) + }) + .collect() + } + + async fn restore_history(&self, history: Vec<serde_json::Value>) { + self.engine.clear_history().await; + for entry in history { + let role = entry["role"].as_str().unwrap_or(""); + let content = entry["content"].as_str().unwrap_or("").to_string(); + // Tool-call-only assistant entries and empty tool results carry no + // text a plain chat history can replay; drop them. + if content.is_empty() && role != "user" && role != "system" { + continue; + } + let message = match role { + "system" => ChatMessage::system(content), + "user" => ChatMessage::user(content), + "assistant" => ChatMessage::assistant(content), + // Tool results flatten to plain text (MVP; acceptable loss). + "tool" => ChatMessage::user(format!("[tool result]\n{content}")), + _ => continue, + }; + self.engine.push_history(message).await; + } + } + + async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> { + let snapshot = self.engine.history().await; + // One plain (tool-free) inference round produces the summary. On error + // history is untouched — send_message only mutates it on success, and + // whatever it appended is wiped by the clear below anyway. + let result = self + .engine + .send_message(SUMMARIZE_PROMPT) + .await + .map_err(|error| error.to_string())?; + // Local models may reason in <think> blocks; keep only the visible part. + let (_think, summary) = crate::chat::strip_think_blocks(&result.text); + + self.engine.clear_history().await; + // Leading system messages carry the session context; keep them all. + for message in snapshot + .iter() + .take_while(|message| message.role == ChatRole::System) + { + self.engine.push_history(message.clone()).await; + } + self.engine + .push_history(ChatMessage::user(format!( + "[Conversation summary]\n{summary}" + ))) + .await; + let non_system: Vec<&ChatMessage> = snapshot + .iter() + .filter(|message| message.role != ChatRole::System) + .collect(); + let tail_start = non_system.len().saturating_sub(keep_last); + for message in &non_system[tail_start..] { + self.engine.push_history((*message).clone()).await; + } + Ok(()) + } } /// Drain an onde streaming receiver, forwarding each token to `sink` and @@ -591,6 +708,68 @@ impl InferenceBackend for OpenAiBackend { fn is_remote(&self) -> bool { true } + + async fn history_snapshot(&self) -> Vec<serde_json::Value> { + self.history.lock().await.clone() + } + + async fn restore_history(&self, history: Vec<serde_json::Value>) { + // The snapshot includes the seeded system message, so a wholesale + // replacement restores exactly what was saved. + *self.history.lock().await = history; + } + + async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> { + let snapshot: Vec<serde_json::Value> = self.history.lock().await.clone(); + + // Ask the endpoint for a summary of the conversation so far, through + // the ordinary completion machinery (non-streaming). + self.history + .lock() + .await + .push(serde_json::json!({ "role": "user", "content": SUMMARIZE_PROMPT })); + let summary = match self.complete(None, None).await { + Ok(result) => result.text, + Err(error) => { + // Roll back the summarization request; the turn never happened. + *self.history.lock().await = snapshot; + return Err(error); + } + }; + + let system = snapshot + .first() + .filter(|message| message["role"] == "system") + .cloned(); + let non_system: Vec<serde_json::Value> = snapshot + .iter() + .filter(|message| message["role"] != "system") + .cloned() + .collect(); + let tail_start = non_system.len().saturating_sub(keep_last); + let mut tail = non_system[tail_start..].to_vec(); + // Drop leading tool results whose assistant tool-call message was + // summarized away — strict endpoints reject orphaned `role: "tool"` + // entries on the very next request. + while tail + .first() + .is_some_and(|message| message["role"] == "tool") + { + tail.remove(0); + } + + let mut rebuilt = Vec::new(); + if let Some(system) = system { + rebuilt.push(system); + } + rebuilt.push(serde_json::json!({ + "role": "user", + "content": format!("[Conversation summary]\n{summary}"), + })); + rebuilt.extend(tail); + *self.history.lock().await = rebuilt; + Ok(()) + } } // ── OpenAI response shapes ──────────────────────────────────────────────────────── @@ -783,6 +962,159 @@ mod tests { assert_eq!(last["content"], "cancelled by the user"); } + #[test] + fn estimate_tokens_scales_with_serialized_size() { + assert_eq!(estimate_tokens(&[]), 0); + + let short = vec![serde_json::json!({ "role": "user", "content": "hi" })]; + let long = vec![serde_json::json!({ "role": "user", "content": "x".repeat(4_000) })]; + let short_estimate = estimate_tokens(&short); + let long_estimate = estimate_tokens(&long); + + assert!(short_estimate > 0, "non-empty history estimates > 0 tokens"); + assert!(long_estimate > short_estimate, "longer history costs more"); + // 4,000 content chars / 4 ≈ 1,000 tokens, plus a little JSON framing. + assert!((1_000..1_100).contains(&long_estimate), "{long_estimate}"); + } + + #[tokio::test] + async fn openai_snapshot_restore_round_trips_exactly() { + let backend = OpenAiBackend::new("http://localhost", "", "m", Some("be helpful".into())); + { + let mut history = backend.history.lock().await; + history.push(serde_json::json!({ "role": "user", "content": "hello" })); + history.push(streamed_assistant_history( + "", + &[ToolCall { + id: "call_1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"a.rs"}"#.to_string(), + }], + )); + history.push(serde_json::json!({ + "role": "tool", "tool_call_id": "call_1", "content": "fn main() {}", + })); + history.push(serde_json::json!({ "role": "assistant", "content": "done" })); + } + let snapshot = backend.history_snapshot().await; + assert_eq!( + snapshot[0]["role"], "system", + "snapshot keeps the system message" + ); + + // Restoring into a backend seeded with a *different* system prompt must + // replace everything, including that seed. + let restored = OpenAiBackend::new("http://localhost", "", "m", Some("other seed".into())); + restored.restore_history(snapshot.clone()).await; + assert_eq!(restored.history_snapshot().await, snapshot); + } + + /// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on + /// a std listener and answers with a fixed non-streaming completion. + fn spawn_completion_stub(summary: &str) -> std::net::SocketAddr { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = serde_json::json!({ + "choices": [{ "message": { "role": "assistant", "content": summary } }] + }) + .to_string(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + // Read until the full request (headers + content-length body) is in. + let mut request = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + let n = stream.read(&mut chunk).unwrap_or(0); + if n == 0 { + break; + } + request.extend_from_slice(&chunk[..n]); + if let Some(headers_end) = + request.windows(4).position(|window| window == b"\r\n\r\n") + { + let headers = String::from_utf8_lossy(&request[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|value| value.trim().parse::<usize>().unwrap_or(0)) + }) + .unwrap_or(0); + if request.len() >= headers_end + 4 + content_length { + break; + } + } + } + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + content-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + }); + addr + } + + #[tokio::test] + async fn compact_history_rebuilds_system_summary_and_tail() { + let addr = spawn_completion_stub("We refactored backend.rs; tests pass."); + let backend = OpenAiBackend::new( + format!("http://{addr}/v1"), + "test-key", + "test-model", + Some("be helpful".into()), + ); + { + let mut history = backend.history.lock().await; + for i in 0..5 { + let role = if i % 2 == 0 { "user" } else { "assistant" }; + history.push(serde_json::json!({ + "role": role, "content": format!("message {i}"), + })); + } + } + + backend.compact_history(2).await.unwrap(); + + let history = backend.history_snapshot().await; + assert_eq!(history.len(), 4, "system + summary + last 2: {history:?}"); + assert_eq!(history[0]["role"], "system"); + assert_eq!(history[0]["content"], "be helpful"); + assert_eq!(history[1]["role"], "user"); + let summary_text = history[1]["content"].as_str().unwrap(); + assert!(summary_text.starts_with("[Conversation summary]\n")); + assert!(summary_text.contains("We refactored backend.rs; tests pass.")); + assert_eq!( + history[2], + serde_json::json!({ "role": "assistant", "content": "message 3" }) + ); + assert_eq!( + history[3], + serde_json::json!({ "role": "user", "content": "message 4" }) + ); + } + + #[tokio::test] + async fn compact_history_failure_leaves_history_intact() { + // No listener at this address: the summarization request fails, and + // history must roll back to exactly what it was. + let backend = + OpenAiBackend::new("http://127.0.0.1:9", "", "test-model", Some("sys".into())); + backend + .history + .lock() + .await + .push(serde_json::json!({ "role": "user", "content": "hello" })); + let before = backend.history_snapshot().await; + + assert!(backend.compact_history(2).await.is_err()); + assert_eq!(backend.history_snapshot().await, before); + } + #[test] fn assistant_message_with_tool_calls_round_trips() { let message = ResponseMessage {
src/chat.rs
+86 -2
index 77a5476..8865759 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -777,6 +777,10 @@ mod tui { Plan(Option<bool>), /// Show the effective permission policy for this session. Permissions, + /// Summarize-and-shrink the conversation history on demand. + Compact, + /// Restore the saved TUI session from disk. + Resume, Exit, Unknown(String), } @@ -803,6 +807,8 @@ mod tui { "/whoami" => SlashCommand::Whoami, "/plan" => SlashCommand::Plan(parse_on_off(arg)), "/permissions" => SlashCommand::Permissions, + "/compact" => SlashCommand::Compact, + "/resume" => SlashCommand::Resume, "/exit" | "/quit" | "/q" => SlashCommand::Exit, other => SlashCommand::Unknown(other.to_string()), }) @@ -1407,6 +1413,8 @@ mod tui { /whoami — show the signed-in account\n\ /plan [on|off] — plan mode: research only, no edits or commands\n\ /permissions — show the tool permission policy\n\ + /compact — summarize and shrink conversation history\n\ + /resume — restore the saved session from disk\n\ /clear — wipe conversation history\n\ /status — show engine status\n\ /exit — quit chat", @@ -1416,10 +1424,53 @@ mod tui { let cleared = engine.clear_history().await; app.messages.clear(); crate::permissions::reset_session(crate::permissions::TUI_SESSION); + // The saved session must not resurrect what the user just wiped. + crate::session_store::delete(TUI_STORE_SESSION); app.messages.push(ChatMessage::system(format!( "Cleared {cleared} turn(s). History is empty.", ))); } + SlashCommand::Compact => { + let before = crate::backend::estimate_tokens(&app.backend.history_snapshot().await); + match app + .backend + .compact_history(crate::backend::COMPACT_KEEP_LAST) + .await + { + Ok(()) => { + let snapshot = app.backend.history_snapshot().await; + let after = crate::backend::estimate_tokens(&snapshot); + // Keep the saved session in step with the compacted state. + if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) + { + log::warn!("session save after /compact failed: {error}"); + } + app.messages.push(ChatMessage::system(format!( + "Compacted history: ~{before} → ~{after} tokens (estimated)." + ))); + } + Err(error) => { + app.messages + .push(ChatMessage::system(format!("Compaction failed: {error}"))); + } + } + } + SlashCommand::Resume => match crate::session_store::load(TUI_STORE_SESSION) { + Some(history) if !history.is_empty() => { + let restored = history.len(); + app.backend.restore_history(history).await; + app.messages.push(ChatMessage::system(format!( + "Restored {restored} message(s) from the saved session. \ + The model remembers the conversation; the scrollback above does not \ + replay it." + ))); + } + _ => { + app.messages.push(ChatMessage::system( + "No saved session to resume. Sessions are saved after each turn.", + )); + } + }, SlashCommand::Plan(value) => { use crate::permissions::{self, TUI_SESSION}; let enabled = value.unwrap_or_else(|| !permissions::plan_mode(TUI_SESSION)); @@ -1575,8 +1626,13 @@ mod tui { // ── Background inference task ───────────────────────────────────────────── - /// cap tool rounds so a confused model can't loop forever - const MAX_TOOL_ROUNDS: usize = 10; + /// cap tool rounds so a confused model can't loop forever; auto-compaction + /// keeps long runs inside the context window, so the cap can be generous + const MAX_TOOL_ROUNDS: usize = 24; + + /// The TUI is a single conversation, so it persists under one fixed + /// session-store id (ACP sessions use their protocol-assigned ids). + const TUI_STORE_SESSION: &str = "tui"; fn build_tool_specs() -> Vec<ToolSpec> { let mut specs: Vec<ToolSpec> = crate::tools::all_tools() @@ -1686,6 +1742,27 @@ mod tui { round += 1; log::info!("tool round {} — {} call(s)", round, result.tool_calls.len()); + // Auto-compaction: long tool runs grow history fast; fold it into + // a summary before the next round rather than blowing the window. + let estimate = crate::backend::estimate_tokens(&backend.history_snapshot().await); + if estimate > crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET { + log::info!( + "history ≈{estimate} tokens exceeds budget {} — compacting", + crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET + ); + match backend + .compact_history(crate::backend::COMPACT_KEEP_LAST) + .await + { + Ok(()) => { + let after = + crate::backend::estimate_tokens(&backend.history_snapshot().await); + log::info!("compacted history to ≈{after} tokens"); + } + Err(error) => log::warn!("history compaction failed: {error}"), + } + } + let mut tool_results = Vec::new(); for (call_index, tc) in result.tool_calls.iter().enumerate() { @@ -1823,6 +1900,13 @@ mod tui { } } + // Persist the completed turn so /resume (or a restart) can pick the + // conversation back up. + let snapshot = backend.history_snapshot().await; + if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) { + log::warn!("session save failed: {error}"); + } + log::info!("inference complete — {} tool round(s)", round); // tx drops here — event loop gets None from rx.recv() }
src/main.rs
+70 -3
index bccf236..a4999aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ mod mcp; mod models; mod permissions; mod provider; +mod session_store; mod settings; mod setup; mod skills; @@ -229,8 +230,10 @@ pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str { } } -/// cap tool-call loops so a confused model can't spin forever -const MAX_TOOL_ROUNDS: usize = 10; +/// cap tool-call loops so a confused model can't spin forever; auto-compaction +/// (see [`backend::DEFAULT_CONTEXT_TOKEN_BUDGET`]) keeps long runs inside the +/// context window, so the cap can afford to be generous +const MAX_TOOL_ROUNDS: usize = 24; /// Outcome of asking the client for permission to run one tool call. enum PermissionVerdict { @@ -769,6 +772,7 @@ impl SiGitAgent { "on|off (optional)", ), AvailableCommand::new("permissions", "Show the tool permission policy"), + AvailableCommand::new("compact", "Summarize and shrink the conversation history"), AvailableCommand::new("clear", "Wipe the conversation history"), AvailableCommand::new("status", "Show engine status"), ]; @@ -961,7 +965,7 @@ impl SiGitAgent { log::warn!("could not set cwd to {}: {err}", args.cwd.display()); } - // no session persistence, so "load" just resets + // start from a clean slate; a stored session (below) replaces it self.engine.clear_history().await; self.engine @@ -973,6 +977,20 @@ impl SiGitAgent { // Honor the persisted Local Inference toggle (off + signed in → cloud). self.apply_startup_inference_mode().await; + // Durable sessions: when this session id was saved before, restore its + // history into the active backend. The snapshot includes the system + // messages that were live when it was saved, so restore replaces the + // freshly seeded state wholesale. + if let Some(history) = session_store::load(&args.session_id.to_string()) { + let restored = history.len(); + let backend = self.backend.lock().await.clone(); + backend.restore_history(history).await; + log::info!( + "load_session: restored {restored} message(s) for {}", + args.session_id + ); + } + let config_options = { let guard = self.current_model.lock().unwrap(); build_model_config_options(&guard) @@ -1300,6 +1318,25 @@ impl SiGitAgent { result.tool_calls.len() ); + // Auto-compaction: long tool runs grow history fast; fold it into + // a summary before the next round rather than blowing the window. + let estimate = backend::estimate_tokens(&backend.history_snapshot().await); + if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET { + log::info!( + "prompt({}) history ≈{} tokens exceeds budget {} — compacting", + session_id, + estimate, + backend::DEFAULT_CONTEXT_TOKEN_BUDGET + ); + match backend.compact_history(backend::COMPACT_KEEP_LAST).await { + Ok(()) => { + let after = backend::estimate_tokens(&backend.history_snapshot().await); + log::info!("prompt({}) compacted to ≈{} tokens", session_id, after); + } + Err(error) => log::warn!("prompt({}) compaction failed: {error}", session_id), + } + } + let mut tool_results = Vec::new(); for (call_index, tc) in result.tool_calls.iter().enumerate() { @@ -1417,6 +1454,13 @@ impl SiGitAgent { } } + // Persist the completed turn so a restart (or session/load) can pick + // the conversation back up. + let snapshot = backend.history_snapshot().await; + if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) { + log::warn!("prompt({}) session save failed: {error}", session_id); + } + log::info!("prompt({}) complete — {} tool round(s)", session_id, round); Ok(PromptResponse::new(StopReason::EndTurn)) } @@ -2155,6 +2199,8 @@ enum SlashCommand { Plan(Option<bool>), /// Show the effective permission policy for this session. Permissions, + /// Summarize-and-shrink the conversation history on demand. + Compact, Exit, Unknown(String), } @@ -2182,6 +2228,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> { "/reload" => SlashCommand::Reload, "/plan" => SlashCommand::Plan(parse_on_off(argument)), "/permissions" => SlashCommand::Permissions, + "/compact" => SlashCommand::Compact, "/exit" | "/quit" | "/q" => SlashCommand::Exit, other => SlashCommand::Unknown(other.to_string()), }) @@ -2292,6 +2339,7 @@ async fn exec_slash_acp( /reload - re-sync sign-in and model state\n\ /plan [on|off] - plan mode: research only, no edits or commands\n\ /permissions - show the tool permission policy\n\ + /compact - summarize and shrink conversation history\n\ /clear - wipe conversation history\n\ /status - show engine status\n\ /exit - end this turn", @@ -2301,6 +2349,8 @@ async fn exec_slash_acp( SlashCommand::Clear => { let cleared = agent.engine.clear_history().await; permissions::reset_session(&session_id.to_string()); + // The saved session must not resurrect what the user just wiped. + session_store::delete(&session_id.to_string()); agent .send_assistant_message( cx, @@ -2326,6 +2376,23 @@ async fn exec_slash_acp( let summary = permissions::describe(&session_id.to_string()); agent.send_assistant_message(cx, session_id, summary).ok(); } + SlashCommand::Compact => { + let backend = agent.backend.lock().await.clone(); + let before = backend::estimate_tokens(&backend.history_snapshot().await); + let message = match backend.compact_history(backend::COMPACT_KEEP_LAST).await { + Ok(()) => { + let snapshot = backend.history_snapshot().await; + let after = backend::estimate_tokens(&snapshot); + // Keep the saved session in step with the compacted state. + if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) { + log::warn!("session save after /compact failed: {error}"); + } + format!("Compacted history: ~{before} → ~{after} tokens (estimated).") + } + Err(error) => format!("Compaction failed: {error}"), + }; + agent.send_assistant_message(cx, session_id, message).ok(); + } SlashCommand::Status => { let info = agent.engine.info().await; let model = info.model_name.as_deref().unwrap_or("(none)");
src/session_store.rs
+167
new file mode 100644 index 0000000..103a9a4 --- /dev/null +++ b/src/session_store.rs @@ -0,0 +1,167 @@ +//! Durable session storage. +//! +//! One JSON-lines file per session at `$SIGIT_CONFIG_DIR/sessions/<id>.jsonl` +//! (config dir resolution matches [`crate::settings`] / [`crate::credentials`]: +//! `$SIGIT_CONFIG_DIR` or `~/.config/sigit`). Each line is one history message +//! as produced by `InferenceBackend::history_snapshot`, so a saved file can be +//! restored into either backend. +//! +//! Writes are atomic (temp file + rename) so a crash mid-save never leaves a +//! truncated session behind. Session ids are sanitized to a filename-safe +//! alphabet before touching the filesystem. + +use std::path::PathBuf; + +use serde_json::Value; + +/// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. +fn config_dir() -> Option<PathBuf> { + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") { + return Some(PathBuf::from(dir)); + } + let home = std::env::var("HOME").ok()?; + Some(PathBuf::from(home).join(".config/sigit")) +} + +fn sessions_dir() -> Option<PathBuf> { + config_dir().map(|dir| dir.join("sessions")) +} + +/// Reduce a session id to a filename-safe form: `[A-Za-z0-9._-]` pass through, +/// anything else becomes `_`. An empty id maps to `_` so the file name never +/// collapses to just the extension. +fn sanitize_id(session_id: &str) -> String { + if session_id.is_empty() { + return "_".to_string(); + } + session_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect() +} + +fn session_path(session_id: &str) -> Option<PathBuf> { + sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id)))) +} + +/// Persist a history snapshot for `session_id`, replacing any previous save. +/// The write is atomic: a temp file in the same directory is renamed over the +/// final path. +pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> { + let path = + session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?; + let dir = path + .parent() + .ok_or_else(|| "session path has no parent".to_string())?; + std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?; + + let mut body = String::new(); + for message in history { + body.push_str(&message.to_string()); + body.push('\n'); + } + + // Unique temp name so two processes saving the same session can't clobber + // each other's half-written file; rename is atomic on the same filesystem. + let tmp = dir.join(format!( + ".{}.{}.tmp", + sanitize_id(session_id), + std::process::id() + )); + std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?; + std::fs::rename(&tmp, &path).map_err(|error| { + let _ = std::fs::remove_file(&tmp); + format!("rename {tmp:?} -> {path:?}: {error}") + })?; + Ok(()) +} + +/// Load the saved history for `session_id`, or `None` when no save exists (or +/// it cannot be read). Unparseable lines are skipped rather than failing the +/// whole restore. +pub fn load(session_id: &str) -> Option<Vec<Value>> { + let path = session_path(session_id)?; + let contents = std::fs::read_to_string(&path).ok()?; + Some( + contents + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::<Value>(line).ok()) + .collect(), + ) +} + +/// Remove the saved history for `session_id`. Missing files are fine. +pub fn delete(session_id: &str) { + if let Some(path) = session_path(session_id) { + let _ = std::fs::remove_file(path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_keeps_safe_chars_and_replaces_the_rest() { + assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z"); + assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e"); + assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd"); + assert_eq!(sanitize_id(""), "_"); + } + + // One test for the filesystem behavior because it mutates the + // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings + // tests): splitting would race under the parallel test runner. + #[test] + fn save_load_delete_round_trip() { + let _guard = crate::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // SAFETY: serialized by ENV_TEST_LOCK; restored below. + unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; + + // Missing file → None. + assert_eq!(load("nope"), None); + + let history = vec![ + serde_json::json!({ "role": "system", "content": "sys" }), + serde_json::json!({ "role": "user", "content": "hi\nthere" }), + serde_json::json!({ + "role": "assistant", "content": null, + "tool_calls": [{ "id": "call_1", "type": "function", + "function": { "name": "read_file", "arguments": "{}" } }], + }), + ]; + save("sess-1", &history).unwrap(); + assert_eq!(load("sess-1"), Some(history.clone())); + + // Saving again replaces, not appends. + let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })]; + save("sess-1", &shorter).unwrap(); + assert_eq!(load("sess-1"), Some(shorter)); + + // A hostile id stays inside the sessions dir via sanitization. + save("../escape", &history).unwrap(); + assert!(dir.join("sessions").join(".._escape.jsonl").is_file()); + assert_eq!(load("../escape"), Some(history)); + delete("../escape"); + assert_eq!(load("../escape"), None); + + delete("sess-1"); + assert_eq!(load("sess-1"), None); + // Deleting a missing session is a no-op. + delete("sess-1"); + + unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; + let _ = std::fs::remove_dir_all(&dir); + } +}