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
+}