claude/sigit-acp-local-chat-cx6380
claude/code-feature-parity-q003hm
claude/elegant-carson-l1menh
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/agent-tools-multiedit-glob-todos-remember
feature/background-commands
feature/commit-coauthor-attribution
feature/headless-mode
feature/init-command
feature/load-local-model-explicitly
feature/session-persistence-compaction
feature/sigit-code-cloud
feature/subagent-tool
feature/tool-permission-system
feature/tui-repo-tabs
feature/tui-tabs
main
release/v1.3.1
| 1 | //! Local persistence of ACP chat conversations. |
| 2 | //! |
| 3 | //! Editors such as Zed remember a thread's `SessionId` across restarts and call |
| 4 | //! `session/load` to reopen it. Before this module siGit cleared history on |
| 5 | //! load, so every reopened thread started blank. Here we store a compact |
| 6 | //! transcript — the user prompts and the assistant's visible replies — per |
| 7 | //! session under `$SIGIT_CONFIG_DIR/sessions/<id>.json`. On reload the |
| 8 | //! transcript is replayed to the editor and pushed back into the active backend |
| 9 | //! so the model keeps its context. |
| 10 | //! |
| 11 | //! Only finished user/assistant turns are stored: no tool-call plumbing and no |
| 12 | //! system context (that is rebuilt fresh from the cwd on every load). This keeps |
| 13 | //! the format backend-agnostic — the same file restores whether the session |
| 14 | //! resumes on-device or on a siGit Code Cloud tier. |
| 15 | |
| 16 | use std::path::{Path, PathBuf}; |
| 17 | |
| 18 | use serde::{Deserialize, Serialize}; |
| 19 | |
| 20 | /// Author of a stored message. |
| 21 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 22 | #[serde(rename_all = "lowercase")] |
| 23 | pub enum Role { |
| 24 | User, |
| 25 | Assistant, |
| 26 | } |
| 27 | |
| 28 | /// One persisted turn in a conversation. |
| 29 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 30 | pub struct StoredMessage { |
| 31 | pub role: Role, |
| 32 | pub text: String, |
| 33 | } |
| 34 | |
| 35 | /// A persisted conversation, keyed on disk by its ACP `SessionId`. |
| 36 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
| 37 | pub struct StoredSession { |
| 38 | /// The session's working directory, kept for reference/debugging. |
| 39 | #[serde(default)] |
| 40 | pub cwd: Option<String>, |
| 41 | #[serde(default)] |
| 42 | pub messages: Vec<StoredMessage>, |
| 43 | } |
| 44 | |
| 45 | impl StoredSession { |
| 46 | pub fn is_empty(&self) -> bool { |
| 47 | self.messages.is_empty() |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. Mirrors |
| 52 | /// [`crate::settings`] and [`crate::credentials`]. |
| 53 | fn config_dir() -> Option<PathBuf> { |
| 54 | if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") { |
| 55 | return Some(PathBuf::from(dir)); |
| 56 | } |
| 57 | let home = std::env::var("HOME").ok()?; |
| 58 | Some(PathBuf::from(home).join(".config/sigit")) |
| 59 | } |
| 60 | |
| 61 | fn sessions_dir() -> Option<PathBuf> { |
| 62 | config_dir().map(|dir| dir.join("sessions")) |
| 63 | } |
| 64 | |
| 65 | /// Reduce a `SessionId` to a single, traversal-safe file-name stem. Editors |
| 66 | /// pick the id (usually a UUID); keep `[A-Za-z0-9._-]` and map anything else to |
| 67 | /// `_` so it can never escape the sessions directory. |
| 68 | fn sanitize_id(id: &str) -> String { |
| 69 | id.chars() |
| 70 | .map(|c| { |
| 71 | if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { |
| 72 | c |
| 73 | } else { |
| 74 | '_' |
| 75 | } |
| 76 | }) |
| 77 | .collect() |
| 78 | } |
| 79 | |
| 80 | /// Path to the transcript file for `id`, or `None` if the id can't form a valid |
| 81 | /// file name (empty, or only dots/separators after sanitizing). |
| 82 | fn session_path(id: &str) -> Option<PathBuf> { |
| 83 | let stem = sanitize_id(id); |
| 84 | if stem.trim_matches(['.', '_', '-']).is_empty() { |
| 85 | return None; |
| 86 | } |
| 87 | sessions_dir().map(|dir| dir.join(format!("{stem}.json"))) |
| 88 | } |
| 89 | |
| 90 | /// Load the stored transcript for `id`, or an empty session if none exists or |
| 91 | /// the file can't be read or parsed. |
| 92 | pub fn load(id: &str) -> StoredSession { |
| 93 | let Some(path) = session_path(id) else { |
| 94 | return StoredSession::default(); |
| 95 | }; |
| 96 | match std::fs::read_to_string(&path) { |
| 97 | Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|error| { |
| 98 | log::warn!("sessions: ignoring unreadable {}: {error}", path.display()); |
| 99 | StoredSession::default() |
| 100 | }), |
| 101 | Err(_) => StoredSession::default(), |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /// Persist `session` for `id`, creating the sessions directory if needed. |
| 106 | pub fn save(id: &str, session: &StoredSession) -> Result<(), String> { |
| 107 | let path = session_path(id).ok_or_else(|| format!("invalid session id: {id:?}"))?; |
| 108 | if let Some(parent) = path.parent() { |
| 109 | std::fs::create_dir_all(parent).map_err(|error| format!("create {parent:?}: {error}"))?; |
| 110 | } |
| 111 | let body = |
| 112 | serde_json::to_string_pretty(session).map_err(|error| format!("serialize: {error}"))?; |
| 113 | std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}")) |
| 114 | } |
| 115 | |
| 116 | /// Append a completed turn to `id`'s transcript. The user text is always |
| 117 | /// recorded; the assistant reply only when non-empty. Best-effort: a write |
| 118 | /// failure is logged, never surfaced, so persistence can't break a live turn. |
| 119 | pub fn append_turn(id: &str, cwd: Option<&Path>, user_text: &str, assistant_text: &str) { |
| 120 | let mut session = load(id); |
| 121 | if session.cwd.is_none() { |
| 122 | session.cwd = cwd.map(|path| path.display().to_string()); |
| 123 | } |
| 124 | session.messages.push(StoredMessage { |
| 125 | role: Role::User, |
| 126 | text: user_text.to_string(), |
| 127 | }); |
| 128 | let assistant = assistant_text.trim(); |
| 129 | if !assistant.is_empty() { |
| 130 | session.messages.push(StoredMessage { |
| 131 | role: Role::Assistant, |
| 132 | text: assistant.to_string(), |
| 133 | }); |
| 134 | } |
| 135 | if let Err(error) = save(id, &session) { |
| 136 | log::warn!("sessions: could not persist turn for {id}: {error}"); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// Forget `id`'s transcript (used by `/clear`). A missing file is not an error. |
| 141 | pub fn clear(id: &str) { |
| 142 | if let Some(path) = session_path(id) |
| 143 | && let Err(error) = std::fs::remove_file(&path) |
| 144 | && error.kind() != std::io::ErrorKind::NotFound |
| 145 | { |
| 146 | log::warn!("sessions: could not clear {}: {error}", path.display()); |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | /// Copy `from`'s transcript onto `to` when a session is forked, so the fork |
| 151 | /// opens with the parent's history instead of blank. Best-effort. |
| 152 | pub fn fork(from: &str, to: &str) { |
| 153 | let session = load(from); |
| 154 | if session.is_empty() { |
| 155 | return; |
| 156 | } |
| 157 | if let Err(error) = save(to, &session) { |
| 158 | log::warn!("sessions: could not fork {from} -> {to}: {error}"); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | #[cfg(test)] |
| 163 | mod tests { |
| 164 | use super::*; |
| 165 | |
| 166 | #[test] |
| 167 | fn append_load_clear_round_trip() { |
| 168 | let _guard = crate::ENV_TEST_LOCK |
| 169 | .lock() |
| 170 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 171 | let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id())); |
| 172 | let _ = std::fs::remove_dir_all(&dir); |
| 173 | // SAFETY: single-threaded test guarded by ENV_TEST_LOCK; restored below. |
| 174 | unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; |
| 175 | |
| 176 | let id = "11111111-2222-3333-4444-555555555555"; |
| 177 | assert!(load(id).is_empty(), "unknown session starts empty"); |
| 178 | |
| 179 | append_turn(id, Some(Path::new("/tmp/project")), "hello", "hi there"); |
| 180 | append_turn(id, None, "second", ""); |
| 181 | |
| 182 | let session = load(id); |
| 183 | assert_eq!(session.cwd.as_deref(), Some("/tmp/project")); |
| 184 | // user, assistant, user — the empty assistant reply is dropped. |
| 185 | assert_eq!(session.messages.len(), 3); |
| 186 | assert_eq!(session.messages[0].role, Role::User); |
| 187 | assert_eq!(session.messages[0].text, "hello"); |
| 188 | assert_eq!(session.messages[1].role, Role::Assistant); |
| 189 | assert_eq!(session.messages[1].text, "hi there"); |
| 190 | assert_eq!(session.messages[2].role, Role::User); |
| 191 | assert_eq!(session.messages[2].text, "second"); |
| 192 | |
| 193 | let forked = "99999999-2222-3333-4444-555555555555"; |
| 194 | fork(id, forked); |
| 195 | assert_eq!(load(forked).messages.len(), 3, "fork copies the transcript"); |
| 196 | |
| 197 | clear(id); |
| 198 | assert!(load(id).is_empty(), "clear forgets the transcript"); |
| 199 | clear(id); // clearing a missing session is a no-op |
| 200 | |
| 201 | let _ = std::fs::remove_dir_all(&dir); |
| 202 | // SAFETY: single-threaded test guarded by ENV_TEST_LOCK. |
| 203 | unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; |
| 204 | } |
| 205 | |
| 206 | #[test] |
| 207 | fn sanitize_id_blocks_path_traversal() { |
| 208 | // Dots are kept, but every path separator becomes `_`, so the result is |
| 209 | // always a single, non-traversing path component. |
| 210 | assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd"); |
| 211 | assert_eq!(sanitize_id("a/b\\c"), "a_b_c"); |
| 212 | assert_eq!(sanitize_id("uuid-1234_AB.cd"), "uuid-1234_AB.cd"); |
| 213 | // Ids that sanitize to nothing usable yield no path. |
| 214 | assert!(session_path("..").is_none()); |
| 215 | assert!(session_path("/").is_none()); |
| 216 | } |
| 217 | } |