6
//! as produced by `InferenceBackend::history_snapshot`, so a saved file can be
7
//! restored into either backend.
8
//!
9
+//! A session also carries a small sidecar at `<id>.meta.json` holding the
10
+//! directories it ran in. History alone can't say where a thread belongs, and
11
+//! ACP's `session/list` — the "Import Threads" picker in editors — must report
12
+//! an absolute `cwd` per session and may ask for only the sessions of one
13
+//! directory. A session without a sidecar is therefore not listable (it still
14
+//! loads fine); that is the case for threads saved before sidecars existed.
15
+//!
16
//! Writes are atomic (temp file + rename) so a crash mid-save never leaves a
17
//! truncated session behind. Session ids are sanitized to a filename-safe
18
//! alphabet before touching the filesystem.
19
13
-use std::path::PathBuf;
20
+use std::path::{Path, PathBuf};
21
22
+use serde::{Deserialize, Serialize};
23
use serde_json::Value;
24
25
/// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
58
sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id))))
59
}
60
53
-/// Persist a history snapshot for `session_id`, replacing any previous save.
54
-/// The write is atomic: a temp file in the same directory is renamed over the
55
-/// final path.
56
-pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> {
57
- let path =
58
- session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
61
+fn meta_path(session_id: &str) -> Option<PathBuf> {
62
+ sessions_dir().map(|dir| dir.join(format!("{}.meta.json", sanitize_id(session_id))))
63
+}
64
+
65
+/// Write `body` to `path` through a uniquely named temp file in the same
66
+/// directory, then rename it into place. The unique name keeps two processes
67
+/// saving the same session from clobbering each other's half-written file;
68
+/// rename is atomic on the same filesystem.
69
+fn write_atomic(path: &Path, session_id: &str, body: &str) -> Result<(), String> {
70
let dir = path
71
.parent()
72
.ok_or_else(|| "session path has no parent".to_string())?;
73
std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?;
74
64
- let mut body = String::new();
65
- for message in history {
66
- body.push_str(&message.to_string());
67
- body.push('\n');
68
- }
69
-
70
- // Unique temp name so two processes saving the same session can't clobber
71
- // each other's half-written file; rename is atomic on the same filesystem.
75
let tmp = dir.join(format!(
76
".{}.{}.tmp",
77
sanitize_id(session_id),
78
std::process::id()
79
));
80
std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?;
78
- std::fs::rename(&tmp, &path).map_err(|error| {
81
+ std::fs::rename(&tmp, path).map_err(|error| {
82
let _ = std::fs::remove_file(&tmp);
83
format!("rename {tmp:?} -> {path:?}: {error}")
81
- })?;
82
- Ok(())
84
+ })
85
+}
86
+
87
+/// The directories a saved session ran in, as stored in its sidecar.
88
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89
+pub struct SessionMeta {
90
+ /// The session's primary working directory (absolute).
91
+ pub cwd: PathBuf,
92
+ /// Extra workspace roots of a multi-root project, in order.
93
+ #[serde(default)]
94
+ pub additional_directories: Vec<PathBuf>,
95
+}
96
+
97
+/// Record where `session_id` is running so it can be listed later. Cheap enough
98
+/// to call on every save; the sidecar is a few hundred bytes.
99
+pub fn save_meta(session_id: &str, cwd: &Path, additional_directories: &[PathBuf]) {
100
+ let Some(path) = meta_path(session_id) else {
101
+ return;
102
+ };
103
+ let meta = SessionMeta {
104
+ cwd: cwd.to_path_buf(),
105
+ additional_directories: additional_directories.to_vec(),
106
+ };
107
+ let Ok(body) = serde_json::to_string(&meta) else {
108
+ return;
109
+ };
110
+ if let Err(error) = write_atomic(&path, session_id, &body) {
111
+ log::warn!("session meta save failed for {session_id}: {error}");
112
+ }
113
+}
114
+
115
+/// Read the sidecar for `session_id`, or `None` when there isn't a readable one.
116
+fn load_meta(session_id: &str) -> Option<SessionMeta> {
117
+ let path = meta_path(session_id)?;
118
+ let contents = std::fs::read_to_string(&path).ok()?;
119
+ serde_json::from_str(&contents).ok()
120
+}
121
+
122
+/// Persist a history snapshot for `session_id`, replacing any previous save.
123
+/// The write is atomic: a temp file in the same directory is renamed over the
124
+/// final path.
125
+pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> {
126
+ let path =
127
+ session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
128
+
129
+ let mut body = String::new();
130
+ for message in history {
131
+ body.push_str(&message.to_string());
132
+ body.push('\n');
133
+ }
134
+
135
+ write_atomic(&path, session_id, &body)
136
}
137
138
/// Load the saved history for `session_id`, or `None` when no save exists (or
150
)
151
}
152
100
-/// Remove the saved history for `session_id`. Missing files are fine.
153
+/// Remove the saved history for `session_id`, sidecar included. Missing files
154
+/// are fine.
155
pub fn delete(session_id: &str) {
156
if let Some(path) = session_path(session_id) {
157
let _ = std::fs::remove_file(path);
158
}
159
+ if let Some(path) = meta_path(session_id) {
160
+ let _ = std::fs::remove_file(path);
161
+ }
162
}
163
164
/// One saved session as seen on disk.
165
///
109
-/// Cross-platform like the rest of the store, though today only the Unix-only
110
-/// TUI (`chat.rs` History tab) consumes it — hence the non-Unix dead-code gate,
111
-/// mirroring `permissions::TUI_SESSION`.
112
-#[cfg_attr(not(unix), allow(dead_code))]
166
+/// Consumed by the Unix-only TUI (`chat.rs` History tab) and, on every
167
+/// platform, by the ACP `session/list` handler in `main.rs`.
168
#[derive(Debug, Clone, PartialEq, Eq)]
169
pub struct SessionEntry {
170
/// The sanitized id (the file stem), which `load`/`delete` accept as-is.
173
pub modified: std::time::SystemTime,
174
/// Number of history messages (non-empty lines) in the file.
175
pub message_count: usize,
176
+ /// The session's working directory, when a sidecar recorded one.
177
+ pub cwd: Option<PathBuf>,
178
+ /// Extra workspace roots recorded alongside `cwd`.
179
+ pub additional_directories: Vec<PathBuf>,
180
+ /// A one-line title taken from the first user message, when there is one.
181
+ pub title: Option<String>,
182
+}
183
+
184
+/// Longest title handed out by [`session_title`] before it is cut short.
185
+const TITLE_MAX_CHARS: usize = 80;
186
+
187
+/// The plain text of a history message, whether `content` is a string or the
188
+/// structured `[{ "type": "text", "text": … }]` form.
189
+fn message_text(message: &Value) -> String {
190
+ match &message["content"] {
191
+ Value::String(text) => text.clone(),
192
+ Value::Array(blocks) => blocks
193
+ .iter()
194
+ .filter_map(|block| block["text"].as_str())
195
+ .collect::<Vec<_>>()
196
+ .join("\n"),
197
+ _ => String::new(),
198
+ }
199
+}
200
+
201
+/// A human-readable title for a session: the first line of its first user
202
+/// message, trimmed and cut to [`TITLE_MAX_CHARS`].
203
+fn session_title(contents: &str) -> Option<String> {
204
+ for line in contents.lines() {
205
+ if line.trim().is_empty() {
206
+ continue;
207
+ }
208
+ let Ok(message) = serde_json::from_str::<Value>(line) else {
209
+ continue;
210
+ };
211
+ if message["role"] != "user" {
212
+ continue;
213
+ }
214
+ let text = message_text(&message);
215
+ let Some(first_line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
216
+ continue;
217
+ };
218
+ let mut title: String = first_line.chars().take(TITLE_MAX_CHARS).collect();
219
+ if first_line.chars().count() > TITLE_MAX_CHARS {
220
+ title.push('…');
221
+ }
222
+ return Some(title);
223
+ }
224
+ None
225
}
226
227
/// List the saved sessions, newest first. Non-`.jsonl` entries (temp files,
124
-/// stray junk) are skipped. A missing or unreadable sessions dir yields an
125
-/// empty list.
126
-#[cfg_attr(not(unix), allow(dead_code))]
228
+/// sidecars, stray junk) are skipped. A missing or unreadable sessions dir
229
+/// yields an empty list.
230
pub fn list() -> Vec<SessionEntry> {
231
let Some(dir) = sessions_dir() else {
232
return Vec::new();
242
return None;
243
}
244
let id = path.file_stem()?.to_str()?.to_string();
142
- // Cheap line count: no JSON parsing, just non-empty lines.
245
let contents = std::fs::read_to_string(&path).ok()?;
246
let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count();
247
+ let title = session_title(&contents);
248
let modified = entry
249
.metadata()
250
.ok()
251
.and_then(|m| m.modified().ok())
252
.unwrap_or(std::time::UNIX_EPOCH);
253
+ let meta = load_meta(&id);
254
+ let (cwd, additional_directories) = match meta {
255
+ Some(meta) => (Some(meta.cwd), meta.additional_directories),
256
+ None => (None, Vec::new()),
257
+ };
258
Some(SessionEntry {
259
id,
260
modified,
261
message_count,
262
+ cwd,
263
+ additional_directories,
264
+ title,
265
})
266
})
267
.collect();
269
sessions
270
}
271
272
+/// Format a timestamp as an ISO 8601 UTC instant (`2026-09-16T10:08:15Z`),
273
+/// which is what ACP's `SessionInfo.updatedAt` expects. Hand-rolled rather than
274
+/// pulling in a date crate for one field; pre-epoch times clamp to the epoch.
275
+pub fn iso8601(time: std::time::SystemTime) -> String {
276
+ let secs = time
277
+ .duration_since(std::time::UNIX_EPOCH)
278
+ .map(|d| d.as_secs())
279
+ .unwrap_or(0);
280
+ let days = (secs / 86_400) as i64;
281
+ let seconds_of_day = secs % 86_400;
282
+ let (hour, minute, second) = (
283
+ seconds_of_day / 3600,
284
+ (seconds_of_day % 3600) / 60,
285
+ seconds_of_day % 60,
286
+ );
287
+
288
+ // Howard Hinnant's civil_from_days: shift the epoch to 0000-03-01 so leap
289
+ // days land at the end of the era and the month arithmetic stays branchless.
290
+ let z = days + 719_468;
291
+ let era = z.div_euclid(146_097);
292
+ let day_of_era = z.rem_euclid(146_097);
293
+ let year_of_era =
294
+ (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
295
+ let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
296
+ let mp = (5 * day_of_year + 2) / 153;
297
+ let day = day_of_year - (153 * mp + 2) / 5 + 1;
298
+ let month = if mp < 10 { mp + 3 } else { mp - 9 };
299
+ let year = year_of_era + era * 400 + i64::from(month <= 2);
300
+
301
+ format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
302
+}
303
+
304
#[cfg(test)]
305
mod tests {
306
use super::*;
397
assert_eq!(listed[1].id, "older");
398
assert_eq!(listed[1].message_count, 3);
399
assert!(listed[0].modified >= listed[1].modified);
400
+ // Titles come from the first user message; no sidecar means no cwd.
401
+ assert_eq!(listed[0].title.as_deref(), Some("x"));
402
+ assert_eq!(listed[1].title.as_deref(), Some("a"));
403
+ assert_eq!(listed[0].cwd, None);
404
+ assert!(listed[0].additional_directories.is_empty());
405
+
406
+ // A sidecar makes the session listable with its directories, and
407
+ // deleting the session takes the sidecar with it.
408
+ save_meta(
409
+ "newer",
410
+ Path::new("/tmp/project"),
411
+ &[PathBuf::from("/tmp/lib")],
412
+ );
413
+ let listed = list();
414
+ assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/tmp/project")));
415
+ assert_eq!(
416
+ listed[0].additional_directories,
417
+ vec![PathBuf::from("/tmp/lib")]
418
+ );
419
+ assert!(sessions.join("newer.meta.json").is_file());
420
+ delete("newer");
421
+ assert!(!sessions.join("newer.meta.json").exists());
422
+ assert_eq!(list().len(), 1);
423
424
unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
425
let _ = std::fs::remove_dir_all(&dir);
426
}
427
+
428
+ #[test]
429
+ fn title_uses_the_first_line_of_the_first_user_message() {
430
+ let contents = "{\"role\":\"system\",\"content\":\"sys\"}\n\
431
+ {\"role\":\"user\",\"content\":\" fix the parser\\nmore detail\"}\n\
432
+ {\"role\":\"user\",\"content\":\"second\"}\n";
433
+ assert_eq!(session_title(contents).as_deref(), Some("fix the parser"));
434
+
435
+ // Structured content blocks, not just plain strings.
436
+ let blocks =
437
+ "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"from a block\"}]}\n";
438
+ assert_eq!(session_title(blocks).as_deref(), Some("from a block"));
439
+
440
+ // Unparseable lines are skipped, not fatal.
441
+ let junk = "not json\n{\"role\":\"user\",\"content\":\"still found\"}\n";
442
+ assert_eq!(session_title(junk).as_deref(), Some("still found"));
443
+
444
+ // Long titles are cut short with an ellipsis.
445
+ let long = format!(
446
+ "{{\"role\":\"user\",\"content\":\"{}\"}}\n",
447
+ "x".repeat(TITLE_MAX_CHARS + 10)
448
+ );
449
+ let title = session_title(&long).unwrap();
450
+ assert_eq!(title.chars().count(), TITLE_MAX_CHARS + 1);
451
+ assert!(title.ends_with('…'));
452
+
453
+ // Nothing from the user yet → no title.
454
+ assert_eq!(
455
+ session_title("{\"role\":\"assistant\",\"content\":\"hi\"}\n"),
456
+ None
457
+ );
458
+ assert_eq!(session_title(""), None);
459
+ }
460
+
461
+ #[test]
462
+ fn iso8601_formats_utc_instants() {
463
+ use std::time::{Duration, UNIX_EPOCH};
464
+
465
+ assert_eq!(iso8601(UNIX_EPOCH), "1970-01-01T00:00:00Z");
466
+ // 2026-09-16T10:08:15Z
467
+ assert_eq!(
468
+ iso8601(UNIX_EPOCH + Duration::from_secs(1_789_553_295)),
469
+ "2026-09-16T10:08:15Z"
470
+ );
471
+ // A leap day, to exercise the civil-date arithmetic.
472
+ assert_eq!(
473
+ iso8601(UNIX_EPOCH + Duration::from_secs(1_709_164_800)),
474
+ "2024-02-29T00:00:00Z"
475
+ );
476
+ }
477
}