+11
-1
index 5fc3327..3ada51b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
`OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
-`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
+`SIGIT_MODEL`, `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`, `SIGIT_DISABLE_APP_GROUP` (macOS: skip the
+shared Onde App Group model cache so the cross-app data privacy prompt never fires; falls back to
+`~/.cache/huggingface`).
+
+## Session persistence (ACP)
+
+ACP chat transcripts persist per `SessionId` under `$SIGIT_CONFIG_DIR/sessions/<id>.json` (see
+`src/sessions.rs`). Each completed user/assistant turn is appended in `handle_prompt`; on
+`session/load` (and `session/fork`) the transcript is replayed to the editor and pushed back into
+the active backend via `InferenceBackend::restore_history`, so reopening a thread in Zed resumes it
+instead of starting blank. `/clear` wipes both the engine history and the stored file.
## Releasing
+49
index c99e90d..d851f53 100644
--- a/src/backend.rs
+++ b/src/backend.rs
pub tool_calls: Vec<ToolCall>,
}
+/// Author of a turn replayed into a backend when a persisted session reopens.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HistoryRole {
+ User,
+ Assistant,
+}
+
+/// A prior user/assistant turn restored into a backend's context when a
+/// persisted session is reopened (see [`InferenceBackend::restore_history`]).
+#[derive(Debug, Clone)]
+pub struct HistoryMessage {
+ pub role: HistoryRole,
+ pub content: String,
+}
+
/// Backend errors are plain strings. Callers map them to ACP errors.
pub type BackendError = String;
/// 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;
+
+ /// Replay prior user/assistant turns into the backend's context after a
+ /// persisted session is reopened, so the model continues with its history
+ /// rather than starting blank. Called once on session load, before any new
+ /// prompt. The default does nothing.
+ async fn restore_history(&self, _messages: &[HistoryMessage]) {}
}
// ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
fn is_remote(&self) -> bool {
false
}
+
+ async fn restore_history(&self, messages: &[HistoryMessage]) {
+ // Push the saved turns straight into onde's conversation history so the
+ // next prompt sees them. The system context is re-pushed separately by
+ // the caller, so only user/assistant turns flow through here.
+ for message in messages {
+ let chat_message = match message.role {
+ HistoryRole::User => onde::inference::ChatMessage::user(message.content.clone()),
+ HistoryRole::Assistant => {
+ onde::inference::ChatMessage::assistant(message.content.clone())
+ }
+ };
+ self.engine.push_history(chat_message).await;
+ }
+ }
}
/// Drain an onde streaming receiver, forwarding each token to `sink` and
fn is_remote(&self) -> bool {
true
}
+
+ async fn restore_history(&self, messages: &[HistoryMessage]) {
+ // Splice the saved turns in after the seeded system prompt and before
+ // any new request, matching the OpenAI chat history shape.
+ let mut history = self.history.lock().await;
+ for message in messages {
+ let role = match message.role {
+ HistoryRole::User => "user",
+ HistoryRole::Assistant => "assistant",
+ };
+ history.push(serde_json::json!({ "role": role, "content": message.content }));
+ }
+ }
}
// ── OpenAI response shapes ────────────────────────────────────────────────────────
+86
-10
index 79c041d..f0961a0 100644
--- a/src/main.rs
+++ b/src/main.rs
mod instructions;
mod models;
mod provider;
+mod sessions;
mod settings;
mod setup;
mod skills;
use onde::inference::{ChatEngine, GgufModelConfig};
use crate::backend::{
- InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult, ToolSpec,
- TurnResult,
+ HistoryMessage, HistoryRole, InferenceBackend, LocalBackend, OpenAiBackend,
+ ToolResult as BackendToolResult, ToolSpec, TurnResult,
};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
}
}
+ /// Replay a persisted transcript into a freshly-loaded session: restore the
+ /// model's context on whatever backend is now active, then re-emit each turn
+ /// to the editor so a reopened thread shows its history instead of a blank
+ /// panel. Call after the session cwd and backend are settled.
+ async fn replay_stored_session(
+ &self,
+ cx: &ConnectionTo<Client>,
+ session_id: &SessionId,
+ stored: &sessions::StoredSession,
+ ) {
+ if stored.messages.is_empty() {
+ return;
+ }
+
+ // Give the model its prior context back on the active backend.
+ let history: Vec<HistoryMessage> = stored
+ .messages
+ .iter()
+ .map(|message| HistoryMessage {
+ role: match message.role {
+ sessions::Role::User => HistoryRole::User,
+ sessions::Role::Assistant => HistoryRole::Assistant,
+ },
+ content: message.text.clone(),
+ })
+ .collect();
+ self.backend.lock().await.restore_history(&history).await;
+
+ // Replay the visible transcript so the editor re-renders the thread.
+ for message in &stored.messages {
+ let chunk = ContentChunk::new(ContentBlock::from(message.text.clone()));
+ let update = match message.role {
+ sessions::Role::User => SessionUpdate::UserMessageChunk(chunk),
+ sessions::Role::Assistant => SessionUpdate::AgentMessageChunk(chunk),
+ };
+ self.send_tool_call_update(cx, session_id.clone(), update)
+ .ok();
+ }
+
+ log::info!(
+ "replayed {} stored message(s) for session {session_id}",
+ stored.messages.len()
+ );
+ }
+
async fn handle_load_session(
&self,
cx: &ConnectionTo<Client>,
log::warn!("could not set cwd to {}: {err}", args.cwd.display());
}
- // no session persistence, so "load" just resets
+ // Start from a clean engine, then rebuild the per-session context.
self.engine.clear_history().await;
self.engine
.await;
// Honor the persisted Local Inference toggle (off + signed in → cloud).
+ // Do this before replaying so the saved turns land on the active backend.
self.apply_startup_inference_mode().await;
+ // Replay the persisted transcript so a reopened thread resumes instead
+ // of starting blank.
+ let stored = sessions::load(args.session_id.0.as_ref());
+ self.replay_stored_session(cx, &args.session_id, &stored)
+ .await;
+
let config_options = {
let guard = self.current_model.lock().unwrap();
build_model_config_options(&guard)
log::warn!("could not set cwd to {}: {err}", args.cwd.display());
}
- // no persistence, so fork == fresh session
+ // Start from a clean engine, then rebuild the per-session context.
self.engine.clear_history().await;
self.engine
// Honor the persisted Local Inference toggle (off + signed in → cloud).
self.apply_startup_inference_mode().await;
+ // Carry the parent thread's transcript into the fork so it opens with
+ // the same history rather than blank, then replay it like a load.
+ sessions::fork(args.session_id.0.as_ref(), new_id.0.as_ref());
+ let stored = sessions::load(new_id.0.as_ref());
+ self.replay_stored_session(cx, &new_id, &stored).await;
+
let config_options = {
let guard = self.current_model.lock().unwrap();
build_model_config_options(&guard)
}
// ── Final text response ───────────────────────────────────────────
- // If anything streamed, the visible reply is already on the wire; only
- // send a trailing block for the non-streamed path (e.g. on-device direct
- // answers, which onde can't stream while tools are on offer).
- if !streamed_any {
+ // If anything streamed, the visible reply is already on the wire and
+ // `sent` holds exactly what reached the editor. Otherwise send the
+ // buffered final text now (e.g. on-device direct answers, which onde
+ // can't stream while tools are on offer). Either way, keep the visible
+ // reply so we can persist the turn below.
+ let assistant_reply = if streamed_any {
+ sent.clone()
+ } else {
let reply_text = result.text.trim().to_string();
let final_text = if reply_text.is_empty() {
if round > 0 {
};
if !final_text.is_empty() {
- self.send_assistant_message(cx, session_id.clone(), final_text)
+ self.send_assistant_message(cx, session_id.clone(), final_text.clone())
.ok();
}
- }
+ final_text
+ };
+
+ // Persist the completed turn so reopening this thread in the editor
+ // resumes it instead of starting from a blank panel.
+ let cwd = self.session_cwd.lock().ok().and_then(|guard| guard.clone());
+ sessions::append_turn(
+ session_id.0.as_ref(),
+ cwd.as_deref(),
+ &user_text,
+ &assistant_reply,
+ );
log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
Ok(PromptResponse::new(StopReason::EndTurn))
}
SlashCommand::Clear => {
let cleared = agent.engine.clear_history().await;
+ // Also forget the persisted transcript so the wipe survives a reload.
+ sessions::clear(session_id.0.as_ref());
agent
.send_assistant_message(
cx,
+217
new file mode 100644
index 0000000..ae79662
--- /dev/null
+++ b/src/sessions.rs
+//! Local persistence of ACP chat conversations.
+//!
+//! Editors such as Zed remember a thread's `SessionId` across restarts and call
+//! `session/load` to reopen it. Before this module siGit cleared history on
+//! load, so every reopened thread started blank. Here we store a compact
+//! transcript — the user prompts and the assistant's visible replies — per
+//! session under `$SIGIT_CONFIG_DIR/sessions/<id>.json`. On reload the
+//! transcript is replayed to the editor and pushed back into the active backend
+//! so the model keeps its context.
+//!
+//! Only finished user/assistant turns are stored: no tool-call plumbing and no
+//! system context (that is rebuilt fresh from the cwd on every load). This keeps
+//! the format backend-agnostic — the same file restores whether the session
+//! resumes on-device or on a siGit Code Cloud tier.
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+/// Author of a stored message.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Role {
+ User,
+ Assistant,
+}
+
+/// One persisted turn in a conversation.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StoredMessage {
+ pub role: Role,
+ pub text: String,
+}
+
+/// A persisted conversation, keyed on disk by its ACP `SessionId`.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct StoredSession {
+ /// The session's working directory, kept for reference/debugging.
+ #[serde(default)]
+ pub cwd: Option<String>,
+ #[serde(default)]
+ pub messages: Vec<StoredMessage>,
+}
+
+impl StoredSession {
+ pub fn is_empty(&self) -> bool {
+ self.messages.is_empty()
+ }
+}
+
+/// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. Mirrors
+/// [`crate::settings`] and [`crate::credentials`].
+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 `SessionId` to a single, traversal-safe file-name stem. Editors
+/// pick the id (usually a UUID); keep `[A-Za-z0-9._-]` and map anything else to
+/// `_` so it can never escape the sessions directory.
+fn sanitize_id(id: &str) -> String {
+ id.chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
+ c
+ } else {
+ '_'
+ }
+ })
+ .collect()
+}
+
+/// Path to the transcript file for `id`, or `None` if the id can't form a valid
+/// file name (empty, or only dots/separators after sanitizing).
+fn session_path(id: &str) -> Option<PathBuf> {
+ let stem = sanitize_id(id);
+ if stem.trim_matches(['.', '_', '-']).is_empty() {
+ return None;
+ }
+ sessions_dir().map(|dir| dir.join(format!("{stem}.json")))
+}
+
+/// Load the stored transcript for `id`, or an empty session if none exists or
+/// the file can't be read or parsed.
+pub fn load(id: &str) -> StoredSession {
+ let Some(path) = session_path(id) else {
+ return StoredSession::default();
+ };
+ match std::fs::read_to_string(&path) {
+ Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|error| {
+ log::warn!("sessions: ignoring unreadable {}: {error}", path.display());
+ StoredSession::default()
+ }),
+ Err(_) => StoredSession::default(),
+ }
+}
+
+/// Persist `session` for `id`, creating the sessions directory if needed.
+pub fn save(id: &str, session: &StoredSession) -> Result<(), String> {
+ let path = session_path(id).ok_or_else(|| format!("invalid session id: {id:?}"))?;
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|error| format!("create {parent:?}: {error}"))?;
+ }
+ let body =
+ serde_json::to_string_pretty(session).map_err(|error| format!("serialize: {error}"))?;
+ std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))
+}
+
+/// Append a completed turn to `id`'s transcript. The user text is always
+/// recorded; the assistant reply only when non-empty. Best-effort: a write
+/// failure is logged, never surfaced, so persistence can't break a live turn.
+pub fn append_turn(id: &str, cwd: Option<&Path>, user_text: &str, assistant_text: &str) {
+ let mut session = load(id);
+ if session.cwd.is_none() {
+ session.cwd = cwd.map(|path| path.display().to_string());
+ }
+ session.messages.push(StoredMessage {
+ role: Role::User,
+ text: user_text.to_string(),
+ });
+ let assistant = assistant_text.trim();
+ if !assistant.is_empty() {
+ session.messages.push(StoredMessage {
+ role: Role::Assistant,
+ text: assistant.to_string(),
+ });
+ }
+ if let Err(error) = save(id, &session) {
+ log::warn!("sessions: could not persist turn for {id}: {error}");
+ }
+}
+
+/// Forget `id`'s transcript (used by `/clear`). A missing file is not an error.
+pub fn clear(id: &str) {
+ if let Some(path) = session_path(id)
+ && let Err(error) = std::fs::remove_file(&path)
+ && error.kind() != std::io::ErrorKind::NotFound
+ {
+ log::warn!("sessions: could not clear {}: {error}", path.display());
+ }
+}
+
+/// Copy `from`'s transcript onto `to` when a session is forked, so the fork
+/// opens with the parent's history instead of blank. Best-effort.
+pub fn fork(from: &str, to: &str) {
+ let session = load(from);
+ if session.is_empty() {
+ return;
+ }
+ if let Err(error) = save(to, &session) {
+ log::warn!("sessions: could not fork {from} -> {to}: {error}");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn append_load_clear_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: single-threaded test guarded by ENV_TEST_LOCK; restored below.
+ unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
+
+ let id = "11111111-2222-3333-4444-555555555555";
+ assert!(load(id).is_empty(), "unknown session starts empty");
+
+ append_turn(id, Some(Path::new("/tmp/project")), "hello", "hi there");
+ append_turn(id, None, "second", "");
+
+ let session = load(id);
+ assert_eq!(session.cwd.as_deref(), Some("/tmp/project"));
+ // user, assistant, user — the empty assistant reply is dropped.
+ assert_eq!(session.messages.len(), 3);
+ assert_eq!(session.messages[0].role, Role::User);
+ assert_eq!(session.messages[0].text, "hello");
+ assert_eq!(session.messages[1].role, Role::Assistant);
+ assert_eq!(session.messages[1].text, "hi there");
+ assert_eq!(session.messages[2].role, Role::User);
+ assert_eq!(session.messages[2].text, "second");
+
+ let forked = "99999999-2222-3333-4444-555555555555";
+ fork(id, forked);
+ assert_eq!(load(forked).messages.len(), 3, "fork copies the transcript");
+
+ clear(id);
+ assert!(load(id).is_empty(), "clear forgets the transcript");
+ clear(id); // clearing a missing session is a no-op
+
+ let _ = std::fs::remove_dir_all(&dir);
+ // SAFETY: single-threaded test guarded by ENV_TEST_LOCK.
+ unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
+ }
+
+ #[test]
+ fn sanitize_id_blocks_path_traversal() {
+ // Dots are kept, but every path separator becomes `_`, so the result is
+ // always a single, non-traversing path component.
+ assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd");
+ assert_eq!(sanitize_id("a/b\\c"), "a_b_c");
+ assert_eq!(sanitize_id("uuid-1234_AB.cd"), "uuid-1234_AB.cd");
+ // Ids that sanitize to nothing usable yield no path.
+ assert!(session_path("..").is_none());
+ assert!(session_path("/").is_none());
+ }
+}
+29
index f57c798..8c7310a 100644
--- a/src/setup.rs
+++ b/src/setup.rs
#[cfg(target_os = "macos")]
const APP_GROUP_IDENTIFIER: &str = "group.com.ondeinference.apps";
+/// Opt out of the shared App Group cache. Set truthy to keep siGit out of the
+/// Onde App Group container entirely. On macOS Sequoia a process that touches
+/// another app's Group Container triggers a "would like to access data from
+/// other apps" privacy prompt; when siGit runs as an editor's ACP subprocess
+/// (e.g. Zed) that prompt is attributed to the editor and recurs on every
+/// launch because the unsigned CLI binary can't hold a stable TCC grant.
+/// Setting this routes model discovery and caching to the default
+/// `~/.cache/huggingface` location instead, so the prompt never appears.
+#[cfg(target_os = "macos")]
+const DISABLE_APP_GROUP_ENV: &str = "SIGIT_DISABLE_APP_GROUP";
+
/// point `HF_HOME` / `HF_HUB_CACHE` at the shared container. no-ops if
/// the user already set them.
pub fn setup_shared_model_cache() {
/// so it won't exist until the user has launched siGit desktop or another Onde app.
#[cfg(target_os = "macos")]
fn resolve_shared_container() -> Option<PathBuf> {
+ // Honor the opt-out before touching the container at all — the access
+ // itself is what triggers the macOS cross-app data privacy prompt.
+ if std::env::var(DISABLE_APP_GROUP_ENV)
+ .ok()
+ .map(|value| {
+ matches!(
+ value.trim().to_ascii_lowercase().as_str(),
+ "1" | "true" | "on" | "yes"
+ )
+ })
+ .unwrap_or(false)
+ {
+ log::info!(
+ "{DISABLE_APP_GROUP_ENV} set — skipping Onde App Group container, using default HF cache"
+ );
+ return None;
+ }
+
let home = std::env::var("HOME").ok()?;
let container = PathBuf::from(home)
.join("Library")