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::forge;
use crate::memory::try_load_memory;
use crate::model_config;
use crate::prompt::{
forge_planner_system_prompt, forge_system_prompt, forge_verifier_system_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)
}
#[must_use]
pub fn soft_compact_threshold() -> Option<usize> {
std::env::var("CLAUDETTE_SOFT_COMPACT_THRESHOLD")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|n| *n > 0)
}
const HARD_COMPACT_PRESERVE: usize = 4;
const SOFT_COMPACT_PRESERVE: usize = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactionTier {
Hard,
Soft,
}
impl CompactionTier {
pub(crate) const fn name(self) -> &'static str {
match self {
Self::Hard => "hard",
Self::Soft => "soft",
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CompactionOutcome {
pub removed: usize,
pub tier: CompactionTier,
pub threshold: usize,
}
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(outcome) = maybe_compact_session(&mut runtime, false) {
eprintln!(
"[auto-compacted {} older message(s) — {} tier @ {} tokens]",
outcome.removed,
outcome.tier.name(),
outcome.threshold,
);
}
if opts.autosave {
save_session(runtime.session())?;
}
Ok(summary)
}
const DEFAULT_MAX_FIX_ROUNDS: u32 = 2;
const FIX_ROUNDS_HARD_CAP: u32 = 10;
fn max_fix_rounds() -> u32 {
match std::env::var("CLAUDETTE_MAX_FIX_ROUNDS")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
{
Some(n) => n.min(FIX_ROUNDS_HARD_CAP),
None => DEFAULT_MAX_FIX_ROUNDS,
}
}
#[derive(Debug, Clone)]
pub(crate) struct VerifierResult {
pub score: u8,
pub pass: bool,
pub feedback: String,
}
pub fn run_forge_mission(user_input: &str, opts: SessionOptions) -> Result<TurnSummary> {
let mission = match crate::missions::active_mission() {
Some(m) => m,
None => match crate::missions::try_bootstrap_local_mission() {
Ok(m) => {
eprintln!(
"{} {} {}",
theme::BOLT,
theme::accent("forge: ephemeral mission"),
theme::dim(&m.path.display().to_string())
);
crate::missions::set_active(m.clone())
.map_err(|e| anyhow::anyhow!("set_active for ephemeral mission: {e}"))?;
m
}
Err(why) => {
return Err(anyhow::anyhow!(
"forge-mode requires an active brownfield mission, and could not \
auto-bootstrap one from the working directory ({why}). Either \
`cd` into a git repo under $HOME / CLAUDETTE_WORKSPACE, or run \
`/brownfield <owner/repo>` first to clone a target tree."
));
}
},
};
let mut cleanup = EphemeralMissionGuard::new(mission.ephemeral);
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 prompter = CliPrompter;
let mut prompter_opt: Option<&mut dyn PermissionPrompter> = Some(&mut prompter);
eprintln!("{} {}", theme::BOLT, theme::accent("forge: planner"));
let plan = run_planner(session.clone(), &mission, user_input, &mut prompter_opt)
.unwrap_or_else(|e| {
eprintln!(
" {} {}",
theme::dim("∘"),
theme::dim(&format!("planner skipped: {e}"))
);
String::new()
});
if !plan.trim().is_empty() {
eprintln!("{}", theme::dim(plan.trim()));
}
let augmented_input = if plan.trim().is_empty() {
user_input.to_string()
} else {
format!("Plan:\n{}\n\nTask: {user_input}", plan.trim())
};
let mut feedback: Option<String> = None;
let mut round: u32 = 0;
loop {
eprintln!(
"{} {} (round {})",
theme::BOLT,
theme::accent("forge: coder"),
round
);
let coder_input = match &feedback {
None => augmented_input.clone(),
Some(f) => format!(
"The Verifier rejected your previous attempt with this feedback:\n{f}\n\n\
Revise your work — add additional commits to the same branch as needed. \
Do NOT push or call mission_submit yet; the Verifier will review again.\n\n\
Original task: {user_input}"
),
};
let mut coder_runtime = build_forge_runtime(session.clone(), &mission, false);
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(&coder_input));
let _ = crate::brain_selector::run_turn_with_fallback(
&mut coder_runtime,
&coder_input,
&mut prompter_opt,
)
.map_err(|e| anyhow::anyhow!("forge coder turn failed (round {round}): {e}"))?;
eprintln!("{} {}", theme::BOLT, theme::accent("forge: verifier"));
let diff = capture_git_diff(&mission.path).unwrap_or_default();
let verifier = run_verifier(
session.clone(),
&mission,
user_input,
&diff,
&mut prompter_opt,
)
.unwrap_or_else(|e| {
eprintln!(
" {} {}",
theme::dim("∘"),
theme::dim(&format!("verifier skipped: {e}"))
);
VerifierResult {
score: 10,
pass: true,
feedback: String::new(),
}
});
let feedback_display: &str = if verifier.feedback.is_empty() {
"(no feedback)"
} else {
verifier.feedback.as_str()
};
eprintln!(
" {} {}",
theme::BOLT,
theme::info(&format!(
"score={} pass={} {feedback_display}",
verifier.score, verifier.pass,
))
);
if verifier.pass {
break;
}
if round >= max_fix_rounds() {
eprintln!(
" {} {}",
theme::dim("∘"),
theme::dim(&format!(
"verifier still failing after {round} round(s); submitting anyway"
))
);
break;
}
feedback = Some(verifier.feedback);
round += 1;
}
eprintln!("{} {}", theme::BOLT, theme::accent("forge: submit"));
let mut submit_runtime = build_forge_runtime(session, &mission, true);
let submit_input =
"All quality checks passed. Now call mission_submit with a short PR title that \
summarises the change. Do nothing else.";
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(submit_input));
let submit_summary = crate::brain_selector::run_turn_with_fallback(
&mut submit_runtime,
submit_input,
&mut prompter_opt,
)
.map_err(|e| anyhow::anyhow!("forge submitter turn failed: {e}"))?;
if let Some(outcome) = maybe_compact_session(&mut submit_runtime, false) {
eprintln!(
"[auto-compacted {} older message(s) — {} tier @ {} tokens]",
outcome.removed,
outcome.tier.name(),
outcome.threshold,
);
}
if opts.autosave {
save_session(submit_runtime.session())?;
}
cleanup.disarm();
Ok(submit_summary)
}
struct EphemeralMissionGuard {
armed: bool,
}
impl EphemeralMissionGuard {
fn new(ephemeral: bool) -> Self {
Self { armed: ephemeral }
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for EphemeralMissionGuard {
fn drop(&mut self) {
if self.armed {
let _ = crate::missions::clear_active();
}
}
}
fn capture_git_diff(mission_path: &std::path::Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["diff", "HEAD"])
.current_dir(mission_path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn run_planner(
session: Session,
mission: &crate::missions::Mission,
user_input: &str,
prompter: &mut Option<&mut dyn PermissionPrompter>,
) -> Result<String> {
let mut runtime = build_forge_role_runtime(
session,
mission,
forge::types::Role::Planner,
forge_planner_system_prompt(&mission.path.to_string_lossy()),
&[], );
let summary = crate::brain_selector::run_turn_with_fallback(&mut runtime, user_input, prompter)
.map_err(|e| anyhow::anyhow!("planner turn failed: {e}"))?;
Ok(extract_assistant_text(&summary))
}
fn run_verifier(
session: Session,
mission: &crate::missions::Mission,
user_input: &str,
diff: &str,
prompter: &mut Option<&mut dyn PermissionPrompter>,
) -> Result<VerifierResult> {
let mut runtime = build_forge_role_runtime(
session,
mission,
forge::types::Role::Verifier,
forge_verifier_system_prompt(&mission.path.to_string_lossy()),
&[],
);
let payload = format!(
"Original request: {user_input}\n\n--- git diff HEAD ---\n{diff}\n--- end diff ---"
);
let summary = crate::brain_selector::run_turn_with_fallback(&mut runtime, &payload, prompter)
.map_err(|e| anyhow::anyhow!("verifier turn failed: {e}"))?;
let text = extract_assistant_text(&summary);
Ok(parse_verifier_response(&text))
}
fn extract_assistant_text(summary: &TurnSummary) -> String {
use crate::ContentBlock;
let mut out = String::new();
for msg in &summary.assistant_messages {
for block in &msg.blocks {
if let ContentBlock::Text { text } = block {
if !out.is_empty() {
out.push('\n');
}
out.push_str(text);
}
}
}
out
}
fn parse_verifier_response(text: &str) -> VerifierResult {
let default = VerifierResult {
score: 10,
pass: true,
feedback: String::new(),
};
let trimmed = text.trim();
let stripped = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.map_or(trimmed, |s| s.trim_start())
.strip_suffix("```")
.map_or(trimmed, |s| s.trim_end());
let Some(start) = stripped.find('{') else {
return default;
};
let Some(end) = stripped.rfind('}') else {
return default;
};
if end <= start {
return default;
}
let json_slice = &stripped[start..=end];
let Ok(v) = serde_json::from_str::<serde_json::Value>(json_slice) else {
return default;
};
let score = v
.get("score")
.and_then(serde_json::Value::as_u64)
.map_or(10, |n| n.clamp(0, 10) as u8);
let pass = v
.get("pass")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
let feedback = v
.get("feedback")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
VerifierResult {
score,
pass,
feedback,
}
}
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()))
);
probe_recall_at_startup();
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) {
let stderr = std::io::stderr();
let mut err = stderr.lock();
let rebuild = |s: Session| build_runtime_streaming(s, false);
match dispatch_slash_command(cmd, &mut runtime, &state, &mut err, &rebuild) {
SlashOutcome::Continue => continue,
SlashOutcome::Exit => break,
}
}
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(trimmed));
let extracted = crate::image_attach::extract_image_attachments_from_input(trimmed);
if extracted.extension_matches > 0 && extracted.attached.is_empty() {
if let Some(reason) = &extracted.first_failure {
eprintln!(
"{} {}",
theme::WARN_GLYPH,
theme::warn(&format!(
"image-path detected but couldn't attach: {reason}"
))
);
}
}
let turn_result: Result<TurnSummary, String> = if extracted.attached.is_empty() {
let mut prompter_opt: Option<&mut dyn PermissionPrompter> = Some(&mut prompter);
crate::brain_selector::run_turn_with_fallback(&mut runtime, trimmed, &mut prompter_opt)
} else {
let count = extracted.attached.len();
eprintln!(
"{} {}",
theme::SAVE,
theme::dim(&format!("📎 attached {count} image(s) — routing to vision"))
);
let images: Vec<(String, String)> = extracted
.attached
.into_iter()
.map(|a| (a.media_type, a.data_b64))
.collect();
runtime
.run_turn_with_images(trimmed, images, Some(&mut prompter))
.map_err(|e| e.to_string())
};
match turn_result {
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 recall_index_allowed() {
index_turn_for_recall(trimmed, &runtime);
}
}
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("turn failed: {e}"))
);
}
}
if let Some(outcome) = maybe_compact_session(&mut runtime, false) {
eprintln!(
"{} {}",
theme::SAVE,
theme::ok(&format!(
"auto-compacted {} older message(s) — {} tier crossed at {} tokens",
outcome.removed,
outcome.tier.name(),
outcome.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:#}"))
);
}
}
}
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 reg = ToolRegistry::new();
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)
})
})
}
fn build_forge_runtime(
session: Session,
mission: &crate::missions::Mission,
should_submit: bool,
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
let persona = forge_default_coder_persona();
let memory = try_load_memory();
let persona_overlay = persona
.as_ref()
.map(|p| (p.voice.as_str(), p.backstory.as_str()));
let system = forge_system_prompt(
&mission.path.to_string_lossy(),
memory.as_deref(),
persona_overlay,
should_submit,
);
build_forge_role_runtime(
session,
mission,
forge::types::Role::Coder,
system,
&[
ToolGroup::Files,
ToolGroup::Search,
ToolGroup::Git,
ToolGroup::Advanced,
ToolGroup::Github,
],
)
}
fn build_forge_role_runtime(
session: Session,
_mission: &crate::missions::Mission,
role: forge::types::Role,
system_prompt: Vec<String>,
tool_groups: &[ToolGroup],
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
let mut brain = model_config::active().brain;
if let Some(role_model) = forge_role_model(role) {
brain.model = role_model;
}
let mut reg = ToolRegistry::new();
for group in tool_groups {
reg.enable(*group);
}
let registry = Arc::new(Mutex::new(reg));
let api_client = OllamaApiClient::with_registry(brain.model.clone(), registry.clone())
.with_context(brain.num_ctx)
.with_max_predict(brain.num_predict)
.with_text_callback(stdout_text_callback());
let hinter_registry = Arc::clone(®istry);
let executor = SecretaryToolExecutor::with_registry(registry);
let policy = build_permission_policy();
ConversationRuntime::new(session, api_client, executor, policy, system_prompt)
.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)
})
})
}
fn forge_role_model(role: forge::types::Role) -> Option<String> {
forge::types::ModelMap::load()
.ok()?
.resolve(role)
.map(|(_, name)| name.to_string())
}
fn forge_default_coder_persona() -> Option<forge::personas::Persona> {
const CODEX7: &str = include_str!("../personas/codex7.md");
forge::personas::parse_persona_content(CODEX7, "bundled:codex7").ok()
}
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("load_workspace_rules", 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_list_repo_issues", ReadOnly)
.with_tool_requirement("gh_pr_status", ReadOnly)
.with_tool_requirement("gh_create_issue", WorkspaceWrite)
.with_tool_requirement("gh_comment_issue", WorkspaceWrite)
.with_tool_requirement("gh_fork", WorkspaceWrite)
.with_tool_requirement("gh_create_pr", 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("calendar_list_events", ReadOnly)
.with_tool_requirement("calendar_create_event", WorkspaceWrite)
.with_tool_requirement("calendar_update_event", WorkspaceWrite)
.with_tool_requirement("calendar_respond_to_event", WorkspaceWrite)
.with_tool_requirement("calendar_delete_event", DangerFullAccess)
.with_tool_requirement("gmail_list", ReadOnly)
.with_tool_requirement("gmail_search", ReadOnly)
.with_tool_requirement("gmail_read", ReadOnly)
.with_tool_requirement("gmail_list_labels", ReadOnly)
.with_tool_requirement("schedule_list", ReadOnly)
.with_tool_requirement("schedule_once", WorkspaceWrite)
.with_tool_requirement("schedule_recurring", WorkspaceWrite)
.with_tool_requirement("schedule_cancel", WorkspaceWrite)
.with_tool_requirement("recall", ReadOnly)
.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)
.with_tool_requirement("git_clone", WorkspaceWrite)
.with_tool_requirement("mission_start", WorkspaceWrite)
.with_tool_requirement("mission_status", ReadOnly)
.with_tool_requirement("mission_list", ReadOnly)
.with_tool_requirement("mission_attach", ReadOnly)
.with_tool_requirement("mission_exit", WorkspaceWrite)
.with_tool_requirement("mission_submit", DangerFullAccess)
}
pub(crate) fn recall_disabled() -> bool {
matches!(
std::env::var("CLAUDETTE_RECALL_DISABLE").as_deref(),
Ok("1")
)
}
static RECALL_INDEX_BROKEN: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub(crate) fn recall_index_allowed() -> bool {
!recall_disabled() && !RECALL_INDEX_BROKEN.load(std::sync::atomic::Ordering::Relaxed)
}
pub(crate) fn mark_recall_index_broken() {
RECALL_INDEX_BROKEN.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub fn reprobe_recall() -> Result<(), String> {
RECALL_INDEX_BROKEN.store(false, std::sync::atomic::Ordering::Relaxed);
crate::recall::probe()
}
pub(crate) fn probe_recall_at_startup() {
if recall_disabled() {
return;
}
if let Err(e) = crate::recall::probe() {
mark_recall_index_broken();
eprintln!(
"{} {}",
theme::warn(theme::WARN_GLYPH),
theme::warn(&format!(
"recall: probe failed — {e}. Indexing disabled for this session \
(load an embed model and restart, or set CLAUDETTE_RECALL_DISABLE=1 to silence)."
))
);
}
}
fn extract_turn_snippets<C, T>(
user_input: &str,
runtime: &ConversationRuntime<C, T>,
) -> (String, String)
where
C: crate::ApiClient,
T: crate::ToolExecutor,
{
use crate::ContentBlock;
let user_text = user_input.trim().to_string();
let mut asst_text = String::new();
if let Some(msg) = runtime
.session()
.messages
.iter()
.rev()
.find(|m| matches!(m.role, crate::MessageRole::Assistant))
{
for block in &msg.blocks {
if let ContentBlock::Text { text: t } = block {
if !asst_text.is_empty() {
asst_text.push('\n');
}
asst_text.push_str(t);
}
}
}
(user_text, asst_text)
}
struct IndexJob {
role: crate::recall::Role,
snippet: String,
}
fn recall_index_sender() -> &'static std::sync::mpsc::Sender<IndexJob> {
use std::sync::OnceLock;
static SENDER: OnceLock<std::sync::mpsc::Sender<IndexJob>> = OnceLock::new();
SENDER.get_or_init(|| {
let (tx, rx) = std::sync::mpsc::channel::<IndexJob>();
std::thread::Builder::new()
.name("recall-indexer".to_string())
.spawn(move || {
while let Ok(job) = rx.recv() {
if !recall_index_allowed() {
continue;
}
if job.snippet.trim().is_empty() {
continue;
}
if let Err(e) = crate::recall::global_index(job.role, &job.snippet) {
mark_recall_index_broken();
eprintln!(
"{} {}",
theme::warn(theme::WARN_GLYPH),
theme::warn(&format!(
"recall: {e} — disabling recall indexing for this session \
(run /recall reprobe to retry after loading the embed model)"
))
);
}
}
})
.expect("spawn recall-indexer thread");
tx
})
}
pub(crate) fn index_turn_for_recall<C, T>(user_input: &str, runtime: &ConversationRuntime<C, T>)
where
C: crate::ApiClient,
T: crate::ToolExecutor,
{
let (user_text, asst_text) = extract_turn_snippets(user_input, runtime);
let tx = recall_index_sender();
if !user_text.is_empty() {
let _ = tx.send(IndexJob {
role: crate::recall::Role::User,
snippet: user_text,
});
}
if !asst_text.trim().is_empty() {
let _ = tx.send(IndexJob {
role: crate::recall::Role::Assistant,
snippet: asst_text,
});
}
}
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));
crate::codet::drain_pending_coder_lease();
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<CompactionOutcome> {
let estimated = estimate_session_tokens(runtime.session());
let hard = compact_threshold();
let soft = soft_compact_threshold();
let (tier, preserve, threshold) = pick_compact_plan(estimated, hard, soft)?;
let result = compact_session(
runtime.session(),
CompactionConfig {
preserve_recent_messages: preserve,
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(CompactionOutcome {
removed,
tier,
threshold,
})
}
#[must_use]
pub(crate) fn pick_compact_plan(
estimated: usize,
hard_threshold: usize,
soft_threshold: Option<usize>,
) -> Option<(CompactionTier, usize, usize)> {
if estimated >= hard_threshold {
return Some((CompactionTier::Hard, HARD_COMPACT_PRESERVE, hard_threshold));
}
if let Some(soft) = soft_threshold {
if estimated >= soft {
return Some((CompactionTier::Soft, SOFT_COMPACT_PRESERVE, soft));
}
}
None
}
#[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 outcome = result.expect("expected compaction to fire");
assert!(outcome.removed > 0, "should remove at least one message");
assert_eq!(
outcome.tier,
CompactionTier::Hard,
"10-token hard threshold should fire the hard tier"
);
assert_eq!(outcome.threshold, 10);
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());
}
#[test]
fn pick_compact_plan_returns_none_below_both_thresholds() {
assert_eq!(pick_compact_plan(50_000, 1_000_000, Some(200_000)), None);
assert_eq!(pick_compact_plan(50_000, 1_000_000, None), None);
}
#[test]
fn pick_compact_plan_returns_soft_when_only_soft_crossed() {
assert_eq!(
pick_compact_plan(250_000, 1_000_000, Some(200_000)),
Some((CompactionTier::Soft, SOFT_COMPACT_PRESERVE, 200_000))
);
}
#[test]
fn pick_compact_plan_returns_hard_when_hard_crossed() {
assert_eq!(
pick_compact_plan(1_500_000, 1_000_000, Some(200_000)),
Some((CompactionTier::Hard, HARD_COMPACT_PRESERVE, 1_000_000))
);
}
#[test]
fn pick_compact_plan_prefers_hard_over_soft_when_both_crossed() {
assert_eq!(
pick_compact_plan(2_000_000, 1_000_000, Some(200_000)),
Some((CompactionTier::Hard, HARD_COMPACT_PRESERVE, 1_000_000))
);
}
#[test]
fn pick_compact_plan_skips_soft_when_threshold_unset() {
assert_eq!(pick_compact_plan(500_000, 1_000_000, None), None);
}
#[test]
fn compaction_tier_names_are_lowercase_for_logs() {
assert_eq!(CompactionTier::Soft.name(), "soft");
assert_eq!(CompactionTier::Hard.name(), "hard");
}
#[test]
fn soft_compact_threshold_returns_none_when_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_SOFT_COMPACT_THRESHOLD").ok();
std::env::remove_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD");
assert_eq!(soft_compact_threshold(), None);
if let Some(v) = prev {
std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", v);
}
}
#[test]
fn soft_compact_threshold_returns_some_when_set() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_SOFT_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", "200000");
assert_eq!(soft_compact_threshold(), Some(200_000));
match prev {
Some(v) => std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD"),
}
}
#[test]
fn soft_compact_threshold_treats_zero_as_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_SOFT_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", "0");
assert_eq!(soft_compact_threshold(), None);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD"),
}
}
#[test]
fn every_advertised_tool_has_permission_requirement() {
let policy = build_permission_policy();
let full = crate::tools::secretary_tools_json();
let arr = full.as_array().cloned().unwrap_or_default();
let mut missing: Vec<String> = Vec::new();
for tool in arr {
let Some(name) = tool
.pointer("/function/name")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
else {
continue;
};
if !policy.is_known(&name) {
missing.push(name);
}
}
assert!(
missing.is_empty(),
"tool(s) advertised but not registered in build_permission_policy() — \
will be swallowed by the unknown-tool short-circuit and never reach \
the dispatcher: {missing:?}. Add a `.with_tool_requirement(name, ...)` \
entry."
);
}
#[test]
fn high_blast_radius_tools_require_danger_tier() {
let policy = build_permission_policy();
let cases: &[(&str, &str)] =
&[("mission_submit", "calls git_push + gh_create_pr internally")];
for (name, why) in cases {
let actual = policy.required_mode_for(name);
assert_eq!(
actual,
PermissionMode::DangerFullAccess,
"{name} must be DangerFullAccess: {why}; got {actual:?}"
);
}
}
#[test]
fn forge_default_coder_persona_parses_bundled_codex7() {
let p = forge_default_coder_persona().expect("bundled codex7 must parse");
assert_eq!(p.name, "CodeX-7");
assert_eq!(p.role, forge::types::Role::Coder);
assert!(!p.voice.is_empty(), "codex7 should have a voice");
assert!(!p.backstory.is_empty(), "codex7 should have backstory");
}
#[test]
fn forge_role_model_returns_a_default_for_each_role() {
for role in [
forge::types::Role::Coder,
forge::types::Role::Planner,
forge::types::Role::Verifier,
] {
let model = forge_role_model(role)
.unwrap_or_else(|| panic!("forge default model for {role:?}"));
assert!(!model.is_empty(), "{role:?} model name must be non-empty");
}
}
#[test]
fn verifier_parses_clean_json() {
let r = parse_verifier_response(r#"{"score": 9, "pass": true, "feedback": "looks good"}"#);
assert_eq!(r.score, 9);
assert!(r.pass);
assert_eq!(r.feedback, "looks good");
}
#[test]
fn verifier_parses_json_in_code_fence() {
let r = parse_verifier_response(
"```json\n{\"score\": 5, \"pass\": false, \"feedback\": \"missing tests\"}\n```",
);
assert_eq!(r.score, 5);
assert!(!r.pass);
assert_eq!(r.feedback, "missing tests");
}
#[test]
fn verifier_parses_json_with_trailing_prose() {
let r = parse_verifier_response(
"Here is my evaluation:\n{\"score\": 7, \"pass\": true, \"feedback\": \"ok\"}\nDone.",
);
assert_eq!(r.score, 7);
assert!(r.pass);
}
#[test]
fn verifier_unparseable_falls_through_to_pass() {
let r = parse_verifier_response("I don't know how to format JSON");
assert!(r.pass);
assert_eq!(r.score, 10);
assert!(r.feedback.is_empty());
}
#[test]
fn verifier_clamps_out_of_range_scores() {
let r = parse_verifier_response(r#"{"score": 42, "pass": false, "feedback": "x"}"#);
assert_eq!(r.score, 10);
assert!(!r.pass);
}
#[test]
fn verifier_missing_fields_use_permissive_defaults() {
let r = parse_verifier_response(r#"{"score": 6}"#);
assert_eq!(r.score, 6);
assert!(r.pass);
assert!(r.feedback.is_empty());
}
}