use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use crate::{
compact_session, estimate_session_tokens, CompactionConfig, ConversationRuntime,
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
PermissionRequest, Session, TurnSummary,
};
use anyhow::{Context, Result};
use crate::api::{stdout_text_callback, telegram_text_callback, OllamaApiClient};
use crate::commands::{dispatch_slash_command, parse_slash_command, ReplState, SlashOutcome};
use crate::executor::SecretaryToolExecutor;
use crate::memory::try_load_memory;
use crate::model_config;
use crate::prompt::secretary_system_prompt_with_memory;
use crate::theme;
use crate::tool_groups::{ToolGroup, ToolRegistry};
pub const DEFAULT_COMPACT_THRESHOLD: usize = 1_000_000;
#[must_use]
pub fn compact_threshold() -> usize {
std::env::var("CLAUDETTE_COMPACT_THRESHOLD")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(DEFAULT_COMPACT_THRESHOLD)
}
pub const DEFAULT_MAX_ITERATIONS: usize = 40;
#[must_use]
pub fn max_iterations() -> usize {
std::env::var("CLAUDETTE_MAX_ITERATIONS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_MAX_ITERATIONS)
}
#[must_use]
pub fn current_model() -> String {
model_config::active().brain.model
}
#[derive(Debug, Clone, Default)]
pub struct SessionOptions {
pub resume: bool,
pub autosave: bool,
}
#[must_use]
pub fn default_session_path() -> PathBuf {
if let Ok(custom) = std::env::var("CLAUDETTE_SESSION") {
if !custom.is_empty() {
return PathBuf::from(custom);
}
}
sessions_dir().join("last.json")
}
pub(crate) fn sessions_dir() -> PathBuf {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".claudette").join("sessions")
}
pub fn try_load_session() -> Result<Option<Session>> {
try_load_session_at(&default_session_path())
}
pub fn try_load_session_at(path: &std::path::Path) -> Result<Option<Session>> {
if !path.exists() {
return Ok(None);
}
let session = Session::load_from_path(path)
.with_context(|| format!("failed to load session from {}", path.display()))?;
Ok(Some(session))
}
pub fn save_session(session: &Session) -> Result<()> {
save_session_at(session, &default_session_path())
}
pub fn save_session_at(session: &Session, path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
session
.save_to_path(path)
.with_context(|| format!("failed to save session to {}", path.display()))?;
Ok(())
}
pub fn run_secretary(user_input: &str, opts: SessionOptions) -> Result<TurnSummary> {
let session = if opts.resume {
try_load_session()?.ok_or_else(|| {
anyhow::anyhow!("no saved session at {}", default_session_path().display())
})?
} else {
Session::default()
};
let mut runtime = build_runtime(session);
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(user_input));
let mut no_prompter: Option<&mut dyn PermissionPrompter> = None;
let summary =
crate::brain_selector::run_turn_with_fallback(&mut runtime, user_input, &mut no_prompter)
.map_err(|e| anyhow::anyhow!("secretary turn failed: {e}"))?;
if let Some(removed) = maybe_compact_session(&mut runtime, false) {
eprintln!("[auto-compacted {removed} older message(s)]");
}
if opts.autosave {
save_session(runtime.session())?;
}
Ok(summary)
}
pub fn run_secretary_repl(opts: SessionOptions) -> Result<()> {
theme::init();
let session = if opts.resume {
match try_load_session()? {
Some(s) => {
eprintln!(
"{} {} {}",
theme::SAVE,
theme::ok("resumed session"),
theme::dim(&format!(
"from {} ({} messages)",
default_session_path().display(),
s.messages.len()
))
);
s
}
None => {
eprintln!(
"{} {}",
theme::dim("○"),
theme::dim(&format!(
"no saved session at {} — starting fresh",
default_session_path().display()
))
);
Session::default()
}
}
} else {
Session::default()
};
let mut runtime = build_runtime_streaming(session, false);
let mut state = ReplState::default();
let mut prompter = CliPrompter;
eprintln!(
"{} {} {}",
theme::ROBOT,
theme::brand("claudette"),
theme::dim("— your local secretary")
);
eprintln!(
"{} {}",
theme::SPARKLES,
theme::dim("type /help for commands, /exit (or Ctrl-D) to leave")
);
eprintln!(
"{} {}",
theme::SAVE,
theme::dim(&format!("session: {}", default_session_path().display()))
);
eprintln!();
loop {
{
let stderr = io::stderr();
let mut err = stderr.lock();
write!(err, "{} ", theme::accent(theme::PROMPT_ARROW))?;
err.flush()?;
}
let line = {
let stdin = io::stdin();
let mut buf = String::new();
match stdin.read_line(&mut buf) {
Ok(0) => {
eprintln!();
break; }
Ok(_) => buf,
Err(e) => {
eprintln!("stdin error: {e}");
break;
}
}
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if matches!(trimmed, "exit" | "quit" | ":q") {
break;
}
if let Some(cmd) = parse_slash_command(trimmed) {
match dispatch_slash_command(cmd, &mut runtime, &state) {
SlashOutcome::Continue => continue,
SlashOutcome::Exit => break,
}
}
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(trimmed));
let mut prompter_opt: Option<&mut dyn PermissionPrompter> = Some(&mut prompter);
match crate::brain_selector::run_turn_with_fallback(
&mut runtime,
trimmed,
&mut prompter_opt,
) {
Ok(summary) => {
state.record_turn(summary.usage.input_tokens, summary.usage.output_tokens);
eprintln!(
"{} {}",
theme::BOLT,
theme::info(&format!(
"turn iter={} in={} out={}",
summary.iterations, summary.usage.input_tokens, summary.usage.output_tokens,
))
);
if let Some(removed) = maybe_compact_session(&mut runtime, false) {
eprintln!(
"{} {}",
theme::SAVE,
theme::ok(&format!(
"auto-compacted {removed} older message(s) — session was over {}-token threshold",
compact_threshold(),
))
);
}
if opts.autosave {
if let Err(e) = save_session(runtime.session()) {
eprintln!(
"{} {}",
theme::warn(theme::WARN_GLYPH),
theme::warn(&format!("session save failed: {e:#}"))
);
}
}
}
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("turn failed: {e}"))
);
}
}
}
Ok(())
}
pub(crate) fn build_runtime(
session: Session,
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
build_runtime_inner(session, false, false)
}
pub(crate) fn build_runtime_streaming(
session: Session,
telegram: bool,
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
build_runtime_inner(session, true, telegram)
}
fn build_runtime_inner(
session: Session,
streaming: bool,
telegram: bool,
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
let brain = model_config::active().brain;
build_runtime_with_brain(session, &brain, streaming, telegram)
}
pub(crate) fn build_runtime_with_brain(
session: Session,
brain: &crate::model_config::RoleConfig,
streaming: bool,
telegram: bool,
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
let mut reg = ToolRegistry::new();
if telegram {
reg.enable(ToolGroup::Markets);
reg.enable(ToolGroup::Facts);
reg.enable(ToolGroup::Advanced);
reg.enable(ToolGroup::Git);
reg.enable(ToolGroup::Search);
}
let registry = Arc::new(Mutex::new(reg));
let mut api_client = OllamaApiClient::with_registry(brain.model.clone(), registry.clone())
.with_context(brain.num_ctx)
.with_max_predict(brain.num_predict);
if streaming {
let cb = if telegram {
telegram_text_callback()
} else {
stdout_text_callback()
};
api_client = api_client.with_text_callback(cb);
}
let hinter_registry = Arc::clone(®istry);
let executor = SecretaryToolExecutor::with_registry(registry);
let policy = build_permission_policy();
let memory = try_load_memory();
ConversationRuntime::new(
session,
api_client,
executor,
policy,
secretary_system_prompt_with_memory(memory.as_deref(), telegram),
)
.with_max_iterations(max_iterations())
.with_auto_compaction_input_tokens_threshold(u32::MAX)
.with_unknown_tool_hinter(move |name: &str| {
ToolGroup::parse(name).map_or_else(Vec::new, |group| {
let reg = match hinter_registry.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
reg.group_tool_names(group)
})
})
}
pub(crate) fn build_permission_policy() -> PermissionPolicy {
use PermissionMode::{DangerFullAccess, ReadOnly, WorkspaceWrite};
PermissionPolicy::new(WorkspaceWrite)
.with_tool_requirement("get_current_time", ReadOnly)
.with_tool_requirement("note_list", ReadOnly)
.with_tool_requirement("note_read", ReadOnly)
.with_tool_requirement("todo_list", ReadOnly)
.with_tool_requirement("enable_tools", ReadOnly)
.with_tool_requirement("read_file", ReadOnly)
.with_tool_requirement("list_dir", ReadOnly)
.with_tool_requirement("get_capabilities", ReadOnly)
.with_tool_requirement("glob_search", ReadOnly)
.with_tool_requirement("grep_search", ReadOnly)
.with_tool_requirement("git_status", ReadOnly)
.with_tool_requirement("git_diff", ReadOnly)
.with_tool_requirement("git_log", ReadOnly)
.with_tool_requirement("git_branch", ReadOnly)
.with_tool_requirement("note_create", WorkspaceWrite)
.with_tool_requirement("note_update", WorkspaceWrite)
.with_tool_requirement("note_delete", WorkspaceWrite)
.with_tool_requirement("todo_add", WorkspaceWrite)
.with_tool_requirement("todo_complete", WorkspaceWrite)
.with_tool_requirement("todo_uncomplete", WorkspaceWrite)
.with_tool_requirement("todo_delete", WorkspaceWrite)
.with_tool_requirement("write_file", WorkspaceWrite)
.with_tool_requirement("generate_code", WorkspaceWrite)
.with_tool_requirement("web_search", WorkspaceWrite)
.with_tool_requirement("web_fetch", WorkspaceWrite)
.with_tool_requirement("open_in_editor", WorkspaceWrite)
.with_tool_requirement("reveal_in_explorer", WorkspaceWrite)
.with_tool_requirement("open_url", WorkspaceWrite)
.with_tool_requirement("add_numbers", WorkspaceWrite)
.with_tool_requirement("spawn_agent", WorkspaceWrite)
.with_tool_requirement("wikipedia_search", ReadOnly)
.with_tool_requirement("wikipedia_summary", ReadOnly)
.with_tool_requirement("weather_current", ReadOnly)
.with_tool_requirement("weather_forecast", ReadOnly)
.with_tool_requirement("crate_info", ReadOnly)
.with_tool_requirement("crate_search", ReadOnly)
.with_tool_requirement("npm_info", ReadOnly)
.with_tool_requirement("npm_search", ReadOnly)
.with_tool_requirement("gh_list_my_prs", ReadOnly)
.with_tool_requirement("gh_list_assigned_issues", ReadOnly)
.with_tool_requirement("gh_get_issue", ReadOnly)
.with_tool_requirement("gh_search_code", ReadOnly)
.with_tool_requirement("gh_create_issue", WorkspaceWrite)
.with_tool_requirement("gh_comment_issue", WorkspaceWrite)
.with_tool_requirement("tv_get_quote", ReadOnly)
.with_tool_requirement("tv_technical_rating", ReadOnly)
.with_tool_requirement("tv_search_symbol", ReadOnly)
.with_tool_requirement("tv_economic_calendar", ReadOnly)
.with_tool_requirement("vestige_asa_info", ReadOnly)
.with_tool_requirement("vestige_search_asa", ReadOnly)
.with_tool_requirement("vestige_top_movers", ReadOnly)
.with_tool_requirement("tg_get_updates", ReadOnly)
.with_tool_requirement("tg_send", WorkspaceWrite)
.with_tool_requirement("tg_send_photo", WorkspaceWrite)
.with_tool_requirement("bash", DangerFullAccess)
.with_tool_requirement("edit_file", DangerFullAccess)
.with_tool_requirement("git_add", DangerFullAccess)
.with_tool_requirement("git_commit", DangerFullAccess)
.with_tool_requirement("git_push", DangerFullAccess)
.with_tool_requirement("git_checkout", DangerFullAccess)
}
pub struct CliPrompter;
impl PermissionPrompter for CliPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
let stderr = io::stderr();
let mut err = stderr.lock();
let _ = writeln!(err);
let input_chars = request.input.chars().count();
let _ = writeln!(
err,
" {} {} wants to run ({} chars):",
theme::warn(theme::WARN_GLYPH),
theme::accent(&request.tool_name),
input_chars
);
if request.input.is_empty() {
let _ = writeln!(err, " {}", theme::dim("(empty input)"));
} else {
for line in request.input.lines() {
let _ = writeln!(err, " {}", theme::dim(line));
}
}
let _ = write!(err, " Allow? [y/N] ");
let _ = err.flush();
let stdin = io::stdin();
let mut buf = String::new();
match stdin.read_line(&mut buf) {
Ok(_) => {
let answer = buf.trim().to_lowercase();
if answer == "y" || answer == "yes" {
PermissionPromptDecision::Allow
} else {
PermissionPromptDecision::Deny {
reason: "user denied permission".to_string(),
}
}
}
Err(_) => PermissionPromptDecision::Deny {
reason: "could not read user input".to_string(),
},
}
}
}
const EMPTY_RESPONSE_NUDGE: &str =
"Your response was empty. If you need a tool that isn't available, \
call enable_tools(group) to load it first, then call the tool. \
Otherwise, answer the question directly with text.";
pub(crate) fn run_turn_with_retry(
runtime: &mut ConversationRuntime<OllamaApiClient, SecretaryToolExecutor>,
input: &str,
prompter: Option<&mut dyn PermissionPrompter>,
) -> Result<TurnSummary, String> {
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(input));
match runtime.run_turn(input, prompter) {
Ok(summary) => return Ok(summary),
Err(e) => {
let msg = e.to_string();
if !msg.contains("no content") {
return Err(msg);
}
eprintln!(
" {} {}",
theme::dim("▸"),
theme::dim("empty response — retrying with enable_tools hint...")
);
}
}
runtime
.run_turn(EMPTY_RESPONSE_NUDGE, None)
.map_err(|e| e.to_string())
}
pub(crate) fn maybe_compact_session(
runtime: &mut ConversationRuntime<OllamaApiClient, SecretaryToolExecutor>,
telegram: bool,
) -> Option<usize> {
let estimated = estimate_session_tokens(runtime.session());
if estimated < compact_threshold() {
return None;
}
let result = compact_session(
runtime.session(),
CompactionConfig {
preserve_recent_messages: 4,
max_estimated_tokens: 0,
},
);
if result.removed_message_count == 0 {
return None;
}
let removed = result.removed_message_count;
*runtime = build_runtime_streaming(result.compacted_session, telegram);
Some(removed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ContentBlock, ConversationMessage, MessageRole};
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn temp_session_file(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join("claudette-test-sessions");
let _ = std::fs::create_dir_all(&dir);
dir.join(format!(
"{label}-{}-{}.json",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
))
}
#[test]
fn default_session_path_honors_env_var() {
let _guard = ENV_LOCK.lock().unwrap();
let path = temp_session_file("env-var");
let prev = std::env::var("CLAUDETTE_SESSION").ok();
std::env::set_var("CLAUDETTE_SESSION", &path);
let resolved = default_session_path();
assert_eq!(resolved, path);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_SESSION", v),
None => std::env::remove_var("CLAUDETTE_SESSION"),
}
}
#[test]
fn save_then_load_round_trip() {
let path = temp_session_file("round-trip");
let mut session = Session::default();
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: "remember this".to_string(),
}],
usage: None,
});
save_session_at(&session, &path).expect("save should succeed");
let loaded = try_load_session_at(&path)
.expect("load should not error")
.expect("session should be present");
assert_eq!(loaded.messages.len(), 1);
if let ContentBlock::Text { text } = &loaded.messages[0].blocks[0] {
assert_eq!(text, "remember this");
} else {
panic!("expected text block");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn try_load_returns_none_when_missing() {
let path = temp_session_file("missing");
let _ = std::fs::remove_file(&path); let result = try_load_session_at(&path).expect("missing file should not error");
assert!(result.is_none());
}
#[test]
fn compact_threshold_default_when_env_var_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD");
assert_eq!(compact_threshold(), DEFAULT_COMPACT_THRESHOLD);
if let Some(v) = prev {
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v);
}
}
#[test]
fn compact_threshold_honors_env_var() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", "12345");
assert_eq!(compact_threshold(), 12345);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD"),
}
}
#[test]
fn compact_threshold_falls_back_on_garbage() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", "not-a-number");
assert_eq!(compact_threshold(), DEFAULT_COMPACT_THRESHOLD);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD"),
}
}
#[test]
fn maybe_compact_session_no_op_when_under_threshold() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", "1000000");
let mut session = Session::default();
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: "tiny".to_string(),
}],
usage: None,
});
let messages_before = session.messages.len();
let mut runtime = build_runtime(session);
let result = maybe_compact_session(&mut runtime, false);
assert!(
result.is_none(),
"should not compact when session is under threshold"
);
assert_eq!(runtime.session().messages.len(), messages_before);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD"),
}
}
#[test]
fn maybe_compact_session_fires_when_over_threshold() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", "10");
let mut session = Session::default();
for i in 0..8 {
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: format!("turn {i} content padded long enough to register"),
}],
usage: None,
});
}
let mut runtime = build_runtime(session);
let messages_before = runtime.session().messages.len();
let result = maybe_compact_session(&mut runtime, false);
let removed = result.expect("expected compaction to fire");
assert!(removed > 0, "should remove at least one message");
assert!(runtime.session().messages.len() < messages_before);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD"),
}
}
#[test]
fn save_creates_parent_directory() {
let path = temp_session_file("nested")
.parent()
.unwrap()
.join("nested-subdir")
.join("session.json");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
save_session_at(&Session::default(), &path).expect("save should create parents");
assert!(path.exists());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
}