+333
-1
index a004c11..3b23f20 100644
--- a/src/backend.rs
+++ b/src/backend.rs
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;
/// 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
/// 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) ──────────────────────────────────────────────
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
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 ────────────────────────────────────────────────────────
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 {
+86
-2
index 77a5476..8865759 100644
--- a/src/chat.rs
+++ b/src/chat.rs
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),
}
"/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()),
})
/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",
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));
// ── 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()
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() {
}
}
+ // 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()
}
+70
-3
index bccf236..a4999aa 100644
--- a/src/main.rs
+++ b/src/main.rs
mod models;
mod permissions;
mod provider;
+mod session_store;
mod settings;
mod setup;
mod skills;
}
}
-/// 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 {
"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"),
];
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
// 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)
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() {
}
}
+ // 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))
}
Plan(Option<bool>),
/// Show the effective permission policy for this session.
Permissions,
+ /// Summarize-and-shrink the conversation history on demand.
+ Compact,
Exit,
Unknown(String),
}
"/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()),
})
/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",
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,
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)");
+167
new file mode 100644
index 0000000..103a9a4
--- /dev/null
+++ b/src/session_store.rs
+//! 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);
+ }
+}