slug-code/src/session/mod.rs
Bryan Ramos b8bf9029fe Initial Slug Code Rust implementation
Core features:
- OpenAI-compatible streaming provider (vLLM, Ollama, OpenAI, etc.)
- Agent loop with tool use (bash, read, write, edit, glob, grep)
- Permission system: ask/yolo/sandbox/allowEdits + glob patterns
- SLUG.md hierarchy loaded every turn (CLAUDE.md equivalent)
- Session persistence with --continue/--resume/--fork-session
- Hook system: 5 lifecycle events, command + prompt types
- Compaction: ToolResultTrim/Truncate strategies, /compact command
- Config via TOML, CLI args, env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:23:04 -04:00

233 lines
8.2 KiB
Rust

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use uuid::Uuid;
use crate::provider::ChatMessage;
/// Metadata for a saved session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub session_id: String,
pub created_at: String,
pub last_used_at: String,
pub working_directory: String,
pub model: String,
pub summary: String,
}
/// An in-memory session handle returned on create or fork.
#[derive(Debug, Clone)]
pub struct Session {
pub session_id: String,
pub messages: Vec<ChatMessage>,
}
/// Manages session persistence on disk.
pub struct SessionManager {
sessions_dir: PathBuf,
}
impl SessionManager {
/// Create a new SessionManager, ensuring the sessions directory exists.
pub fn new() -> Self {
let sessions_dir = Self::sessions_dir();
if let Err(e) = fs::create_dir_all(&sessions_dir) {
eprintln!("Warning: could not create sessions directory: {e}");
}
Self { sessions_dir }
}
fn sessions_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".slug")
.join("sessions")
}
fn jsonl_path(&self, session_id: &str) -> PathBuf {
self.sessions_dir.join(format!("{session_id}.jsonl"))
}
fn meta_path(&self, session_id: &str) -> PathBuf {
self.sessions_dir
.join(format!("{session_id}.meta.json"))
}
fn now_iso8601() -> String {
// Use std::time to get a basic timestamp without pulling in chrono.
// Format: seconds since UNIX epoch as a UTC ISO-8601 string.
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Produce a minimal ISO-8601-like UTC string: YYYY-MM-DDTHH:MM:SSZ
let s = secs;
let sec = s % 60;
let min = (s / 60) % 60;
let hour = (s / 3600) % 24;
let days = s / 86400; // days since 1970-01-01
// Convert days → calendar date (proleptic Gregorian)
let (year, month, day) = days_to_ymd(days);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hour, min, sec
)
}
/// Create a brand-new session with a fresh UUID. Does not write to disk yet.
pub fn create_session(&self) -> Session {
Session {
session_id: Uuid::new_v4().to_string(),
messages: Vec::new(),
}
}
/// Write (or overwrite) the metadata file for a session.
pub fn write_meta(&self, meta: &SessionMeta) -> Result<()> {
let path = self.meta_path(&meta.session_id);
let json = serde_json::to_string_pretty(meta)
.context("failed to serialize session metadata")?;
fs::write(&path, json).with_context(|| format!("failed to write meta file {path:?}"))?;
Ok(())
}
/// Initialize metadata for a newly created session.
pub fn init_meta(
&self,
session: &Session,
working_directory: &str,
model: &str,
summary: &str,
) -> Result<()> {
let now = Self::now_iso8601();
let meta = SessionMeta {
session_id: session.session_id.clone(),
created_at: now.clone(),
last_used_at: now,
working_directory: working_directory.to_string(),
model: model.to_string(),
summary: summary.to_string(),
};
self.write_meta(&meta)
}
/// Append a single message to the JSONL file and update last_used_at.
pub fn save_message(&self, session_id: &str, message: &ChatMessage) -> Result<()> {
let path = self.jsonl_path(session_id);
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to open session file {path:?}"))?;
let line = serde_json::to_string(message).context("failed to serialize message")?;
writeln!(file, "{line}").with_context(|| format!("failed to write to {path:?}"))?;
// Update last_used_at in meta if it exists.
let meta_path = self.meta_path(session_id);
if meta_path.exists() {
if let Ok(raw) = fs::read_to_string(&meta_path) {
if let Ok(mut meta) = serde_json::from_str::<SessionMeta>(&raw) {
meta.last_used_at = Self::now_iso8601();
let _ = self.write_meta(&meta);
}
}
}
Ok(())
}
/// Read all messages from a session's JSONL file.
pub fn load_session(&self, session_id: &str) -> Result<Vec<ChatMessage>> {
let path = self.jsonl_path(session_id);
if !path.exists() {
return Ok(Vec::new());
}
let file =
File::open(&path).with_context(|| format!("failed to open session file {path:?}"))?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
for (line_no, line) in reader.lines().enumerate() {
let line = line.with_context(|| format!("error reading line {line_no} of {path:?}"))?;
if line.trim().is_empty() {
continue;
}
let msg: ChatMessage = serde_json::from_str(&line)
.with_context(|| format!("failed to parse line {line_no} of {path:?}"))?;
messages.push(msg);
}
Ok(messages)
}
/// List all sessions, sorted by last_used_at descending (most recent first).
pub fn list_sessions(&self) -> Vec<SessionMeta> {
let mut metas = Vec::new();
let entries = match fs::read_dir(&self.sessions_dir) {
Ok(e) => e,
Err(_) => return metas,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json")
&& path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".meta.json"))
.unwrap_or(false)
{
if let Ok(raw) = fs::read_to_string(&path) {
if let Ok(meta) = serde_json::from_str::<SessionMeta>(&raw) {
metas.push(meta);
}
}
}
}
// Sort most-recent first.
metas.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
metas
}
/// Return the most recently used session, if any.
pub fn get_latest_session(&self) -> Option<SessionMeta> {
self.list_sessions().into_iter().next()
}
/// Create a new session that starts with all messages copied from source_id.
pub fn fork_session(&self, source_id: &str) -> Result<Session> {
let messages = self.load_session(source_id)
.with_context(|| format!("failed to load source session {source_id}"))?;
let new_id = Uuid::new_v4().to_string();
// Write all messages into the new JSONL immediately.
for msg in &messages {
self.save_message(&new_id, msg)?;
}
Ok(Session {
session_id: new_id,
messages,
})
}
}
// ---------------------------------------------------------------------------
// Minimal days-since-epoch → (year, month, day) conversion
// (avoids a chrono dependency for the timestamp helper)
// ---------------------------------------------------------------------------
fn days_to_ymd(days: u64) -> (u64, u8, u8) {
// Algorithm: civil date from days since 1970-01-01
// Based on Howard Hinnant's public-domain algorithm.
let z = days as i64 + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as u64, m as u8, d as u8)
}