Enhance tool documentation and add new features
- Added module-level documentation for memory tools (`remember`, `recall`, `forget`) to clarify their purpose. - Improved documentation in `recall.rs` and `remember.rs` to describe the functionality and flow of memory entry operations. - Updated `mod.rs` to include descriptions for the tool trait and execution context. - Enhanced `plan.rs` with detailed comments on plan-mode signaling tools. - Documented text search tools in `search.rs` to explain their functionality. - Improved sequential-thinking tool documentation in `seqthink.rs`. - Added safety filter documentation in `shell_filter` for credential and git operations. - Enhanced utility tools documentation, including `cd`, `dir_cache_update`, and `todowrite`. - Improved rendering documentation in view modules (`chat`, `markdown`, `status`, `workflow`) to clarify rendering flows and purposes.
This commit is contained in:
@@ -1,14 +1,41 @@
|
||||
//! Global registry of running background bash jobs, and control operations
|
||||
//! (output polling, kill) exposed to the rest of the app.
|
||||
//!
|
||||
//! Flow: a process-wide `Mutex<HashMap<String, BashJob>>` (lazily built via
|
||||
//! `OnceLock`) holds every job spawned via `bgbash::job::spawn_bash_job` →
|
||||
//! `bash_output` drains new lines for a given job id → `bash_kill` removes
|
||||
//! a job from the map and signals its child process.
|
||||
//!
|
||||
//! Why: a single static map (rather than storing jobs in `AppStateRest`)
|
||||
//! lets background jobs outlive the borrow of any particular state mutation
|
||||
//! and be looked up by id from tool calls issued at arbitrary points.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::job::BashJob;
|
||||
|
||||
/// Lazily-initialised, process-wide registry of background bash jobs keyed
|
||||
/// by job id.
|
||||
///
|
||||
/// Return: a reference to the static `Mutex<HashMap<...>>`, created on
|
||||
/// first access.
|
||||
pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> {
|
||||
static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new();
|
||||
JOBS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Drain any newly available output lines from a background bash job.
|
||||
///
|
||||
/// Flow: look up the job by id → repeatedly call `try_read_line()` until it
|
||||
/// returns `None` → collect into a Vec.
|
||||
///
|
||||
/// Why: non-blocking; a job that hasn't produced new output yields no lines
|
||||
/// rather than blocking the caller.
|
||||
///
|
||||
/// Return: `Some(lines)` if at least one new line was read, `None` if the
|
||||
/// job doesn't exist, the lock is poisoned, or there was nothing new to read.
|
||||
pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||
let mut map = bash_jobs_map().lock().ok()?;
|
||||
let job = map.get_mut(id)?;
|
||||
@@ -19,6 +46,16 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||
if lines.is_empty() { None } else { Some(lines) }
|
||||
}
|
||||
|
||||
/// Terminate a running background bash job and remove it from the registry.
|
||||
///
|
||||
/// Flow: remove the job from the map → if it has a valid child PID, send
|
||||
/// `SIGTERM` to it (unix only) → return.
|
||||
///
|
||||
/// Why: removing from the map first means a concurrent lookup can no longer
|
||||
/// see the job even if the signal delivery is delayed.
|
||||
///
|
||||
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
|
||||
/// with that id exists.
|
||||
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
||||
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
|
||||
let job = map.remove(id);
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
//! Background bash job spawning and non-blocking output polling.
|
||||
//!
|
||||
//! Flow: `spawn_bash_job` forks a detached OS thread that execs the command
|
||||
//! via `sh -c`, streams stdout lines back over an `mpsc` channel, and sends
|
||||
//! an `__exit:<code>` sentinel when the child terminates → callers poll the
|
||||
//! returned `BashJob` with `try_read_line()` to drain output without
|
||||
//! blocking the TUI event loop.
|
||||
//!
|
||||
//! Why: running bash commands on a detached thread with a channel (rather
|
||||
//! than synchronously) lets the TUI stay responsive while long-running
|
||||
//! shell commands execute in the background.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::io::BufRead;
|
||||
|
||||
/// Handle to a bash command running in a detached background thread.
|
||||
///
|
||||
/// Why: output is streamed over an mpsc channel rather than buffered
|
||||
/// synchronously, so the TUI can poll for new lines without blocking.
|
||||
pub struct BashJob {
|
||||
pub id: String,
|
||||
pub child_pid: u32,
|
||||
@@ -10,6 +26,21 @@ pub struct BashJob {
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Spawn a shell command in a background thread and return a handle to it.
|
||||
///
|
||||
/// Flow: spawn a thread → thread execs `sh -c <command>` with piped
|
||||
/// stdout/stderr → thread sends the child PID back over a channel →
|
||||
/// thread streams stdout lines to `output_tx` → on exit, sends an
|
||||
/// `__exit:<code>` sentinel line.
|
||||
///
|
||||
/// Why: the PID is sent back before the command finishes so `bash_kill` can
|
||||
/// terminate it mid-run; sentinel-prefixed strings (`__error:`, `__exit:`)
|
||||
/// let `try_read_line` distinguish control messages from real output on the
|
||||
/// same channel without a separate enum.
|
||||
///
|
||||
/// Return: a `BashJob` with a freshly generated id, the child PID (0 if the
|
||||
/// spawn failed before the PID was sent), and the receiving end of the
|
||||
/// output channel.
|
||||
pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let (output_tx, output_rx) = mpsc::channel::<String>();
|
||||
@@ -57,6 +88,15 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
}
|
||||
|
||||
impl BashJob {
|
||||
/// Non-blocking poll for the next output line from the job's channel.
|
||||
///
|
||||
/// Flow: try_recv the channel → if it's an `__exit:<code>` sentinel,
|
||||
/// record `exit_code` and return `None` instead of surfacing it as
|
||||
/// output → otherwise return the line.
|
||||
///
|
||||
/// Return: `Some(line)` for real output, `None` if there's nothing
|
||||
/// available yet or the job just finished (exit code recorded as a
|
||||
/// side effect).
|
||||
pub fn try_read_line(&mut self) -> Option<String> {
|
||||
match self.output_rx.try_recv() {
|
||||
Ok(line) => {
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
//! Background bash: run shell commands off the main thread, poll their
|
||||
//! output non-blockingly, and terminate them on demand.
|
||||
|
||||
pub mod control;
|
||||
pub mod job;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
//! Tool-call gating: decides whether a risky tool call is allowed to run
|
||||
//! before it executes.
|
||||
|
||||
/// Outcome of gating a tool call: whether it's allowed to run.
|
||||
///
|
||||
/// Why: `Block` carries a reason string for surfacing to the user/log, even
|
||||
/// though nothing currently produces `Block` (classify() always allows).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Verdict {
|
||||
Allow,
|
||||
@@ -5,9 +12,20 @@ pub enum Verdict {
|
||||
Block(String),
|
||||
}
|
||||
|
||||
/// Gatekeeper that decides whether a tool call may proceed before execution.
|
||||
pub struct Harness;
|
||||
|
||||
impl Harness {
|
||||
/// Decide whether a tool call is allowed to execute.
|
||||
///
|
||||
/// Flow: if the tool isn't flagged risky, allow immediately → otherwise
|
||||
/// defer to `classify`.
|
||||
///
|
||||
/// Why: `_args` and `_workspace_roots` are accepted for a future
|
||||
/// content-aware classifier but currently unused — `classify` is a
|
||||
/// stub that always allows.
|
||||
///
|
||||
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
|
||||
pub fn gate_tool_call(
|
||||
tool_name: &str,
|
||||
_args: &serde_json::Value,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! MCP server connection management: spawning/talking to stdio child
|
||||
//! processes and HTTP endpoints, and adapting their advertised tools to
|
||||
//! the crate's `Tool` trait.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
@@ -21,6 +25,8 @@ fn mcp_static_str(s: &str) -> &'static str {
|
||||
leaked
|
||||
}
|
||||
|
||||
/// How an MCP server is reached: a spawned child process talking
|
||||
/// newline-delimited JSON-RPC over stdio, or a remote HTTP endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum McpTransport {
|
||||
Stdio {
|
||||
@@ -32,6 +38,7 @@ pub enum McpTransport {
|
||||
},
|
||||
}
|
||||
|
||||
/// A single tool advertised by an MCP server, as returned by `tools/list`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolInfo {
|
||||
pub name: String,
|
||||
@@ -39,6 +46,8 @@ pub struct McpToolInfo {
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// A connected MCP server: its transport, advertised tools, and (for stdio)
|
||||
/// a live handle to the child process.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServer {
|
||||
pub name: String,
|
||||
@@ -51,6 +60,8 @@ pub struct McpServer {
|
||||
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
|
||||
}
|
||||
|
||||
/// Live handle to an MCP server child process communicating over stdio
|
||||
/// via newline-delimited JSON-RPC 2.0.
|
||||
#[derive(Debug)]
|
||||
pub struct StdioChild {
|
||||
stdin: std::process::ChildStdin,
|
||||
@@ -59,6 +70,18 @@ pub struct StdioChild {
|
||||
}
|
||||
|
||||
impl StdioChild {
|
||||
/// Send a JSON-RPC request to the child and block for its matching response.
|
||||
///
|
||||
/// Flow: assign the next request id → write request + newline to stdin →
|
||||
/// loop reading lines from stdout until one has a matching `id` or the
|
||||
/// timeout elapses → return its `result` (or error out on an `error` field).
|
||||
///
|
||||
/// Why: the child may interleave unrelated/malformed lines, so blank
|
||||
/// lines are skipped and non-matching ids are ignored rather than
|
||||
/// treated as a protocol violation.
|
||||
///
|
||||
/// Return: the `result` value of the matching response, or `Err` on
|
||||
/// timeout, EOF, JSON-RPC error, or I/O failure.
|
||||
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
@@ -255,11 +278,14 @@ fn extract_text_content(result: &Value) -> anyhow::Result<String> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Registry of connected MCP servers and their tools for the current session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpManager {
|
||||
pub servers: Vec<McpServer>,
|
||||
}
|
||||
|
||||
/// Adapts a single MCP-advertised tool to the crate's `Tool` trait so it can
|
||||
/// be dispatched through the same execution path as built-in tools.
|
||||
pub struct McpToolAdapter {
|
||||
pub tool_name: String,
|
||||
pub server_name: String,
|
||||
@@ -296,12 +322,22 @@ impl crate::tool::Tool for McpToolAdapter {
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
/// Create an empty manager with no connected servers.
|
||||
pub fn new() -> Self {
|
||||
McpManager {
|
||||
servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten all connected servers' tools into a single list of `Tool` trait objects.
|
||||
///
|
||||
/// Flow: for each server, clone its child handle → wrap each of its
|
||||
/// `McpToolInfo` entries in an `McpToolAdapter` sharing that handle.
|
||||
///
|
||||
/// Why: the handle is cloned (Arc) per tool so every adapter for a given
|
||||
/// stdio server reuses the same persistent child process/connection.
|
||||
///
|
||||
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
|
||||
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
||||
self.servers.iter().flat_map(|server| {
|
||||
let handle = server.child_handle.clone();
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
//! Model Context Protocol (MCP) client: connects to external MCP servers
|
||||
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
|
||||
|
||||
pub mod manager;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Top-level application module: harness, modes, runtime loop, state,
|
||||
//! workflows, subagents, review, background bash, and MCP integration.
|
||||
pub mod harness;
|
||||
pub mod mode;
|
||||
pub mod runtime;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
//! Bash mode: handles submitting a shell command from the bash input panel.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Launch a background bash job for the submitted command.
|
||||
///
|
||||
/// Flow: ignore empty input → spawn the job (fire-and-forget, the job's
|
||||
/// output is polled elsewhere via `bgbash::control`) → mark state dirty
|
||||
/// so the TUI re-renders.
|
||||
///
|
||||
/// Why: the returned `BashJob` handle is intentionally dropped — this
|
||||
/// function only needs to kick the job off; the job registers itself in
|
||||
/// the shared jobs map for later polling.
|
||||
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
||||
if !command.is_empty() {
|
||||
let _ = crate::app::bgbash::job::spawn_bash_job(command);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
|
||||
//! with bounded undo history.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
/// State for the built-in line editor overlay: buffer contents, cursor
|
||||
/// position, and a bounded undo stack.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
pub path: String,
|
||||
@@ -23,6 +28,8 @@ impl Default for EditorState {
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Create a fresh editor state for `path`, seeded with existing content
|
||||
/// (or a single empty line for a new file).
|
||||
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
|
||||
let content = existing_content.unwrap_or_else(|| vec![String::new()]);
|
||||
EditorState {
|
||||
@@ -32,12 +39,20 @@ impl EditorState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a new empty line immediately after the cursor line.
|
||||
///
|
||||
/// Why: snapshots content to the undo stack first, matching every other
|
||||
/// mutating method here.
|
||||
pub fn insert_line_after(&mut self) {
|
||||
self.save_undo();
|
||||
let pos = (self.cursor_line + 1).min(self.content.len());
|
||||
self.content.insert(pos, String::new());
|
||||
}
|
||||
|
||||
/// Push a snapshot of the current content onto the undo stack, capped at 50 entries.
|
||||
///
|
||||
/// Why: `remove(0)` on overflow bounds memory use at the cost of O(n)
|
||||
/// shifting; the cap (50) keeps that cost negligible in practice.
|
||||
fn save_undo(&mut self) {
|
||||
self.undo_stack.push(self.content.clone());
|
||||
if self.undo_stack.len() > 50 {
|
||||
@@ -45,6 +60,7 @@ impl EditorState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor down one line, clamping the column to the new line's length.
|
||||
pub fn cursor_down(&mut self) {
|
||||
if self.cursor_line + 1 < self.content.len() {
|
||||
self.cursor_line += 1;
|
||||
@@ -54,6 +70,7 @@ impl EditorState {
|
||||
);
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor and advance the cursor past it.
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
self.save_undo();
|
||||
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
||||
@@ -62,6 +79,11 @@ impl EditorState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor (backspace).
|
||||
///
|
||||
/// Flow: if not at column 0, remove the preceding char on this line →
|
||||
/// otherwise (start of line, not the first line) merge this line into
|
||||
/// the previous one, joining at the old line's end.
|
||||
pub fn delete_left(&mut self) {
|
||||
self.save_undo();
|
||||
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
||||
@@ -78,11 +100,18 @@ impl EditorState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Join all lines with `\n` into the full file contents, for saving.
|
||||
pub fn as_string(&self) -> String {
|
||||
self.content.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a chunk of typed text into the active editor, translating newlines
|
||||
/// and tabs into editor operations.
|
||||
///
|
||||
/// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a
|
||||
/// line and moves down, `\t` inserts two spaces, everything else inserts
|
||||
/// the char directly → mark state dirty.
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
|
||||
let editor = &mut state.misc.editor;
|
||||
if editor.is_none() {
|
||||
@@ -108,6 +137,7 @@ pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
/// Close the editor overlay without saving, clearing editor state.
|
||||
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
|
||||
state.misc.editor = None;
|
||||
state.misc.overlay = Overlay::None;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Effort mode: cycles the agent's reasoning effort level, which scales the
|
||||
//! LLM's temperature and max_tokens for subsequent turns.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||
@@ -17,15 +20,24 @@ pub fn generation_params(level: usize, base_max_tokens: u32) -> (f32, u32) {
|
||||
(temperature, max_tokens.max(256))
|
||||
}
|
||||
|
||||
/// Return the current effort level index, clamped to a valid `EFFORT_LEVELS` slot.
|
||||
///
|
||||
/// Why: clamping guards against a stale/out-of-range value in loaded state
|
||||
/// (e.g. after `EFFORT_LEVELS` shrinks between versions).
|
||||
pub fn current_effort(state: &AppStateRest) -> usize {
|
||||
state.misc.effort_level.min(EFFORT_LEVELS.len() - 1)
|
||||
}
|
||||
|
||||
/// Return the current effort level's display name (e.g. "medium").
|
||||
pub fn current_effort_str(state: &AppStateRest) -> &'static str {
|
||||
let idx = current_effort(state);
|
||||
EFFORT_LEVELS[idx]
|
||||
}
|
||||
|
||||
/// Advance to the next effort level, wrapping around, and toast the new value.
|
||||
///
|
||||
/// Flow: compute `(current + 1) % len` → store it → push an info toast with
|
||||
/// the new level's label → mark state dirty.
|
||||
pub fn cycle_effort(state: &mut AppStateRest) {
|
||||
let current = current_effort(state);
|
||||
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Help mode: static help text and the action that opens/closes the help overlay.
|
||||
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
@@ -27,6 +29,12 @@ Slash commands:
|
||||
/lesson import Import lessons
|
||||
/clear Clear transcript";
|
||||
|
||||
/// Route an incoming action while the help overlay is open.
|
||||
///
|
||||
/// Flow: `CloseOverlay` passes through unchanged; any other action is
|
||||
/// treated as "open help" (idempotent — re-opens the overlay it's already on).
|
||||
///
|
||||
/// Return: the `Action` to actually dispatch.
|
||||
pub fn handle_help_action(action: &Action) -> Action {
|
||||
match action {
|
||||
Action::CloseOverlay => Action::CloseOverlay,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Replace the input buffer with the given text and mark state dirty.
|
||||
pub fn handle_key_text(state: &mut AppStateRest, text: String) {
|
||||
state.input.buffer = text;
|
||||
state.dirty = true;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Loading mode: transient overlay shown while waiting on an async operation.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const LOADING_MESSAGES: &[&str] = &[
|
||||
@@ -7,6 +9,7 @@ pub const LOADING_MESSAGES: &[&str] = &[
|
||||
"almost done...",
|
||||
];
|
||||
|
||||
/// Mark state dirty to force a re-render (e.g. to advance the loading spinner/message).
|
||||
pub fn resolve_loading(state: &mut AppStateRest) {
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
//! MCP mode: overlay for connecting to a configured MCP server.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Placeholder entry point for connecting to an MCP server by name.
|
||||
///
|
||||
/// Why: not yet wired to `McpManager::connect_stdio` — currently just
|
||||
/// marks state dirty so the overlay re-renders.
|
||||
pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
|
||||
let _ = server_name;
|
||||
state.dirty = true;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! TUI mode definitions and per-mode input/action handlers, one submodule
|
||||
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod bash;
|
||||
@@ -11,6 +14,8 @@ pub mod rewind;
|
||||
pub mod settings;
|
||||
pub mod todo;
|
||||
|
||||
/// Which input/overlay mode the TUI is currently in; drives both key
|
||||
/// routing (`controller/input.rs`) and rendering.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ModeKind {
|
||||
Chat,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
|
||||
|
||||
use crate::app::runtime::actions::Action;
|
||||
|
||||
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
|
||||
///
|
||||
/// Return: `Action::ForceQuit` if confirmed, otherwise `Action::CloseOverlay`
|
||||
/// to dismiss the prompt without quitting.
|
||||
pub fn handle_quit_confirm(yes: bool) -> Action {
|
||||
if yes {
|
||||
Action::ForceQuit
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
|
||||
//! session's SQLite blob store.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use sha2::Digest;
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
//! Settings-mode helper logic for the TUI settings overlay.
|
||||
//!
|
||||
//! Flow: exposes small mutation functions (currently just cycling the
|
||||
//! internet access mode) invoked by keybindings while the settings overlay
|
||||
//! is active.
|
||||
|
||||
use crate::model::settings::{Settings, InternetMode};
|
||||
|
||||
/// Advance the internet access mode to the next value in the cycle.
|
||||
///
|
||||
/// Flow: Off -> ReadOnly -> Full -> Off, wrapping around.
|
||||
///
|
||||
/// Why: used by a settings-toggle keybinding to step through modes
|
||||
/// without needing a dropdown/menu.
|
||||
///
|
||||
/// Return: nothing; mutates `settings.internet_mode` in place.
|
||||
pub fn cycle_internet_mode(settings: &mut Settings) {
|
||||
settings.internet_mode = match settings.internet_mode {
|
||||
InternetMode::Off => InternetMode::ReadOnly,
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
//! Todo-mode helper logic for the TUI todo-list overlay.
|
||||
//!
|
||||
//! Flow: exposes the toggle handler invoked by a keybinding to show/hide
|
||||
//! the todo overlay.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
/// Toggle the todo-list overlay open or closed.
|
||||
///
|
||||
/// Flow: if the todo overlay is currently shown, hide it (set to `Overlay::None`);
|
||||
/// otherwise show it.
|
||||
///
|
||||
/// Why: marks state dirty so the TUI re-renders on the next frame.
|
||||
///
|
||||
/// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place.
|
||||
pub fn handle_todo_toggle(state: &mut AppStateRest) {
|
||||
if state.misc.overlay == Overlay::Todo {
|
||||
state.misc.overlay = Overlay::None;
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
//! Workflow-mode helper logic for the TUI workflow overlay.
|
||||
//!
|
||||
//! Flow: exposes a dismiss handler invoked by a keybinding to close the
|
||||
//! workflow overlay, and a status query used elsewhere to check whether
|
||||
//! it is currently showing.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
/// Close the workflow overlay if it is the currently active overlay.
|
||||
///
|
||||
/// Flow: check `state.misc.overlay == Overlay::Workflow`, reset to `Overlay::None`
|
||||
/// if so, then mark state dirty regardless.
|
||||
///
|
||||
/// Why: no-ops safely if another overlay is showing, so it can be called
|
||||
/// unconditionally from a dismiss keybinding.
|
||||
///
|
||||
/// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place.
|
||||
pub fn handle_workflow_dismiss(state: &mut AppStateRest) {
|
||||
if state.misc.overlay == Overlay::Workflow {
|
||||
state.misc.overlay = Overlay::None;
|
||||
@@ -8,6 +23,11 @@ pub fn handle_workflow_dismiss(state: &mut AppStateRest) {
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
/// Report whether the workflow overlay is currently displayed.
|
||||
///
|
||||
/// Flow: compare `state.misc.overlay` against `Overlay::Workflow`.
|
||||
///
|
||||
/// Return: `"active"` if the workflow overlay is shown, `"idle"` otherwise.
|
||||
pub fn workflow_status(state: &AppStateRest) -> &str {
|
||||
if state.misc.overlay == Overlay::Workflow {
|
||||
"active"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Adaptive quality-review triggering, build/test probing, staleness
|
||||
//! sweeps for stored lessons, and the pending-lesson approval workflow.
|
||||
use std::process::Command;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::runtime::TurnEvent;
|
||||
@@ -7,6 +9,7 @@ use crate::app::subagent::engine::run_subagent;
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How much trust a lesson's origin/verification warrants.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Confidence {
|
||||
Human,
|
||||
@@ -15,6 +18,7 @@ pub enum Confidence {
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Where a lesson sits in its life cycle, from freshly written to superseded.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum LessonLifecycle {
|
||||
New,
|
||||
@@ -24,12 +28,14 @@ pub enum LessonLifecycle {
|
||||
Superseded,
|
||||
}
|
||||
|
||||
/// Whether a lesson applies to the current project only or globally.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum LessonScope {
|
||||
Project,
|
||||
Global,
|
||||
}
|
||||
|
||||
/// Records who/what produced a lesson and in which session/turn.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Provenance {
|
||||
pub session_turn: String,
|
||||
@@ -37,6 +43,8 @@ pub struct Provenance {
|
||||
pub reviewer: Origin,
|
||||
}
|
||||
|
||||
/// A single learned fact/pattern surfaced by a review, prior to being
|
||||
/// written to persistent memory.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Lesson {
|
||||
pub name: String,
|
||||
@@ -49,6 +57,18 @@ pub struct Lesson {
|
||||
pub provenance: Provenance,
|
||||
}
|
||||
|
||||
/// Decide whether an adaptive quality review should fire for this turn.
|
||||
///
|
||||
/// Flow: only `Origin::Main` turns are eligible → require review enabled
|
||||
/// in settings → fire every 5th edit unconditionally → otherwise, once
|
||||
/// `consecutive_empty_reviews` reaches `adaptive_review_max_skip` (min 2),
|
||||
/// fire on an exponentially growing skip interval (2^n, capped at 2^10)
|
||||
/// to avoid reviewing every single edit once reviews keep coming back empty.
|
||||
///
|
||||
/// Why: balances review usefulness against wasted subagent calls when
|
||||
/// reviews consistently find nothing.
|
||||
///
|
||||
/// Return: `true` if a review should be triggered this turn.
|
||||
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
|
||||
if origin != Origin::Main {
|
||||
@@ -75,6 +95,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Outcome of running a build/test probe command against a workspace.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProbeResult {
|
||||
pub command: String,
|
||||
@@ -82,6 +103,21 @@ pub struct ProbeResult {
|
||||
pub output: String,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
/// Run a build/test verification command in the first workspace root and
|
||||
/// capture its outcome, to back a review with a real pass/fail signal.
|
||||
///
|
||||
/// Flow: pick the first workspace → resolve the verify command (explicit
|
||||
/// override or auto-detected via `resolve_verify_command`) → spawn it →
|
||||
/// poll `try_wait` in a loop, killing the child if `timeout_ms` elapses →
|
||||
/// capture combined stdout+stderr (truncated) on completion.
|
||||
///
|
||||
/// Why: polling instead of a blocking wait lets the timeout be enforced
|
||||
/// without spawning a watcher thread.
|
||||
///
|
||||
/// Return: `None` if no workspace exists, no command could be resolved,
|
||||
/// or the process failed to spawn/poll; otherwise `Some(ProbeResult)`
|
||||
/// describing pass/fail/timeout and truncated output.
|
||||
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
|
||||
let probe_dir = workspaces.first()?;
|
||||
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
||||
@@ -131,6 +167,19 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the shell command to build/test a workspace, auto-detecting
|
||||
/// the project type from marker files when no override is given.
|
||||
///
|
||||
/// Flow: use `override_cmd` verbatim if non-empty → otherwise probe for
|
||||
/// language/tool marker files (Cargo.toml, go.mod, package.json, etc.)
|
||||
/// in priority order and return that ecosystem's conventional test/build
|
||||
/// command.
|
||||
///
|
||||
/// Why: covers a broad set of ecosystems so review probing works without
|
||||
/// per-project configuration in the common case.
|
||||
///
|
||||
/// Return: `Some(command)` if a command could be determined, `None` if
|
||||
/// no marker files matched (e.g. plain Python project with no test dir).
|
||||
fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> {
|
||||
if let Some(cmd) = override_cmd {
|
||||
if !cmd.trim().is_empty() {
|
||||
@@ -227,6 +276,10 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
||||
None
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `max` characters, appending a marker if cut.
|
||||
///
|
||||
/// Return: the original string if short enough, otherwise the first `max`
|
||||
/// characters plus `"... (truncated)"`.
|
||||
fn truncate_output(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
@@ -237,6 +290,22 @@ fn truncate_output(s: &str, max: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background quality-review subagent for the current session.
|
||||
///
|
||||
/// Flow: build a "quality-reviewer" subagent context → probe build/test
|
||||
/// status via `probe_build_test` to give the reviewer a real pass/fail
|
||||
/// signal → compose a system prompt embedding the probe result and lesson
|
||||
/// tagging instructions → spawn a thread running `run_subagent` → on
|
||||
/// completion, push a `TurnEvent::SystemNote` with the verdict's first
|
||||
/// line (or error) → push an "in progress" toast immediately.
|
||||
///
|
||||
/// Why: runs on a plain OS thread (not tokio) so it doesn't block the
|
||||
/// async event loop; communicates its result back via `turn_events`
|
||||
/// rather than a channel receiver (the `_rx` half is intentionally unused).
|
||||
///
|
||||
/// Return: `Ok(())` once the review has been kicked off; errors only
|
||||
/// propagate from constructing the subagent context, not from the review
|
||||
/// itself (that failure is reported via a `SystemNote` instead).
|
||||
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
let def = AgentDefinition::new(
|
||||
"quality-reviewer".to_string(),
|
||||
@@ -310,6 +379,14 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
|
||||
const STALE_AFTER_DAYS: i64 = 60;
|
||||
|
||||
/// Flag memory entries as stale if they haven't been updated recently.
|
||||
///
|
||||
/// Flow: list all memory files → for each, read it → if `updated_at` is
|
||||
/// older than `STALE_AFTER_DAYS` and it isn't already flagged, set
|
||||
/// `lifecycle = "stale"` and write it back → collect flagged names.
|
||||
///
|
||||
/// Return: names of newly-flagged memories, or an I/O error from
|
||||
/// `mem.write`.
|
||||
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
|
||||
let mut flagged = Vec::new();
|
||||
let names = crate::model::memory::Memory::list(memory_dir);
|
||||
@@ -327,6 +404,14 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
|
||||
Ok(flagged)
|
||||
}
|
||||
|
||||
/// Run the staleness sweep at most once every 10 minutes, notifying via toast.
|
||||
///
|
||||
/// Flow: skip if less than 600,000ms since `last_staleness_sweep_ms` →
|
||||
/// otherwise update the timestamp and run `run_staleness_sweep`, pushing
|
||||
/// an info toast listing flagged lessons if any were found.
|
||||
///
|
||||
/// Why: rate-limited so the sweep (a file read/write per memory) doesn't
|
||||
/// run on every event-loop tick.
|
||||
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
|
||||
@@ -343,6 +428,8 @@ pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A lesson awaiting confirmation before being committed to memory,
|
||||
/// optionally auto-resolving after a grace period.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingLesson {
|
||||
pub lesson: Lesson,
|
||||
@@ -350,6 +437,10 @@ pub struct PendingLesson {
|
||||
pub auto_resolve: bool,
|
||||
}
|
||||
|
||||
/// Load the session's pending-lessons queue from disk.
|
||||
///
|
||||
/// Return: the parsed list, or an empty `Vec` if the file is missing or
|
||||
/// fails to parse.
|
||||
pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> {
|
||||
let path = session_dir.join("pending_lessons.json");
|
||||
std::fs::read_to_string(&path)
|
||||
@@ -358,12 +449,28 @@ pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson>
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Write the session's pending-lessons queue to disk as pretty JSON.
|
||||
///
|
||||
/// Return: `Ok(())`, or an I/O error from writing the file.
|
||||
pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> {
|
||||
let path = session_dir.join("pending_lessons.json");
|
||||
let data = serde_json::to_string_pretty(pending)?;
|
||||
std::fs::write(&path, data)
|
||||
}
|
||||
|
||||
/// Commit any auto-resolvable pending lessons whose grace period has
|
||||
/// elapsed, and persist the remaining queue.
|
||||
///
|
||||
/// Flow: load pending lessons → partition into those eligible to commit
|
||||
/// (`auto_resolve` and older than the 5s grace window) vs. still pending
|
||||
/// → write eligible lessons as new `Memory` entries with `lifecycle:
|
||||
/// "active"` → save the remaining (unresolved) queue back to disk.
|
||||
///
|
||||
/// Why: the grace window gives the user a brief window to reject an
|
||||
/// auto-resolving lesson via `resolve_pending_lesson` before it commits.
|
||||
///
|
||||
/// Return: the still-pending lessons (post-commit), or an I/O error from
|
||||
/// writing memory files or the queue.
|
||||
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
|
||||
let pending = load_pending_lessons(session_dir);
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
@@ -399,6 +506,17 @@ pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::
|
||||
save_pending_lessons(session_dir, &remaining)?;
|
||||
Ok(remaining)
|
||||
}
|
||||
/// Manually resolve a single pending lesson by name: commit it to memory
|
||||
/// or discard it.
|
||||
///
|
||||
/// Flow: load the queue → find the lesson matching `lesson_name` →
|
||||
/// if `keep` is true, write it as an active `Memory` entry; either way
|
||||
/// remove it from the queue → save the remaining queue.
|
||||
///
|
||||
/// Why: lets the user (or UI action) override a pending lesson's fate
|
||||
/// before/without waiting for the auto-resolve grace window.
|
||||
///
|
||||
/// Return: `Ok(())`, or an I/O error from writing the memory file or queue.
|
||||
pub fn resolve_pending_lesson(
|
||||
session_dir: &std::path::Path,
|
||||
memory_dir: &std::path::Path,
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
//! The `Action` enum and its single dispatcher, `apply_action` — the
|
||||
//! chokepoint through which every key input, streaming event, and async
|
||||
//! background-thread result mutates `AppStateRest`.
|
||||
//!
|
||||
//! Flow: controllers/subagent threads construct `Action` values → the event
|
||||
//! loop calls `apply_action(&mut state, action)` → for turn-producing
|
||||
//! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS
|
||||
//! thread which drives `run_agent_turn` (stream to the LLM, gate and
|
||||
//! execute tool calls via `Harness`, archive messages to SQLite, log edits)
|
||||
//! and pushes `TurnEvent`s onto a shared queue → on the next `Tick`, queued
|
||||
//! `TurnEvent`s are drained back into `AppStateRest` (transcript, toasts,
|
||||
//! usage counters).
|
||||
//!
|
||||
//! Why: keeping all state mutation behind one function means callers only
|
||||
//! need to know how to *produce* actions, not how to update state safely;
|
||||
//! running turns on plain OS threads (rather than blocking the main loop)
|
||||
//! keeps the TUI responsive while the LLM streams.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::app::harness::Verdict;
|
||||
@@ -9,11 +27,14 @@ use crate::app::state::runtime::TurnEvent;
|
||||
use crate::app::state::types::{Origin, Overlay, Toast, ToastKind};
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
|
||||
// Step bounds intentionally left unbounded (usize::MAX) so the agent can
|
||||
// continue across as many turns as needed. Each iteration still honours
|
||||
// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is
|
||||
// observable and cancellable from the UI.
|
||||
|
||||
/// A single, well-typed event in the app — produced by key input, the
|
||||
/// streaming pipeline, or subagent threads — that mutates `AppStateRest`
|
||||
/// when applied via `apply_action`.
|
||||
///
|
||||
/// Step bounds intentionally left unbounded (usize::MAX) so the agent can
|
||||
/// continue across as many turns as needed. Each iteration still honours
|
||||
/// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is
|
||||
/// observable and cancellable from the UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
ForceQuit,
|
||||
@@ -63,6 +84,18 @@ pub enum Action {
|
||||
AbortTurn,
|
||||
}
|
||||
|
||||
/// Apply an `Action` to the application state.
|
||||
///
|
||||
/// Flow: pattern-match the variant → mutate `state` (input buffer, scroll
|
||||
/// position, overlay, transcript, runtime, toasts, dirty flag, etc.) →
|
||||
/// for `Tick`, also drain queued `TurnEvent`s and run periodic side jobs
|
||||
/// (staleness sweep, pending-lesson commit).
|
||||
///
|
||||
/// Why: the single chokepoint that turns every typed key and async event
|
||||
/// into a state change, so callers (controllers, subagent threads) only
|
||||
/// need to know how to *produce* actions.
|
||||
///
|
||||
/// Return: nothing; `state` is mutated in place.
|
||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
match action {
|
||||
Action::ForceQuit => {
|
||||
@@ -449,6 +482,19 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background thread that runs one full LLM turn.
|
||||
///
|
||||
/// Flow: check that no turn is currently in-flight → bail if so →
|
||||
/// collect messages and config from state → determine API key (from
|
||||
/// settings, env var, or default) → resolve generation params from
|
||||
/// the current effort level → collect all tools (built-in + MCP) →
|
||||
/// build `TurnCtx` → spawn a thread running `run_agent_turn` →
|
||||
/// on any error, push a `TurnEvent::Error` → clear the in-flight flag
|
||||
/// when the thread exits.
|
||||
///
|
||||
/// Why: runs on a plain OS thread so the async event loop stays responsive.
|
||||
///
|
||||
/// Return: nothing; results flow through `state.turn_events`.
|
||||
fn spawn_turn(state: &AppStateRest) {
|
||||
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
|
||||
*guard
|
||||
@@ -532,6 +578,7 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Context bundle passed to `run_agent_turn` on its background thread.
|
||||
struct TurnCtx {
|
||||
client: crate::service::provider::LlmClient,
|
||||
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
||||
@@ -547,6 +594,14 @@ struct TurnCtx {
|
||||
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
/// Build an ASCII tree of the workspace directory structure for the
|
||||
/// system prompt, so the LLM can see the file layout.
|
||||
///
|
||||
/// Flow: for each root, walk using `ignore::WalkBuilder` (respecting
|
||||
/// `.gitignore` and hidden files) → prefix `[DIR]` for directories →
|
||||
/// truncate after 1000 entries.
|
||||
///
|
||||
/// Return: a formatted string with one entry per line.
|
||||
fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("Current Workspace Directory Structure:\n");
|
||||
@@ -575,6 +630,11 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Persist a `ChatMessage` to the SQLite message log, if a database
|
||||
/// connection is available.
|
||||
///
|
||||
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
|
||||
/// Errors are silently ignored.
|
||||
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
|
||||
if let Some(ref arc) = db {
|
||||
if let Ok(conn) = arc.lock() {
|
||||
@@ -583,6 +643,28 @@ fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connect
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute one full agent turn: stream the conversation to the LLM,
|
||||
/// handle tool calls, and loop until the LLM produces a non-tool response
|
||||
/// or runs out of unfinished todo items.
|
||||
///
|
||||
/// Flow: build system prompt with workspace tree → optionally shape
|
||||
/// (compact) messages via `shortsend` → call `chat_with_tools_streaming`
|
||||
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
|
||||
/// and `Usage` events → on streaming success, handle tool calls (gated
|
||||
/// through `Harness::gate_tool_call`) or unwrap the final assistant
|
||||
/// message → check for unfinished todo.md tasks (auto-retry with a
|
||||
/// system message if any remain) → finalise with `Done` and an `edits`
|
||||
/// SystemNote.
|
||||
///
|
||||
/// On streaming failure: retry once with a non-streaming call → if that
|
||||
/// also fails and there are unfinished tasks, sleep 5s and loop back;
|
||||
/// otherwise return the error.
|
||||
///
|
||||
/// Why: non-streaming fallback handles flaky connections without aborting
|
||||
/// the turn; todo.md polling lets the agent self-direct toward completeness.
|
||||
///
|
||||
/// Return: `Ok(())` on successful completion, or an error from the LLM
|
||||
/// API after retries are exhausted.
|
||||
fn run_agent_turn(
|
||||
tc: TurnCtx,
|
||||
messages: &[ChatMessage],
|
||||
@@ -836,6 +918,20 @@ fn run_agent_turn(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a single tool call: find the tool by name, snapshot the file
|
||||
/// (if write/edit) for rewind, run the tool, log an `EditLogEntry` for
|
||||
/// write/edit, and return the output.
|
||||
///
|
||||
/// Flow: iterate tools → match by name → for write/edit, snapshot the
|
||||
/// pre-existing file content into the blob store → call `tool.run()` →
|
||||
/// for write/edit, compute SHA-256 of the new content and append an
|
||||
/// `EditLogEntry` → return the tool output string.
|
||||
///
|
||||
/// Why: snapshots enable the rewind feature to restore previous content
|
||||
/// after a write/edit.
|
||||
///
|
||||
/// Return: the tool's stdout string, or an error if no matching tool was
|
||||
/// found or the tool run itself failed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn execute_one_tool(
|
||||
tools: &[Box<dyn crate::tool::Tool>],
|
||||
@@ -910,6 +1006,15 @@ fn execute_one_tool(
|
||||
anyhow::bail!("tool not found: {}", name)
|
||||
}
|
||||
|
||||
/// Optionally push a review-available toast at the end of a turn that
|
||||
/// performed edits.
|
||||
///
|
||||
/// Flow: skip if review is disabled → skip if `edit_count` is zero →
|
||||
/// push an info toast listing the number of modified files.
|
||||
///
|
||||
/// Why: does not launch the review itself (that happens inside
|
||||
/// `should_trigger_review` on `Tick`), only informs the user that
|
||||
/// a review has material to examine.
|
||||
fn maybe_trigger_review(state: &mut AppStateRest) {
|
||||
if !state.settings.review_enabled {
|
||||
return;
|
||||
@@ -928,6 +1033,13 @@ fn maybe_trigger_review(state: &mut AppStateRest) {
|
||||
));
|
||||
}
|
||||
|
||||
/// Persist the current session metadata and conversation to disk.
|
||||
///
|
||||
/// Flow: build a `Session` object → save its metadata → write
|
||||
/// `rt.messages` as JSON to the conversation file → errors are silently
|
||||
/// ignored.
|
||||
///
|
||||
/// Why: called on `ForceQuit` so the session can be resumed later.
|
||||
fn save_current_session(state: &AppStateRest) {
|
||||
let base = state.store_base_dir();
|
||||
let session = crate::model::session::Session::new(
|
||||
@@ -943,6 +1055,20 @@ fn save_current_session(state: &AppStateRest) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a browser-based OAuth PKCE flow for the given provider.
|
||||
///
|
||||
/// Flow: look up config by provider name ("zen"/"opencode", "openai",
|
||||
/// or a custom provider via env vars) → bind a loopback server → generate
|
||||
/// a PKCE code verifier and challenge → build the authorisation URL →
|
||||
/// wait for the redirect code on the loopback server (with a 120s timeout)
|
||||
/// → exchange the code for a token → save the token to
|
||||
/// `~/.config/zesdex/oauth_{provider}.json`.
|
||||
///
|
||||
/// Why: the `webbrowser::open` call is currently commented out; the user
|
||||
/// must open the auth URL manually until that line is reinstated.
|
||||
///
|
||||
/// Return: a success message on completion, or an error if the flow fails
|
||||
/// at any step.
|
||||
fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
|
||||
use crate::service::oauth::manager::{OAuthConfig, OAuthManager};
|
||||
use crate::service::oauth::loopback::LoopbackServer;
|
||||
@@ -1013,6 +1139,11 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
|
||||
Ok(format!("Successfully authenticated with {}.", provider))
|
||||
}
|
||||
|
||||
/// Generate `n` pseudo-random bytes from the current sub-second timestamp.
|
||||
///
|
||||
/// Why: avoids pulling in a full RNG crate for the OAuth state token;
|
||||
/// sufficient for a nonce that only needs to be unpredictable over the
|
||||
/// lifetime of a single OAuth flow.
|
||||
fn rand_bytes(n: usize) -> Vec<u8> {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos();
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
//! Maps parsed `/` slash commands into one or more `Action` variants
|
||||
//! that `apply_action` can process.
|
||||
use crate::controller::command::Command;
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
/// Convert a parsed `Command` into the corresponding sequence of `Action`s.
|
||||
///
|
||||
/// Flow: match each `Command` variant to its handler — most produce a
|
||||
/// single `Action` (open an overlay, dispatch an OAuth flow, open the
|
||||
/// editor, etc.); some produce an `Action::SystemNote` for errors or
|
||||
/// informational responses.
|
||||
///
|
||||
/// Return: a `Vec<Action>` (always non-empty) to be applied sequentially
|
||||
/// by `apply_action`.
|
||||
pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
match command {
|
||||
Command::Help => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS
|
||||
//! after any activity, then slows down to conserve CPU.
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -7,12 +9,15 @@ const FAST_POLL_MS: u64 = 8;
|
||||
const SLOW_POLL_MS: u64 = 100;
|
||||
const IDLE_THRESHOLD_MS: u64 = 500;
|
||||
|
||||
/// Tracks whether the app has been active vs idle to adjust the TUI poll
|
||||
/// rate, balancing responsiveness against CPU usage.
|
||||
pub struct EventLoop {
|
||||
last_activity: Instant,
|
||||
fast_poll_until: Option<Instant>,
|
||||
}
|
||||
|
||||
impl EventLoop {
|
||||
/// Create an `EventLoop` with the current instant as the last activity.
|
||||
pub fn new() -> Self {
|
||||
EventLoop {
|
||||
last_activity: Instant::now(),
|
||||
@@ -20,6 +25,10 @@ impl EventLoop {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the appropriate polling delay based on activity state.
|
||||
///
|
||||
/// Flow: if `fast_poll_until` is set and the deadline hasn't expired,
|
||||
/// return `FAST_POLL_MS`; otherwise return `SLOW_POLL_MS`.
|
||||
pub fn poll_interval(&self) -> Duration {
|
||||
if let Some(fast_until) = self.fast_poll_until {
|
||||
if Instant::now() < fast_until {
|
||||
@@ -29,15 +38,21 @@ impl EventLoop {
|
||||
Duration::from_millis(SLOW_POLL_MS)
|
||||
}
|
||||
|
||||
/// Mark the current time as the last activity and arm the fast-poll
|
||||
/// window for the next `IDLE_THRESHOLD_MS`.
|
||||
pub fn mark_active(&mut self) {
|
||||
self.last_activity = Instant::now();
|
||||
self.fast_poll_until = Some(Instant::now() + Duration::from_millis(IDLE_THRESHOLD_MS));
|
||||
}
|
||||
|
||||
/// Return `true` if the app has been idle for more than `IDLE_THRESHOLD_MS`.
|
||||
pub fn is_idle(&self) -> bool {
|
||||
self.last_activity.elapsed().as_millis() as u64 > IDLE_THRESHOLD_MS
|
||||
}
|
||||
|
||||
/// Drain all pending `TurnEvent`s from the shared mutex queue.
|
||||
///
|
||||
/// Return: a `Vec` of all events that were in the queue (may be empty).
|
||||
pub fn drain_events(
|
||||
events: &std::sync::Mutex<VecDeque<TurnEvent>>,
|
||||
) -> Vec<TurnEvent> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Runtime layer: action dispatch, slash commands, short-send handling,
|
||||
//! and the LLM streaming pipeline.
|
||||
pub mod actions;
|
||||
pub mod commands;
|
||||
pub mod shortsend;
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
//! Short-send / message shaping: compacts long conversation histories so
|
||||
//! they fit within the provider's context window before being sent to the
|
||||
//! LLM API.
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
const MAX_WIRE_TOKENS: usize = 2_000_000;
|
||||
const MIN_MESSAGES_BEFORE_SHAPE: usize = 20;
|
||||
const ENGAGE_HYSTERESIS: usize = 5;
|
||||
|
||||
/// Decide whether the message list should be shaped (compacted) before
|
||||
/// sending to the LLM.
|
||||
///
|
||||
/// Flow: skip shaping if fewer than `MIN_MESSAGES_BEFORE_SHAPE` messages
|
||||
/// → once past that threshold, use hysteresis (require 5 more messages
|
||||
/// before re-engaging if shaping is currently active) to avoid oscillation.
|
||||
///
|
||||
/// Return: `true` if shaping should be applied.
|
||||
pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
|
||||
if total_messages < MIN_MESSAGES_BEFORE_SHAPE {
|
||||
return false;
|
||||
@@ -16,6 +27,19 @@ pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
|
||||
total_messages >= threshold
|
||||
}
|
||||
|
||||
/// Compact a long message list by dropping middle messages and inserting
|
||||
/// a summary placeholder.
|
||||
///
|
||||
/// Flow: if the estimated token count is within budget, return messages
|
||||
/// unchanged → otherwise keep the system message and the most recent
|
||||
/// messages (up to `MAX_WIRE_TOKENS / 200` of them) with a `[prior
|
||||
/// conversation compacted]` system message in between.
|
||||
///
|
||||
/// Why: keeps context-size overhead roughly constant regardless of
|
||||
/// session length.
|
||||
///
|
||||
/// Return: a new Vec<ChatMessage> that preserves the first message and
|
||||
/// the tail.
|
||||
pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec<ChatMessage> {
|
||||
if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 {
|
||||
return messages.to_vec();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
|
||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||
pub mod turn;
|
||||
pub mod tools;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// One atomic event extracted from an LLM streaming response stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StreamEvent {
|
||||
Token(String),
|
||||
@@ -23,6 +26,8 @@ pub enum StreamEvent {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Buffered SSE frame parser that accumulates raw `data:` lines and
|
||||
/// flushes a `StreamEvent` on each blank-line boundary.
|
||||
pub struct SseParser {
|
||||
buffer: String,
|
||||
event_type: Option<String>,
|
||||
@@ -30,6 +35,7 @@ pub struct SseParser {
|
||||
}
|
||||
|
||||
impl SseParser {
|
||||
/// Create a new parser with an empty buffer.
|
||||
pub fn new() -> Self {
|
||||
SseParser {
|
||||
buffer: String::new(),
|
||||
@@ -38,6 +44,17 @@ impl SseParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a raw SSE chunk and produce any completed events.
|
||||
///
|
||||
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
|
||||
/// blank line, call `flush_event` to parse the accumulated data →
|
||||
/// on `event:` line, store the event type → on `data:` line, append
|
||||
/// to data accumulator → continue until buffer exhausted.
|
||||
///
|
||||
/// Edge case: a chunk may split mid-line; the remainder stays in the
|
||||
/// buffer for the next `feed()` call.
|
||||
///
|
||||
/// Return: all `StreamEvent`s completed by this chunk.
|
||||
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
|
||||
self.buffer.push_str(chunk);
|
||||
let mut events = Vec::new();
|
||||
@@ -57,6 +74,19 @@ impl SseParser {
|
||||
events
|
||||
}
|
||||
|
||||
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
|
||||
///
|
||||
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
|
||||
/// emit `Usage` if a usage object is present → else match `event_type`
|
||||
/// ("message.stop", "message.delta", etc.) → extract content,
|
||||
/// reasoning, tool-call deltas, or finish-reason from the delta
|
||||
/// structure (supporting both Anthropic-style top-level delta and
|
||||
/// OpenAI-style `choices` array).
|
||||
///
|
||||
/// Why: dual-format support in one method avoids a separate
|
||||
/// provider-specific parsing layer.
|
||||
///
|
||||
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||
let data = self.data_lines.join("\n");
|
||||
self.data_lines.clear();
|
||||
@@ -179,6 +209,13 @@ impl SseParser {
|
||||
/// Fallback parser for providers that send bare JSON chunks instead of SSE-framed
|
||||
/// `data: ...` lines. Not used by the `SseParser` streaming path (which handles
|
||||
/// standard SSE framing directly), kept for providers/tests that feed raw chunks.
|
||||
///
|
||||
/// Flow: parse `data` as JSON → extract first `choices[0].delta` →
|
||||
/// return a `Token`, `Reasoning`, `Done`, or `ToolCallDelta` event based
|
||||
/// on the fields present.
|
||||
///
|
||||
/// Return: `Some(StreamEvent)` if the chunk contained recognisable
|
||||
/// content, `None` otherwise.
|
||||
#[allow(dead_code)]
|
||||
pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
|
||||
let value: Value = serde_json::from_str(data).ok()?;
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
//! Standalone accumulator for streamed tool-call deltas.
|
||||
//!
|
||||
//! Flow: `ToolCallAccumulator::add_delta` is fed incremental `(index, id,
|
||||
//! name, arguments_delta)` chunks as they arrive over SSE → grows its
|
||||
//! internal `Vec<ParsedToolCall>` as needed → `is_complete` reports once
|
||||
//! every accumulated call has both a name and arguments.
|
||||
//!
|
||||
//! Why: mirrors the accumulation logic built into `StreamedTurn::apply_event`
|
||||
//! but as an independent, reusable type for callers that want to track
|
||||
//! tool-call deltas without a full `StreamedTurn` (e.g. a lighter-weight
|
||||
//! preview). Currently unused (`#[allow(dead_code)]`), kept for that future
|
||||
//! use case.
|
||||
|
||||
use super::turn::ParsedToolCall;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -11,10 +24,15 @@ pub struct ToolCallAccumulator {
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ToolCallAccumulator {
|
||||
/// Construct an empty accumulator with no tool calls tracked yet.
|
||||
///
|
||||
/// Return: a fresh `ToolCallAccumulator`.
|
||||
pub fn new() -> Self {
|
||||
ToolCallAccumulator { calls: Vec::new() }
|
||||
}
|
||||
|
||||
/// Append a delta to the tool call at the given index, growing the
|
||||
/// calls vector if needed.
|
||||
pub fn add_delta(
|
||||
&mut self,
|
||||
index: usize,
|
||||
@@ -44,18 +62,23 @@ impl ToolCallAccumulator {
|
||||
tc.arguments.push_str(arguments_delta);
|
||||
}
|
||||
|
||||
/// Borrow the accumulated tool calls.
|
||||
pub fn calls(&self) -> &[ParsedToolCall] {
|
||||
&self.calls
|
||||
}
|
||||
|
||||
/// Return true once all tool calls have both a name and arguments.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
!self.calls.is_empty() && self.calls.iter().all(|tc| !tc.name.is_empty() && !tc.arguments.is_empty())
|
||||
}
|
||||
|
||||
/// Clear all accumulated calls (starting a fresh turn).
|
||||
pub fn reset(&mut self) {
|
||||
self.calls.clear();
|
||||
}
|
||||
|
||||
/// Build a JSON-serialisable `Vec<Value>` of pending (non-empty-name)
|
||||
/// tool calls, suitable for downstream inspection or replay.
|
||||
pub fn pending_args(&self) -> Vec<Value> {
|
||||
self.calls
|
||||
.iter()
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! Accumulates streaming LLM responses into complete message/tool-call
|
||||
//! representation via `StreamedTurn`, and provides a standalone tool-call
|
||||
//! accumulator in `tools::ToolCallAccumulator`.
|
||||
use super::StreamEvent;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::chat::tool::{ToolCall, ToolFunction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Accumulates a single streaming assistant turn into its final
|
||||
/// `ChatMessage` form, including tool-call deltas and content/reasoning.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamedTurn {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
@@ -13,6 +18,7 @@ pub struct StreamedTurn {
|
||||
pub accumulated_reasoning: String,
|
||||
}
|
||||
|
||||
/// A single tool call being built up from streaming deltas.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParsedToolCall {
|
||||
pub id: String,
|
||||
@@ -22,9 +28,11 @@ pub struct ParsedToolCall {
|
||||
}
|
||||
|
||||
impl ParsedToolCall {
|
||||
/// Attempts to parse the accumulated argument string as JSON before the tool call is
|
||||
/// marked complete — useful for callers that want a speculative preview mid-stream.
|
||||
/// `build_assistant_message` does its own (lossy-fallback) parse for the final message.
|
||||
/// Attempt to parse the accumulated argument string as JSON before
|
||||
/// the tool call is marked complete — useful for a speculative preview.
|
||||
///
|
||||
/// Return: `Some(Value)` if the arguments are parsable JSON, `None`
|
||||
/// if still partial.
|
||||
#[allow(dead_code)]
|
||||
pub fn try_parse(&self) -> Option<Value> {
|
||||
serde_json::from_str(&self.arguments).ok()
|
||||
@@ -32,6 +40,7 @@ impl ParsedToolCall {
|
||||
}
|
||||
|
||||
impl StreamedTurn {
|
||||
/// Create an empty turn accumulator.
|
||||
pub fn new() -> Self {
|
||||
StreamedTurn {
|
||||
messages: Vec::new(),
|
||||
@@ -42,6 +51,12 @@ impl StreamedTurn {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a `StreamEvent` to the turn, updating accumulated content,
|
||||
/// reasoning, and tool-call deltas.
|
||||
///
|
||||
/// Flow: match on variant — `Token` appends to `accumulated_content`,
|
||||
/// `Reasoning` to `accumulated_reasoning`, `ToolCallDelta` fills or
|
||||
/// grows the `tool_calls` vector, `Done` sets `is_complete = true`.
|
||||
pub fn apply_event(&mut self, event: &StreamEvent) {
|
||||
match event {
|
||||
StreamEvent::Token(token) => {
|
||||
@@ -84,6 +99,14 @@ impl StreamedTurn {
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalise the turn into a `ChatMessage`, combining accumulated
|
||||
/// reasoning (wrapped in `<think>` tags) with content and tool calls.
|
||||
///
|
||||
/// Flow: if tool calls exist, build a `ChatMessage` with `tool_calls`
|
||||
/// set; otherwise build a plain assistant message → set `content` to
|
||||
/// the combined reasoning+content string (or `None` if empty).
|
||||
///
|
||||
/// Return: a complete `ChatMessage` with role `Assistant`.
|
||||
pub fn build_assistant_message(&self) -> ChatMessage {
|
||||
let mut msg = if self.tool_calls.is_empty() {
|
||||
ChatMessage::assistant(None)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
//! Shallow state diffing — records opaque "modified" markers so the TUI
|
||||
//! knows to re-render without computing fine-grained deltas.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A collection of changes tracking which parts of app state have been
|
||||
/// modified since the last render sweep.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateDiff {
|
||||
changes: Vec<Change>,
|
||||
}
|
||||
|
||||
/// A single named change — currently always carries a flat `"."` path
|
||||
/// and `"modified"` kind because the system does not track granular diffs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Change {
|
||||
pub path: String,
|
||||
@@ -12,23 +18,36 @@ pub struct Change {
|
||||
}
|
||||
|
||||
impl StateDiff {
|
||||
/// Create an empty diff.
|
||||
pub fn new() -> Self {
|
||||
StateDiff { changes: Vec::new() }
|
||||
}
|
||||
|
||||
/// Record a change at `path` of the given `kind`.
|
||||
pub fn add_change(&mut self, path: String, kind: String) {
|
||||
self.changes.push(Change { path, kind });
|
||||
}
|
||||
|
||||
/// Return true if no changes have been recorded.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changes.is_empty()
|
||||
}
|
||||
|
||||
/// Remove all recorded changes.
|
||||
pub fn clear(&mut self) {
|
||||
self.changes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a shallow diff between two serialised state values.
|
||||
///
|
||||
/// Flow: compare with `==`, return an empty vec if equal, otherwise
|
||||
/// return a single `Change { ".", "modified" }`.
|
||||
///
|
||||
/// Why: a placeholder — the current rendering model re-validates the
|
||||
/// whole viewport every frame, so fine-grained diffs are unnecessary.
|
||||
///
|
||||
/// Return: the list of changes (always 0 or 1 entry).
|
||||
pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec<Change> {
|
||||
if before == after {
|
||||
return Vec::new();
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
//! Application-level "miscellaneous" state: scroll, input buffer,
|
||||
//! overlay stack, toasts, editor, and autocomplete.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use super::types::Overlay;
|
||||
|
||||
/// A shared, async-writable cache of directory entries, used to avoid
|
||||
/// re-reading a directory every render frame.
|
||||
#[derive(Clone)]
|
||||
pub struct DirCache {
|
||||
entries: Arc<RwLock<Vec<PathBuf>>>,
|
||||
}
|
||||
|
||||
impl DirCache {
|
||||
/// Create an empty `DirCache`.
|
||||
pub fn new() -> Self {
|
||||
DirCache {
|
||||
entries: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the cached entries (async write).
|
||||
pub async fn set(&self, paths: Vec<PathBuf>) {
|
||||
let mut w = self.entries.write().await;
|
||||
*w = paths;
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the viewport scroll offset.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollState {
|
||||
pub offset: usize,
|
||||
@@ -28,6 +35,7 @@ pub struct ScrollState {
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
/// Create a `ScrollState` with zero offset and 30 rows visible.
|
||||
pub fn new() -> Self {
|
||||
ScrollState {
|
||||
offset: 0,
|
||||
@@ -35,19 +43,25 @@ impl ScrollState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_add(amount);
|
||||
}
|
||||
|
||||
/// Scroll the viewport down by `amount` lines (decreasing the offset).
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_sub(amount);
|
||||
}
|
||||
|
||||
/// Update the maximum number of visible lines.
|
||||
pub fn set_max_visible(&mut self, max: usize) {
|
||||
self.max_visible = max;
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's input buffer, cursor position, history, and autocomplete
|
||||
/// state for the chat prompt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
pub buffer: String,
|
||||
@@ -81,6 +95,8 @@ const COMMANDS: &[&str] = &[
|
||||
];
|
||||
|
||||
impl InputState {
|
||||
/// Create an empty input state with no buffer, no history, and no
|
||||
/// autocomplete.
|
||||
pub fn new() -> Self {
|
||||
InputState {
|
||||
buffer: String::new(),
|
||||
@@ -94,6 +110,7 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the autocomplete dropdown and clear its state.
|
||||
pub fn close_autocomplete(&mut self) {
|
||||
self.autocomplete_visible = false;
|
||||
self.autocomplete_candidates.clear();
|
||||
@@ -101,6 +118,12 @@ impl InputState {
|
||||
self.autocomplete_idx = 0;
|
||||
}
|
||||
|
||||
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`
|
||||
/// against the current buffer prefix.
|
||||
///
|
||||
/// Flow: if buffer is empty or doesn't start with `/`, close and return
|
||||
/// → filter `COMMANDS` by prefix match → store candidates → set
|
||||
/// `autocomplete_visible` if any candidates found.
|
||||
pub fn open_autocomplete(&mut self) {
|
||||
let trimmed = self.buffer.trim().to_string();
|
||||
if trimmed.is_empty() || !trimmed.starts_with('/') {
|
||||
@@ -119,6 +142,8 @@ impl InputState {
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Move the autocomplete selection up (forward=false) or down (forward=true).
|
||||
/// Wraps around at the boundaries.
|
||||
pub fn cycle_autocomplete(&mut self, forward: bool) {
|
||||
let n = self.autocomplete_candidates.len();
|
||||
if n == 0 { return; }
|
||||
@@ -129,6 +154,10 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept the currently selected autocomplete candidate, placing it
|
||||
/// in the buffer and closing the dropdown.
|
||||
///
|
||||
/// Return: `true` if a candidate was selected, `false` if none existed.
|
||||
pub fn select_autocomplete(&mut self) -> bool {
|
||||
if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) {
|
||||
self.buffer = candidate.clone();
|
||||
@@ -140,6 +169,8 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy inline tab-complete — opens the dropdown on first Tab press,
|
||||
/// then cycles forward on subsequent presses.
|
||||
pub fn tab_complete(&mut self) {
|
||||
// Legacy inline tab-complete — used as a fallback when the dropdown
|
||||
// isn't visible yet. Opens the dropdown on the first Tab press.
|
||||
@@ -150,23 +181,27 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor left by one character (if not at the start).
|
||||
pub fn char_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor right by one character (if not at the end).
|
||||
pub fn char_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert(&mut self, c: char) {
|
||||
self.buffer.insert(self.cursor, c);
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
/// Delete the character to the left of the cursor (backspace).
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
@@ -174,12 +209,17 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character at the cursor position (forward delete).
|
||||
pub fn delete_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit the current buffer: push it to history, clear the buffer,
|
||||
/// and return the submitted text.
|
||||
///
|
||||
/// Return: the text that was in the buffer before clearing.
|
||||
pub fn submit(&mut self) -> String {
|
||||
let result = self.buffer.clone();
|
||||
if !result.is_empty() {
|
||||
@@ -191,6 +231,7 @@ impl InputState {
|
||||
result
|
||||
}
|
||||
|
||||
/// Navigate backward through input history.
|
||||
pub fn history_up(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
@@ -205,6 +246,7 @@ impl InputState {
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
/// Navigate forward through input history (back toward the newest entry).
|
||||
pub fn history_down(&mut self) {
|
||||
match self.history_idx {
|
||||
Some(i) if i < self.history.len() - 1 => {
|
||||
@@ -223,6 +265,8 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "miscellaneous" slice of app state: which overlay is showing,
|
||||
/// toasts, thinking/connected flags, effort level, editor state, and tick.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiscState {
|
||||
pub overlay: Overlay,
|
||||
@@ -237,6 +281,8 @@ pub struct MiscState {
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
/// Create a fresh `MiscState` with no overlay, no toasts, and default
|
||||
/// effort level 1.
|
||||
pub fn new() -> Self {
|
||||
MiscState {
|
||||
overlay: Overlay::None,
|
||||
@@ -255,6 +301,9 @@ impl MiscState {
|
||||
self.toasts.push(toast);
|
||||
}
|
||||
|
||||
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
|
||||
///
|
||||
/// Return: the expired toasts (after removal).
|
||||
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
|
||||
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
|
||||
self.toasts.retain(|t| !t.expired(now_ms));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Application state: misc fields, the main `AppStateRest` struct,
|
||||
//! runtime-only state, and shared types (overlays, toasts, origins).
|
||||
pub mod misc;
|
||||
pub mod rest;
|
||||
pub mod runtime;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
//! Top-level mutable application state (`AppStateRest`) and the transcript
|
||||
//! display type it owns.
|
||||
//!
|
||||
//! `AppStateRest` is the single source-of-truth struct mutated in-place from
|
||||
//! `actions/mod.rs` and `controller/input.rs`; every other module reads it.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -12,6 +18,7 @@ use crate::model::app_config::AppConfig;
|
||||
use crate::model::editlog::EditLog;
|
||||
use crate::model::settings::Settings;
|
||||
|
||||
/// A single transcript entry rendered in the TUI chat pane.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChatMessageDisplay {
|
||||
pub role: crate::dto::chat::message::Role,
|
||||
@@ -20,6 +27,7 @@ pub struct ChatMessageDisplay {
|
||||
}
|
||||
|
||||
impl ChatMessageDisplay {
|
||||
/// Build a display entry, stamping it with the current time.
|
||||
pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self {
|
||||
ChatMessageDisplay {
|
||||
role,
|
||||
@@ -29,6 +37,11 @@ impl ChatMessageDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
/// The single source-of-truth state struct for the entire application.
|
||||
///
|
||||
/// Mutated in-place from two locations: `actions/mod.rs` (`apply_action`)
|
||||
/// and `controller/input.rs` (key event handlers). Read-only from every
|
||||
/// other module.
|
||||
#[derive(Clone)]
|
||||
pub struct AppStateRest {
|
||||
|
||||
@@ -58,6 +71,15 @@ pub struct AppStateRest {
|
||||
}
|
||||
|
||||
impl AppStateRest {
|
||||
/// Construct the initial application state for a session.
|
||||
///
|
||||
/// Flow: load settings/config -> derive download/worktree dirs from
|
||||
/// `memory_dir`'s parent -> derive `session_id` from the session dir's
|
||||
/// file name -> build the sub-state structs.
|
||||
///
|
||||
/// Why: falls back to `memory_dir` itself (with a warning) when it has
|
||||
/// no parent, and to an empty session id when the dir name can't be
|
||||
/// read, so construction never fails.
|
||||
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
|
||||
let settings = Settings::load();
|
||||
let app_config = AppConfig::load();
|
||||
@@ -105,6 +127,10 @@ impl AppStateRest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an agent turn is currently running.
|
||||
///
|
||||
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
|
||||
/// than propagating a panic.
|
||||
pub fn turn_in_flight(&self) -> bool {
|
||||
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| {
|
||||
tracing::warn!("[state] turn_in_flight mutex poisoned");
|
||||
@@ -114,6 +140,8 @@ impl AppStateRest {
|
||||
|
||||
|
||||
|
||||
/// Append a message to the transcript, evicting the oldest entry once
|
||||
/// `max_lines` is exceeded, and mark both the cache and the app dirty.
|
||||
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||||
self.transcript_cache.messages.push(msg);
|
||||
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
|
||||
@@ -123,11 +151,19 @@ impl AppStateRest {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Queue a toast notification for display and mark the app dirty.
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.misc.push_toast(toast);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Resolve the base directory that stores this session (grandparent of
|
||||
/// `session_dir`, i.e. the sessions root, not the individual session
|
||||
/// folder).
|
||||
///
|
||||
/// Why: falls back progressively -- grandparent, then parent, then
|
||||
/// `session_dir` itself -- logging a warning at each step down, so this
|
||||
/// never fails even on a shallow path.
|
||||
pub fn store_base_dir(&self) -> std::path::PathBuf {
|
||||
self.session_dir.parent()
|
||||
.and_then(|p| p.parent())
|
||||
@@ -143,10 +179,13 @@ impl AppStateRest {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a `ToolCtx` for tool calls originating from the main agent.
|
||||
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
|
||||
self.tool_ctx_for(Origin::Main)
|
||||
}
|
||||
|
||||
/// Build a `ToolCtx` scoped to the given call origin (main, subagent,
|
||||
/// reviewer), copying workspace/session/memory paths from state.
|
||||
pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx {
|
||||
crate::tool::ToolCtx {
|
||||
workspaces: self.workspace_roots.clone(),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
//! Per-session runtime state: message history, pending tool queue,
|
||||
//! background bash jobs, lesson/review counters, and the `TurnEvent`
|
||||
//! stream emitted while an agent turn is in flight.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cumulative token/latency counters for a session, persisted alongside it.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct UsageStats {
|
||||
pub tokens_in: u64,
|
||||
@@ -10,6 +15,9 @@ pub struct UsageStats {
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
/// Mutable, serializable state for one session: chat history, tool
|
||||
/// results, pending tools, background jobs, and lesson/review counters
|
||||
/// shown in the TUI status bar.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionRuntime {
|
||||
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
|
||||
@@ -36,6 +44,7 @@ pub struct SessionRuntime {
|
||||
pub usage: UsageStats,
|
||||
}
|
||||
|
||||
/// Record of one completed tool invocation, kept for transcript/history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallResult {
|
||||
pub tool_call_id: String,
|
||||
@@ -45,6 +54,8 @@ pub struct ToolCallResult {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// A tool call awaiting execution, along with which execution model
|
||||
/// (inline, deferred, async) it should run under.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingTool {
|
||||
pub tool_name: String,
|
||||
@@ -52,6 +63,8 @@ pub struct PendingTool {
|
||||
pub execution_model: crate::app::state::types::ExecutionModel,
|
||||
}
|
||||
|
||||
/// Reference to a background bash job tracked in session state (the actual
|
||||
/// process handle lives elsewhere; this is just the display/status record).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BashJobRef {
|
||||
pub id: String,
|
||||
@@ -60,6 +73,8 @@ pub struct BashJobRef {
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Events emitted onto the turn-event queue while an agent turn runs,
|
||||
/// consumed by the event loop to update state and drive re-renders.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEvent {
|
||||
AssistantMessage(crate::dto::chat::message::ChatMessage),
|
||||
@@ -86,6 +101,8 @@ pub enum TurnEvent {
|
||||
}
|
||||
|
||||
impl SessionRuntime {
|
||||
/// Create fresh runtime state for a session rooted at `session_dir`,
|
||||
/// with all counters zeroed and `session_start` set to now.
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
SessionRuntime {
|
||||
messages: Vec::new(),
|
||||
@@ -113,6 +130,7 @@ impl SessionRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message to the session's conversation history.
|
||||
pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Opaque, serializable snapshot of application state used for
|
||||
//! attach/daemon IPC transfer.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A JSON-boxed snapshot of app state, opaque to the transport layer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateSnapshot {
|
||||
pub snapshot: serde_json::Value,
|
||||
}
|
||||
|
||||
impl StateSnapshot {
|
||||
/// Create an empty snapshot (`{}`).
|
||||
pub fn new() -> Self {
|
||||
StateSnapshot {
|
||||
snapshot: serde_json::json!({}),
|
||||
@@ -13,10 +18,16 @@ impl StateSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a snapshot to bytes for transport over the daemon socket.
|
||||
///
|
||||
/// Return: JSON-encoded bytes, or a serde error.
|
||||
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec(snapshot)?)
|
||||
}
|
||||
|
||||
/// Parse a snapshot previously produced by `serialize_snapshot`.
|
||||
///
|
||||
/// Return: the decoded `StateSnapshot`, or a serde error.
|
||||
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
|
||||
Ok(serde_json::from_slice(data)?)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Shared small state types: toasts, overlays, the transcript cache,
|
||||
//! tool execution model, and call origin tags.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
/// Severity/category of a toast notification, used to pick its color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
Info,
|
||||
@@ -10,6 +14,8 @@ pub enum ToastKind {
|
||||
Lesson,
|
||||
}
|
||||
|
||||
/// A transient status message shown in the TUI, auto-dismissed after
|
||||
/// `lifetime_ms`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Toast {
|
||||
pub kind: ToastKind,
|
||||
@@ -19,6 +25,7 @@ pub struct Toast {
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
/// Create a toast with a default 5-second lifetime, stamped with now.
|
||||
pub fn new(kind: ToastKind, message: String) -> Self {
|
||||
Toast {
|
||||
kind,
|
||||
@@ -28,11 +35,13 @@ impl Toast {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this toast's lifetime has elapsed as of `now_ms`.
|
||||
pub fn expired(&self, now_ms: i64) -> bool {
|
||||
now_ms - self.created_at > self.lifetime_ms as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// Which modal overlay, if any, is currently shown over the main TUI view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Overlay {
|
||||
None,
|
||||
@@ -56,11 +65,13 @@ pub enum Overlay {
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
/// Whether any overlay (i.e. anything other than `None`) is active.
|
||||
pub fn is_active(self) -> bool {
|
||||
!matches!(self, Overlay::None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded ring of recent chat messages used to render the transcript view.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TranscriptCache {
|
||||
pub messages: Vec<super::rest::ChatMessageDisplay>,
|
||||
@@ -69,6 +80,7 @@ pub struct TranscriptCache {
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
/// Create an empty transcript cache holding at most `max_lines` messages.
|
||||
pub fn new(max_lines: usize) -> Self {
|
||||
TranscriptCache {
|
||||
messages: Vec::new(),
|
||||
@@ -78,6 +90,7 @@ impl TranscriptCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a pending tool call should be executed when the turn resumes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExecutionModel {
|
||||
Inline,
|
||||
@@ -85,6 +98,8 @@ pub enum ExecutionModel {
|
||||
AsyncTokio,
|
||||
}
|
||||
|
||||
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
||||
/// invoking a tool, used to scope permissions and tag log/output paths.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum Origin {
|
||||
Main,
|
||||
@@ -93,6 +108,7 @@ pub enum Origin {
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
/// Short string tag for this origin, used in filenames and logs.
|
||||
pub fn tag(&self) -> String {
|
||||
match self {
|
||||
Origin::Main => "main".to_string(),
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
//! Construction of a `SubagentContext` from an `AgentDefinition`,
|
||||
//! including the default read-only tool set for reviewer agents.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use super::spawn::AgentDefinition;
|
||||
|
||||
/// Default read-only tool names granted to `role == "reviewer"` agents.
|
||||
pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"];
|
||||
|
||||
/// Per-invocation configuration for a subagent: prompt, allowed tools,
|
||||
/// step budget, and the session directory it should operate against.
|
||||
pub struct SubagentContext {
|
||||
pub system_prompt: String,
|
||||
pub allowed_tools: Vec<String>,
|
||||
@@ -10,6 +16,14 @@ pub struct SubagentContext {
|
||||
pub session_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Build a `SubagentContext` from an `AgentDefinition`.
|
||||
///
|
||||
/// Flow: copy optional `allowed_tools` from the def -> fall back to the
|
||||
/// reviewer-allowlist when the def has none and the role is "reviewer" ->
|
||||
/// fall back to an empty list (i.e. "all tools allowed") for other roles.
|
||||
///
|
||||
/// Return: a context with empty `system_prompt` and `session_dir`,
|
||||
/// `max_steps = 25`, and the resolved allowed-tool list.
|
||||
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
|
||||
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
||||
if def.role == "reviewer" {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Subagent execution loop: drive an LLM conversation, gate tool calls
|
||||
//! against the context's allowlist, run tools, and stream progress events
|
||||
//! to the parent via an mpsc channel.
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::provider::request::ToolDef;
|
||||
@@ -5,12 +9,20 @@ use crate::tool::{all_tools, tool_defs, tool_is_risky};
|
||||
use super::context::SubagentContext;
|
||||
use super::event::SubagentEvent;
|
||||
|
||||
/// Upper bound on agent loop steps; effectively unbounded (`usize::MAX`).
|
||||
#[allow(dead_code)]
|
||||
pub const MAX_AGENT_STEPS: usize = usize::MAX;
|
||||
|
||||
/// Maps a subagent's allowed tool names to concrete Tool trait objects and
|
||||
/// OpenAI-style tool definitions. When `allowed_tools` is empty every tool is
|
||||
/// available; otherwise only explicitly allowed ones are included.
|
||||
/// OpenAI-style tool definitions.
|
||||
///
|
||||
/// Flow: load `all_tools()` → if `allowed_tools` is empty, use all; else
|
||||
/// filter by membership → derive `ToolDef`s for the LLM.
|
||||
///
|
||||
/// Why: an empty allowlist means "no restriction" (matches
|
||||
/// `build_subagent_context`'s default for non-reviewer roles).
|
||||
///
|
||||
/// Return: `(tool impls, schema defs)` for the subagent to use.
|
||||
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
|
||||
let all = all_tools();
|
||||
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
|
||||
@@ -24,9 +36,16 @@ fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::T
|
||||
(filtered, defs)
|
||||
}
|
||||
|
||||
/// Resolves the API key, model, and base URL from the persisted application
|
||||
/// configuration rather than environment variables, matching how the main agent
|
||||
/// resolves its credentials.
|
||||
/// Resolve the API key, model, and base URL from persisted app config.
|
||||
///
|
||||
/// Flow: try the settings key for the active provider → fall back to the
|
||||
/// provider's `api_key_env` env-var → fall back to the provider's
|
||||
/// `default_api_key` → fall back to an empty string.
|
||||
///
|
||||
/// Why: matches the main agent's credential resolution exactly, so
|
||||
/// subagents automatically inherit the same provider settings.
|
||||
///
|
||||
/// Return: `(api_key, model, optional_base_url)`.
|
||||
fn resolve_provider_config() -> (String, String, Option<String>) {
|
||||
let settings = crate::model::settings::Settings::load();
|
||||
let app_config = crate::model::app_config::AppConfig::load();
|
||||
@@ -54,6 +73,20 @@ fn resolve_provider_config() -> (String, String, Option<String>) {
|
||||
(api_key, model, base_url)
|
||||
}
|
||||
|
||||
/// Synchronous subagent entry point: run up to `ctx.max_steps` iterations
|
||||
/// of the LLM tool loop.
|
||||
///
|
||||
/// Flow: inject system prompt → for each step: resolve provider config,
|
||||
/// build an LLM client, call `chat_with_tools_non_streaming`, process tool
|
||||
/// calls or collect text output → send `SubagentEvent`s on `tx` → break on
|
||||
/// first text-only (non-empty) response.
|
||||
///
|
||||
/// Why: runs synchronously on a dedicated thread so the main async event
|
||||
/// loop is not blocked. Tool gating prevents restricted or risky tools from
|
||||
/// executing unless explicitly allowed.
|
||||
///
|
||||
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM
|
||||
/// call fails at any step.
|
||||
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
||||
let mut output = String::new();
|
||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
//! Event variants that a running subagent can emit to its parent via the
|
||||
//! shared mpsc channel.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Progress and outcome events emitted by `run_subagent` as it processes
|
||||
/// LLM responses and tool calls.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubagentEvent {
|
||||
StepCompleted {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Subagent management: spawning, context building, engine loop, and
|
||||
//! progress events.
|
||||
|
||||
pub mod context;
|
||||
pub mod engine;
|
||||
pub mod event;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
//! AgentDefinition -- declarative specification for instantiating a
|
||||
//! subagent from workflow scripts or programmatic calls.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declarative specification for instantiating a subagent: name, role,
|
||||
/// optional system prompt, allowed tools, step budget, and temperature.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDefinition {
|
||||
pub name: String,
|
||||
@@ -11,6 +16,8 @@ pub struct AgentDefinition {
|
||||
}
|
||||
|
||||
impl AgentDefinition {
|
||||
/// Create an agent definition with the required name and role; all
|
||||
/// optional fields start as `None`.
|
||||
pub fn new(name: String, role: String) -> Self {
|
||||
AgentDefinition {
|
||||
name,
|
||||
@@ -22,6 +29,7 @@ impl AgentDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder method: limit this agent to at most `steps` LLM calls.
|
||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||
self.max_steps = Some(steps);
|
||||
self
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Workflow engine: interprets `ScriptPrimitive` values (agent, parallel,
|
||||
//! pipeline, phase) by spawning subagents, collecting results, and
|
||||
//! managing concurrency.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -5,6 +9,7 @@ use super::script::{ScriptPrimitive, WorkflowScript};
|
||||
|
||||
static FINDINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The lifecycle state of an agent within a workflow run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentState {
|
||||
Idle,
|
||||
@@ -13,6 +18,7 @@ pub enum AgentState {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Timestamped status of one workflow agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentStatus {
|
||||
pub state: AgentState,
|
||||
@@ -21,6 +27,7 @@ pub struct AgentStatus {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A single agent tracked within a workflow run.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowAgent {
|
||||
pub id: String,
|
||||
@@ -28,6 +35,8 @@ pub struct WorkflowAgent {
|
||||
pub status: AgentStatus,
|
||||
}
|
||||
|
||||
/// Orchestrator for running workflow scripts: holds agent roster and a
|
||||
/// shared finding accumulator visible to all pipeline stages.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkflowEngine {
|
||||
pub agents: Vec<WorkflowAgent>,
|
||||
@@ -35,6 +44,7 @@ pub struct WorkflowEngine {
|
||||
}
|
||||
|
||||
impl WorkflowEngine {
|
||||
/// Create an empty workflow engine with no agents or findings.
|
||||
pub fn new() -> Self {
|
||||
WorkflowEngine {
|
||||
agents: Vec::new(),
|
||||
@@ -43,6 +53,14 @@ impl WorkflowEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a single synchronous subagent with the given prompt, passing it
|
||||
/// any findings from earlier sibling agents.
|
||||
///
|
||||
/// Flow: build an `AgentDefinition` -> build a `SubagentContext` ->
|
||||
/// inject findings into the system prompt -> call `run_subagent` on a
|
||||
/// dedicated mpsc channel.
|
||||
///
|
||||
/// Return: the agent's text output, or an error on failure.
|
||||
fn spawn_single_agent(prompt: &str, findings_snapshot: Vec<String>) -> anyhow::Result<String> {
|
||||
use crate::app::subagent::context::build_subagent_context;
|
||||
use crate::app::subagent::engine::run_subagent;
|
||||
@@ -74,6 +92,20 @@ fn spawn_single_agent(prompt: &str, findings_snapshot: Vec<String>) -> anyhow::R
|
||||
|
||||
type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
||||
|
||||
/// Recursively execute a `ScriptPrimitive` tree, respecting an overall
|
||||
/// concurrency cap for parallel branches.
|
||||
///
|
||||
/// Flow: match the primitive ->
|
||||
/// `Agent` -> `spawn_single_agent`
|
||||
/// `Parallel` -> spawn threads up to `concurrency_cap`, join
|
||||
/// `Pipeline` -> spawn threads sequentially, collect in order
|
||||
/// `Phase` -> recurse (pass-through wrapper)
|
||||
///
|
||||
/// Why: parallelism is implemented with `std::thread::spawn` and a
|
||||
/// counting semaphore so the main async event loop remains unblocked.
|
||||
///
|
||||
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||
/// the order they were submitted.
|
||||
pub fn execute_primitive(
|
||||
primitive: &ScriptPrimitive,
|
||||
args: &HashMap<String, String>,
|
||||
@@ -169,6 +201,14 @@ pub fn execute_primitive(
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a `WorkflowScript` with the given template arguments and produce a
|
||||
/// summary string.
|
||||
///
|
||||
/// Flow: clear the global finding store -> cap concurrency to 5 -> call
|
||||
/// `execute_primitive` on the script's root primitive -> format results
|
||||
/// into a one-line-per-agent summary.
|
||||
///
|
||||
/// Return: a human-readable summary string.
|
||||
pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) -> anyhow::Result<String> {
|
||||
if let Ok(mut findings) = FINDINGS.lock() {
|
||||
findings.clear();
|
||||
@@ -201,12 +241,19 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) ->
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Add a finding text to the global workflow findings list, making it
|
||||
/// visible to sibling agents spawned later in the same run.
|
||||
pub fn note_finding(text: &str) {
|
||||
if let Ok(mut findings) = FINDINGS.lock() {
|
||||
findings.push(text.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple template engine: replace `{{key}}` placeholders with values
|
||||
/// from `args`.
|
||||
///
|
||||
/// Why: a structed template engine is unnecessary for the limited
|
||||
/// use-case; this is intentionally simple and safe.
|
||||
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
|
||||
let mut result = template.to_string();
|
||||
for (key, value) in args {
|
||||
@@ -215,6 +262,9 @@ fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
/// A counting semaphore built from a `Mutex` + `Condvar`.
|
||||
///
|
||||
/// Used by `execute_primitive` to cap concurrent parallel branches.
|
||||
struct Semaphore {
|
||||
count: Mutex<usize>,
|
||||
condvar: std::sync::Condvar,
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
//! Workflow orchestration: a script interpreter that runs pipeline/parallel
|
||||
//! primitives across multiple subagent instances.
|
||||
|
||||
pub mod engine;
|
||||
pub mod script;
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
//! Script primitives for the workflow engine: agent invocation, parallel
|
||||
//! execution, pipelines, and phases.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A workflow script primitive — can be a single agent, a parallel fan-out,
|
||||
/// a sequential pipeline, or a named phase.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ScriptPrimitive {
|
||||
/// Run a single agent with the given prompt template.
|
||||
Agent(String),
|
||||
/// Execute several primitives concurrently.
|
||||
Parallel(Vec<ScriptPrimitive>),
|
||||
/// Execute several primitives sequentially, each waiting for the
|
||||
/// previous to complete.
|
||||
Pipeline(Vec<ScriptPrimitive>),
|
||||
/// A named wrapper around another primitive (used for display/tracing).
|
||||
Phase {
|
||||
name: String,
|
||||
script: Box<ScriptPrimitive>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Runtime options for a workflow execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScriptOptions {
|
||||
pub max_concurrency: usize,
|
||||
@@ -28,6 +39,7 @@ impl Default for ScriptOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// A named, versioned workflow script with its primitives and options.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowScript {
|
||||
pub name: String,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Slash-command parser that maps TUI `/foo` input lines into `Command`
|
||||
//! variants for the action dispatch system.
|
||||
|
||||
use crate::app::mode::ModeKind;
|
||||
|
||||
/// A parsed slash command from the TUI input buffer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
Help,
|
||||
@@ -23,6 +27,14 @@ pub enum Command {
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// Parse a slash-prefixed input line into a `Command` value.
|
||||
///
|
||||
/// Flow: trim -> check for leading `/` -> split on space (max 3 parts) ->
|
||||
/// match the first token against known commands -> extract arguments from
|
||||
/// the remaining parts.
|
||||
///
|
||||
/// Why: early return `Unknown` for non-slash lines so the caller can treat
|
||||
/// them as regular chat input.
|
||||
pub fn parse_command(text: &str) -> Command {
|
||||
let text = text.trim();
|
||||
if !text.starts_with('/') {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action`
|
||||
//! variants, with special handling for overlays, auto-complete, and the
|
||||
//! inline editor.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::app::mode;
|
||||
@@ -7,6 +11,16 @@ use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
use crate::controller::command::parse_command;
|
||||
|
||||
/// Translate a terminal `KeyEvent` into zero or more `Action` values
|
||||
/// based on the current application state.
|
||||
///
|
||||
/// Flow: check overlay first (Editor gets its own handler) -> match on
|
||||
/// key code and modifiers -> handle auto-complete cycles -> dispatch to
|
||||
/// `Action` variants or overlay-specific handlers.
|
||||
///
|
||||
/// Why: when Editor overlay is active, all key events are consumed by the
|
||||
/// editor handler and never reach the main action dispatch. Return `Vec`
|
||||
/// so that a single key press can trigger multiple actions.
|
||||
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
// While Editor overlay is active, route input directly to the editor handler
|
||||
if state.misc.overlay == Overlay::Editor {
|
||||
@@ -192,6 +206,12 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle pressing Enter while a modal overlay is active: dispatch
|
||||
/// overlay-specific submit logic (bash, settings, todo, quit, etc.).
|
||||
///
|
||||
/// Flow: match the current overlay -> run the associated handler ->
|
||||
/// mutate state or produce actions as needed -> always return `Vec::new()`
|
||||
/// (the handler itself applies state mutations).
|
||||
fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
||||
match state.misc.overlay {
|
||||
Overlay::Bash => {
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
//! Keyboard input handling and command parsing for the TUI.
|
||||
|
||||
pub mod command;
|
||||
pub mod input;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Chat message types shared across the DTO layer: `Role` and `ChatMessage`
|
||||
//! with convenience constructors.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The conversation participant who authored a message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Role {
|
||||
#[serde(rename = "user")]
|
||||
@@ -15,6 +19,8 @@ pub enum Role {
|
||||
impl Role {
|
||||
}
|
||||
|
||||
/// A single message in a conversation, compatible with the OpenAI/Anthropic
|
||||
/// chat-completion API structures.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: Role,
|
||||
@@ -28,6 +34,7 @@ pub struct ChatMessage {
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// Build a user-role message with the given text content.
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::User,
|
||||
@@ -38,6 +45,7 @@ impl ChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an assistant-role message with an optional text response.
|
||||
pub fn assistant(content: Option<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Assistant,
|
||||
@@ -48,6 +56,7 @@ impl ChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a system-role message with the given instruction text.
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::System,
|
||||
@@ -58,6 +67,7 @@ impl ChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a tool-role result message referencing a prior tool call.
|
||||
pub fn tool_result(tool_call_id: String, content: String) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Tool,
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
//! Chat DTO submodules: message roles/content and tool-call structures.
|
||||
|
||||
pub mod message;
|
||||
pub mod tool;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
//! Tool-call DTOs embedded in assistant chat messages.
|
||||
//!
|
||||
//! Flow: provider response/stream carries `tool_calls` on an assistant
|
||||
//! message → deserialized into `ToolCall`/`ToolFunction` → harness resolves
|
||||
//! `function.name` against `all_tools()` and runs it with
|
||||
//! `sanitize_tool_arguments(function.arguments)`.
|
||||
//!
|
||||
//! Why: kept separate from `dto::provider` because tool calls are a property
|
||||
//! of a chat *message*, not of the request/response envelope.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// A single tool-call request emitted by the model in an assistant message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
@@ -9,12 +20,23 @@ pub struct ToolCall {
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
/// The function name and raw arguments payload for a `ToolCall`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// Normalize tool-call arguments into a JSON object/value.
|
||||
///
|
||||
/// Flow: some providers send `arguments` as a JSON-encoded string rather
|
||||
/// than a nested object; if `args` is a string, attempt to parse it as
|
||||
/// JSON. Objects and other value types pass through unchanged.
|
||||
///
|
||||
/// Why: falling back to the raw string on parse failure (rather than
|
||||
/// erroring) keeps the harness resilient to malformed provider output.
|
||||
///
|
||||
/// Return: the parsed `Value`, or the original `args` clone if parsing fails.
|
||||
pub fn sanitize_tool_arguments(args: &Value) -> Value {
|
||||
match args {
|
||||
Value::String(s) => {
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
//! Data transfer objects shared across the app: chat messages/tool calls
|
||||
//! and provider request/response/usage shapes.
|
||||
|
||||
pub mod chat;
|
||||
pub mod provider;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Provider-facing DTOs: chat completion request, response, and usage/cost.
|
||||
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod usage;
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
//! Outbound request DTOs for the OpenAI/Anthropic-compatible chat completions API.
|
||||
//!
|
||||
//! Flow: `harness`/`runtime` builds a `ChatRequest` from conversation state and
|
||||
//! the active tool set → serializes to JSON via `serde` → sends to the
|
||||
//! provider's `/chat/completions`-style endpoint (streaming or not).
|
||||
//!
|
||||
//! Why: fields mirror the wire format exactly (including `#[serde(rename)]`
|
||||
//! for reserved words like `type`) so no manual (de)serialization glue is
|
||||
//! needed; optional fields use `skip_serializing_if` so unset knobs are
|
||||
//! omitted rather than sent as `null`, matching provider expectations.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible provider.
|
||||
///
|
||||
/// Flow: constructed from the current message history plus optional
|
||||
/// generation knobs (temperature, max_tokens, tools, etc.) and serialized
|
||||
/// directly into the HTTP request body.
|
||||
///
|
||||
/// Return: not a function, but the value that becomes the JSON request
|
||||
/// payload for a completion call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
pub model: String,
|
||||
@@ -21,11 +40,21 @@ pub struct ChatRequest {
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
}
|
||||
|
||||
/// Streaming options for the request; `include_usage` asks the provider to
|
||||
/// emit a final usage chunk in the SSE stream.
|
||||
///
|
||||
/// Why: usage tokens are otherwise unavailable in a streamed response since
|
||||
/// they're normally only attached to the final non-streamed completion.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamOptions {
|
||||
pub include_usage: bool,
|
||||
}
|
||||
|
||||
/// Wire format for a single tool definition sent to the provider.
|
||||
///
|
||||
/// Flow: built from the harness's registered `Tool` impls (see `all_tools()`)
|
||||
/// and attached to `ChatRequest.tools` so the model knows which functions it
|
||||
/// may call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDef {
|
||||
#[serde(rename = "type")]
|
||||
@@ -33,6 +62,10 @@ pub struct ToolDef {
|
||||
pub function: ToolFunctionDef,
|
||||
}
|
||||
|
||||
/// Name, description, and JSON schema parameters for a tool definition.
|
||||
///
|
||||
/// Why: `parameters` is a raw `serde_json::Value` rather than a typed struct
|
||||
/// because each tool defines its own arbitrary JSON schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunctionDef {
|
||||
pub name: String,
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
//! Inbound response DTOs for the non-streaming chat completions API.
|
||||
//!
|
||||
//! Flow: provider HTTP response body → `serde_json` deserializes into
|
||||
//! `ChatResponse` → caller reads `choices[0].message` for the assistant
|
||||
//! reply and `usage` for token accounting.
|
||||
//!
|
||||
//! Why: separate from the streaming SSE path (see `app/runtime/stream/mod.rs`),
|
||||
//! which parses incremental deltas rather than a single complete payload.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Non-streaming chat completion response returned by the provider.
|
||||
///
|
||||
/// Flow: deserialized directly from the HTTP response body of a
|
||||
/// non-streaming completion call.
|
||||
///
|
||||
/// Return: not a function, but the value callers inspect for the model's
|
||||
/// reply (`choices`) and token usage (`usage`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
pub id: String,
|
||||
@@ -9,6 +25,10 @@ pub struct ChatResponse {
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
/// One completion candidate within a `ChatResponse.choices` list.
|
||||
///
|
||||
/// Why: `finish_reason` is optional/string-typed since providers vary in
|
||||
/// what values they emit (e.g. `"stop"`, `"tool_calls"`, `"length"`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Choice {
|
||||
pub index: u32,
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
//! Token usage accounting DTO shared by streaming and non-streaming responses.
|
||||
//!
|
||||
//! Flow: populated from the provider's `usage` object (either the final SSE
|
||||
//! chunk when `stream_options.include_usage` is set, or the `usage` field of
|
||||
//! a non-streaming `ChatResponse`) → surfaced to the TUI for cost/token
|
||||
//! display.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Token counts and optional cost breakdown for a single completion request.
|
||||
///
|
||||
/// Why: all fields are optional because providers differ in what they
|
||||
/// report — some omit per-token cost entirely, others omit usage altogether
|
||||
/// on certain response paths. `Default` lets callers start from an empty
|
||||
/// usage record when a provider sends none.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: Option<u32>,
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
//! Unix-socket client used by the `--attach` process to talk to a
|
||||
//! running `--daemon`.
|
||||
//!
|
||||
//! Flow: `IpcClient::connect_unix` opens a `Connection` (see `conn.rs`)
|
||||
//! to the daemon's socket path → `send`/`receive` exchange framed JSON
|
||||
//! messages (typically `ClientRequest`/`DaemonFrame` from `protocol.rs`).
|
||||
|
||||
use anyhow::Result;
|
||||
use super::conn::Connection;
|
||||
|
||||
/// Client-side handle for the `--attach` process: wraps a `Connection`
|
||||
/// to a daemon's Unix socket.
|
||||
pub struct IpcClient {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl IpcClient {
|
||||
/// Connect to a daemon listening on the given Unix socket path.
|
||||
///
|
||||
/// Return: `Ok(IpcClient)` on success, or an error if the socket is
|
||||
/// missing or the daemon isn't accepting connections.
|
||||
pub fn connect_unix(path: &str) -> Result<Self> {
|
||||
let conn = Connection::connect_unix(path)?;
|
||||
Ok(IpcClient { conn })
|
||||
}
|
||||
|
||||
/// Serialize and send a value to the daemon (see `frame::write_frame`).
|
||||
pub fn send<T: serde::Serialize>(&mut self, value: &T) -> Result<()> {
|
||||
self.conn.send(value)
|
||||
}
|
||||
|
||||
/// Read and deserialize the next frame from the daemon.
|
||||
///
|
||||
/// Return: `Ok(None)` if the daemon closed the connection cleanly.
|
||||
pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> Result<Option<T>> {
|
||||
self.conn.receive()
|
||||
}
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
//! Framed Unix-socket connection shared by both the server (`server.rs`)
|
||||
//! and client (`client.rs`) sides of the IPC layer.
|
||||
//!
|
||||
//! Flow: `Connection` wraps a `UnixStream` (either accepted by the server
|
||||
//! or dialed by the client) → `send` serializes a value to JSON and
|
||||
//! writes it as one length-prefixed frame (`frame::write_frame`) →
|
||||
//! `receive` reads one frame and deserializes it back to the caller's
|
||||
//! type, propagating a clean peer-close as `Ok(None)`.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use anyhow::Result;
|
||||
use super::frame;
|
||||
|
||||
/// A framed Unix-socket connection shared by client and server sides of
|
||||
/// the IPC layer; each `send`/`receive` moves one length-prefixed JSON frame.
|
||||
pub struct Connection {
|
||||
inner: UnixStream,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// Wrap an already-connected/accepted `UnixStream`.
|
||||
pub fn from_stream(stream: UnixStream) -> Result<Self> {
|
||||
Ok(Connection { inner: stream })
|
||||
}
|
||||
|
||||
/// Open a new Unix-socket connection to `path`.
|
||||
pub fn connect_unix(path: &str) -> Result<Self> {
|
||||
let stream = UnixStream::connect(path)?;
|
||||
Ok(Connection { inner: stream })
|
||||
}
|
||||
|
||||
/// Serialize `value` to JSON and write it as one length-prefixed frame.
|
||||
pub fn send<T: serde::Serialize>(&mut self, value: &T) -> Result<()> {
|
||||
let data = frame::serialize_frame(value)?;
|
||||
frame::write_frame(&mut self.inner, &data)
|
||||
}
|
||||
|
||||
/// Read one length-prefixed frame and deserialize it as `T`.
|
||||
///
|
||||
/// Return: `Ok(None)` on clean EOF (peer closed the connection).
|
||||
pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> Result<Option<T>> {
|
||||
let data = frame::read_frame(&mut self.inner)?;
|
||||
match data {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
//! Field-level diffing of JSON app-state snapshots, for sending only
|
||||
//! incremental changes over IPC instead of a full `StateSnapshot`.
|
||||
//!
|
||||
//! Flow: `compute_diff` recursively walks two JSON `Value`s (before/after)
|
||||
//! → for objects, recurses per key building a dotted path string; any
|
||||
//! other mismatch is recorded wholesale → results accumulate into a
|
||||
//! `StateDiff`'s `Vec<Change>`, built via `StateDiff::new`/`add_change`
|
||||
//! and reset via `clear`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// A timestamped batch of field-level changes to app state, keyed by
|
||||
/// dotted JSON path, for a given session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateDiff {
|
||||
pub timestamp: i64,
|
||||
@@ -8,6 +19,7 @@ pub struct StateDiff {
|
||||
pub changes: Vec<Change>,
|
||||
}
|
||||
|
||||
/// A single field change: the JSON path and its old/new values.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Change {
|
||||
pub path: String,
|
||||
@@ -16,6 +28,7 @@ pub struct Change {
|
||||
}
|
||||
|
||||
impl StateDiff {
|
||||
/// Create an empty diff for `session_id`, timestamped at creation.
|
||||
pub fn new(session_id: String) -> Self {
|
||||
StateDiff {
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
@@ -24,6 +37,7 @@ impl StateDiff {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a single field change to the diff.
|
||||
pub fn add_change(&mut self, path: String, old_value: Option<Value>, new_value: Option<Value>) {
|
||||
self.changes.push(Change {
|
||||
path,
|
||||
@@ -32,16 +46,28 @@ impl StateDiff {
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the diff has no recorded changes.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changes.is_empty()
|
||||
}
|
||||
|
||||
/// Drop all changes and refresh the timestamp.
|
||||
pub fn clear(&mut self) {
|
||||
self.changes.clear();
|
||||
self.timestamp = chrono::Utc::now().timestamp_millis();
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively diff two JSON values, appending field-level `Change`s.
|
||||
///
|
||||
/// Flow: equal values short-circuit → for two objects, recurse per key
|
||||
/// (union of both maps' keys, missing side treated as `Null`) building
|
||||
/// a dotted `path` → any other value-type mismatch (or non-object diff)
|
||||
/// is recorded as one `Change` at the current `path`.
|
||||
///
|
||||
/// Why: only objects are diffed structurally; arrays and scalars are
|
||||
/// compared wholesale so a change anywhere inside them replaces the
|
||||
/// whole value rather than producing an index-level diff.
|
||||
pub fn compute_diff(before: &Value, after: &Value, path: &str, changes: &mut Vec<Change>) {
|
||||
if before == after {
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
//! Length-prefixed binary framing and JSON (de)serialization helpers for
|
||||
//! the IPC wire protocol.
|
||||
//!
|
||||
//! Flow: `write_frame`/`read_frame` handle the raw byte-level framing
|
||||
//! (4-byte big-endian length header + payload) over any `Read`/`Write`;
|
||||
//! `serialize_frame`/`deserialize_frame` handle the JSON layer on top.
|
||||
//! `Connection` (see `conn.rs`) composes both layers for a full send/receive.
|
||||
//!
|
||||
//! Why: a fixed-size length prefix lets the reader know exactly how many
|
||||
//! bytes to pull before attempting to parse, avoiding partial-JSON reads
|
||||
//! over a stream socket.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Upper bound on a single frame's byte size (64 MiB), enforced on both
|
||||
/// the write and read paths to bound memory use and reject malformed or
|
||||
/// malicious oversized length headers.
|
||||
pub(crate) const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Write `data` as a length-prefixed frame: 4-byte big-endian length
|
||||
/// followed by the raw bytes, then flush.
|
||||
///
|
||||
/// Why: rejects frames over `MAX_FRAME_SIZE` to bound memory use on the
|
||||
/// reading side before any bytes are read.
|
||||
pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> {
|
||||
let len = data.len();
|
||||
if len > MAX_FRAME_SIZE {
|
||||
@@ -15,6 +35,14 @@ pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read one length-prefixed frame written by `write_frame`.
|
||||
///
|
||||
/// Flow: read 4-byte length header → on clean EOF before any bytes,
|
||||
/// return `Ok(None)` (peer closed) → validate against `MAX_FRAME_SIZE`
|
||||
/// → read the payload.
|
||||
///
|
||||
/// Return: `Ok(None)` signals a graceful connection close, distinct
|
||||
/// from an `Err` mid-frame I/O failure.
|
||||
pub fn read_frame<R: Read>(reader: &mut R) -> Result<Option<Vec<u8>>> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
match reader.read_exact(&mut len_buf) {
|
||||
@@ -31,6 +59,7 @@ pub fn read_frame<R: Read>(reader: &mut R) -> Result<Option<Vec<u8>>> {
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
/// Serialize `value` to JSON bytes, rejecting output over `MAX_FRAME_SIZE`.
|
||||
pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(value)?;
|
||||
if json.len() > MAX_FRAME_SIZE {
|
||||
@@ -39,6 +68,7 @@ pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Deserialize a frame's raw JSON bytes into `T`.
|
||||
pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> {
|
||||
Ok(serde_json::from_slice(data)?)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Unix-socket IPC layer used to connect a `--attach` TUI client to a
|
||||
//! `--daemon` process: length-prefixed framing, connection wrapper,
|
||||
//! client/server handles, and the wire protocol types.
|
||||
|
||||
pub mod client;
|
||||
pub mod conn;
|
||||
pub mod frame;
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
//! Wire message types exchanged between an attached client and the
|
||||
//! daemon over the `Connection`/framing layer (`conn.rs`, `frame.rs`).
|
||||
//!
|
||||
//! Flow: client input events are captured as `KeyAction`/`ClientRequest`
|
||||
//! and sent to the daemon → the daemon applies them to its `AppStateRest`
|
||||
//! and replies with `DaemonFrame` variants (a flattened `StatePayload`
|
||||
//! for redraw, streamed tokens, system notes, or a close signal).
|
||||
//!
|
||||
//! Why: `StatePayload`/`MessageEntry`/`ToastEntry` are deliberately flat,
|
||||
//! serializable projections of daemon-side state so the client can
|
||||
//! redraw its TUI without sharing any in-process state with the daemon.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Wire-serializable subset of `crossterm::event::KeyCode`, sent from
|
||||
/// an attached client to the daemon over IPC.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum KeyAction {
|
||||
Char(char),
|
||||
@@ -19,6 +33,8 @@ pub enum KeyAction {
|
||||
Function(u8),
|
||||
}
|
||||
|
||||
/// Messages an attached client sends to the daemon: input events, a
|
||||
/// full-line submit, terminal resize, and connection lifecycle.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClientRequest {
|
||||
Tick,
|
||||
@@ -33,6 +49,7 @@ pub enum ClientRequest {
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Flattened chat message sent from daemon to client for transcript display.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageEntry {
|
||||
pub role: String,
|
||||
@@ -40,6 +57,7 @@ pub struct MessageEntry {
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
/// Flattened toast notification sent from daemon to client for rendering.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToastEntry {
|
||||
pub kind: String,
|
||||
@@ -48,6 +66,8 @@ pub struct ToastEntry {
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
/// Snapshot of daemon-side `AppStateRest` sent to the client after every
|
||||
/// action, enough for the client to redraw its TUI without shared state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatePayload {
|
||||
pub session_id: String,
|
||||
@@ -61,6 +81,7 @@ pub struct StatePayload {
|
||||
pub input_cursor: usize,
|
||||
}
|
||||
|
||||
/// Messages the daemon sends back to an attached client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DaemonFrame {
|
||||
StateUpdate(Box<StatePayload>),
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
//! Unix-socket listener for the `--daemon` process.
|
||||
//!
|
||||
//! Flow: `IpcServer::bind_unix` opens/binds a Unix socket at a well-known
|
||||
//! path (clearing any stale file left by a crashed prior daemon) →
|
||||
//! `accept` blocks for the next client and wraps it as a `Connection`
|
||||
//! (see `conn.rs`) for framed request/response traffic.
|
||||
|
||||
use std::os::unix::net::UnixListener;
|
||||
use anyhow::Result;
|
||||
use super::conn::Connection;
|
||||
|
||||
/// Server-side handle for the `--daemon` process: listens on a Unix
|
||||
/// socket and hands out `Connection`s to accepted clients.
|
||||
pub struct IpcServer {
|
||||
listener: UnixListener,
|
||||
}
|
||||
|
||||
impl IpcServer {
|
||||
/// Bind a new Unix-socket listener at `path`.
|
||||
///
|
||||
/// Why: removes any stale socket file at `path` first, since a prior
|
||||
/// crashed daemon can leave one behind and `UnixListener::bind` fails
|
||||
/// on an existing path.
|
||||
pub fn bind_unix(path: &str) -> Result<Self> {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let listener = UnixListener::bind(path)?;
|
||||
Ok(IpcServer { listener })
|
||||
}
|
||||
|
||||
/// Block until a client connects, then wrap it as a `Connection`.
|
||||
pub fn accept(&self) -> Result<Connection> {
|
||||
let (stream, _addr) = self.listener.accept()?;
|
||||
Connection::from_stream(stream)
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
//! Point-in-time state snapshots for external inspection/persistence of
|
||||
//! a running session (distinct from the incremental `StateDiff` in
|
||||
//! `diff.rs`).
|
||||
//!
|
||||
//! Flow: `StateSnapshot::new` builds an empty, `dirty`-marked snapshot →
|
||||
//! callers populate/replace its fields as state changes →
|
||||
//! `serialize_snapshot`/`deserialize_snapshot` move it to/from JSON bytes
|
||||
//! for storage or IPC transport.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Point-in-time summary of app state (mode, session, counts, arbitrary
|
||||
/// `payload`) used for external inspection/persistence of a running session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateSnapshot {
|
||||
pub timestamp: i64,
|
||||
@@ -15,6 +26,7 @@ pub struct StateSnapshot {
|
||||
}
|
||||
|
||||
impl StateSnapshot {
|
||||
/// Build a fresh, empty snapshot marked `dirty` for the given session.
|
||||
pub fn new(session_id: String, mode: String, model: String) -> Self {
|
||||
StateSnapshot {
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
@@ -30,11 +42,13 @@ impl StateSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a `StateSnapshot` to JSON bytes.
|
||||
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
|
||||
let data = serde_json::to_vec(snapshot)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Deserialize JSON bytes back into a `StateSnapshot`.
|
||||
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
|
||||
let snapshot: StateSnapshot = serde_json::from_slice(data)?;
|
||||
Ok(snapshot)
|
||||
|
||||
+102
@@ -1,3 +1,10 @@
|
||||
//! Zesdex binary entry point.
|
||||
//!
|
||||
//! Parses `--daemon` / `--attach <id>` flags to select one of three
|
||||
//! process modes (single-process TUI+agent, background daemon, or
|
||||
//! attach-only TUI client), sets up file logging, and runs the
|
||||
//! corresponding event loop.
|
||||
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::sync::Mutex;
|
||||
@@ -17,6 +24,16 @@ mod tool;
|
||||
mod resources;
|
||||
mod view;
|
||||
|
||||
/// Process entry point: parse CLI flags, initialize logging, then dispatch
|
||||
/// to single-process, daemon, or attach mode.
|
||||
///
|
||||
/// Flow: parse `--daemon`/`--attach <id>` from argv → create/open the log
|
||||
/// file under the platform data dir (falling back to `/dev/null` if that
|
||||
/// fails, so a broken log path can't crash the TUI) → init tracing →
|
||||
/// reject `--daemon` + `--attach` together → dispatch.
|
||||
///
|
||||
/// Why: logging is routed to a file (never stderr/stdout) because writing
|
||||
/// to the terminal while ratatui owns the alternate screen corrupts the UI.
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let is_daemon = args.iter().any(|a| a == "--daemon");
|
||||
@@ -61,6 +78,17 @@ fn main() -> Result<()> {
|
||||
run_single_process()
|
||||
}
|
||||
|
||||
/// Run zesdex as a self-contained TUI + agent loop in one process.
|
||||
///
|
||||
/// Flow: create the store, a fresh session dir, and take an exclusive
|
||||
/// session lock → build `AppStateRest` → enter raw mode / alternate
|
||||
/// screen → run the event loop → always restore the terminal (even on
|
||||
/// error) → save settings and release the session lock.
|
||||
///
|
||||
/// Why: the session lock prevents two zesdex processes from concurrently
|
||||
/// writing the same session directory. Terminal restoration happens
|
||||
/// outside `run_loop`'s `Result` so a panicking/erroring loop still
|
||||
/// leaves the user's terminal usable.
|
||||
fn run_single_process() -> Result<()> {
|
||||
let store = model::store::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
@@ -110,6 +138,11 @@ fn run_single_process() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map a `crossterm` key code to the wire-serializable `KeyAction`, for
|
||||
/// sending key input from an attached client to the daemon.
|
||||
///
|
||||
/// Return: `None` for key codes with no `KeyAction` equivalent (e.g.
|
||||
/// media keys), which are silently dropped.
|
||||
fn key_code_to_action(code: crossterm::event::KeyCode) -> Option<ipc::protocol::KeyAction> {
|
||||
use crossterm::event::KeyCode;
|
||||
match code {
|
||||
@@ -132,6 +165,9 @@ fn key_code_to_action(code: crossterm::event::KeyCode) -> Option<ipc::protocol::
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `key_code_to_action`: reconstruct a `crossterm::KeyCode`
|
||||
/// from a `KeyAction` received over IPC, for replaying it into the
|
||||
/// daemon's normal key-handling path.
|
||||
fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::KeyCode {
|
||||
use crossterm::event::KeyCode;
|
||||
match action {
|
||||
@@ -153,6 +189,15 @@ fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::Ke
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten the daemon's `AppStateRest` into a `StatePayload` and send it
|
||||
/// to the attached client as a `DaemonFrame::StateUpdate`.
|
||||
///
|
||||
/// Flow: map transcript messages/toasts to their wire DTOs → derive the
|
||||
/// active overlay name (or `None` if no overlay is active) → build and
|
||||
/// send one `DaemonFrame`.
|
||||
///
|
||||
/// Why: the client never shares memory with the daemon, so every action
|
||||
/// on the daemon side is followed by a full state push rather than a diff.
|
||||
fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> {
|
||||
use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload};
|
||||
|
||||
@@ -194,6 +239,17 @@ fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest
|
||||
conn.send(&frame)
|
||||
}
|
||||
|
||||
/// Apply a `StatePayload` received from the daemon onto the client's
|
||||
/// local `AppStateRest`, so the attach-mode TUI can render it.
|
||||
///
|
||||
/// Flow: copy scalar fields directly → rebuild the transcript cache from
|
||||
/// `MessageEntry`s (mapping role strings back to the `Role` enum) →
|
||||
/// resolve the overlay name string to an `Overlay` variant → rebuild
|
||||
/// toasts from `ToastEntry`s.
|
||||
///
|
||||
/// Why: unrecognized role/overlay/toast-kind strings fall back to a safe
|
||||
/// default (`Role::User`, `Overlay::None`, `ToastKind::Info`) rather than
|
||||
/// panicking, so a protocol/version mismatch degrades gracefully.
|
||||
fn apply_client_update(
|
||||
state: &mut app::state::rest::AppStateRest,
|
||||
payload: ipc::protocol::StatePayload,
|
||||
@@ -260,6 +316,20 @@ fn apply_client_update(
|
||||
state.input.cursor = payload.input_cursor;
|
||||
}
|
||||
|
||||
/// Run zesdex as a background daemon: owns the agent state, listens on a
|
||||
/// per-session Unix socket, and drives one attached client.
|
||||
///
|
||||
/// Flow: create session + lock it → bind a Unix socket under
|
||||
/// `<store>/run/<session_id>.sock` → block for a single client to
|
||||
/// `accept()` → loop reading `ClientRequest`s, translating each into
|
||||
/// `Action`(s) via the same `controller::input`/`apply_action` path the
|
||||
/// single-process mode uses, then pushing a full state update back →
|
||||
/// on `Close` or client disconnect, clean up the socket file, save
|
||||
/// settings, and release the lock.
|
||||
///
|
||||
/// Why: reuses `controller::input::handle_key` by synthesizing a
|
||||
/// `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and
|
||||
/// single-process modes share identical key-handling logic.
|
||||
fn run_daemon() -> Result<()> {
|
||||
use app::runtime::actions::{Action, apply_action};
|
||||
use ipc::protocol::ClientRequest;
|
||||
@@ -362,6 +432,19 @@ fn run_daemon() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run zesdex as a TUI-only client attached to an existing daemon session.
|
||||
///
|
||||
/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate
|
||||
/// screen → build a local `AppStateRest` mirror (only used for rendering
|
||||
/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a
|
||||
/// terminal event (key/resize) and forward it as a `ClientRequest`, or
|
||||
/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and
|
||||
/// apply it via `apply_client_update` → redraw → exit when the daemon
|
||||
/// closes or the user quits (sending `ClientRequest::Close` first).
|
||||
///
|
||||
/// Why: Ctrl+C is intercepted locally to quit the client without going
|
||||
/// through the daemon, since the daemon has no notion of "this client
|
||||
/// wants to leave" beyond the explicit `Close` request.
|
||||
fn run_attach(session_id: &str) -> Result<()> {
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ipc::protocol::ClientRequest;
|
||||
@@ -467,6 +550,14 @@ fn run_attach(session_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the single-process event loop, guaranteeing terminal restoration
|
||||
/// on error.
|
||||
///
|
||||
/// Flow: delegate to `run_loop_inner` → if it errors, clear the screen
|
||||
/// and tear down raw mode / alternate screen before propagating the error.
|
||||
///
|
||||
/// Why: without this wrapper, an error inside the loop would leave the
|
||||
/// user's terminal in raw/alternate-screen mode after the process exits.
|
||||
fn run_loop(
|
||||
state: &mut app::state::rest::AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
@@ -481,6 +572,17 @@ fn run_loop(
|
||||
result
|
||||
}
|
||||
|
||||
/// The core single-process render/input loop.
|
||||
///
|
||||
/// Flow: until `state.quit` → drain expired toasts → draw the frame →
|
||||
/// poll for a terminal event with a 50ms timeout (keys go through
|
||||
/// `handle_key` → `apply_action`; resize and scroll map to `Action`
|
||||
/// variants directly) → always fire `Action::Tick` each iteration
|
||||
/// (drives streaming/background progress) → on exit, clear the terminal.
|
||||
///
|
||||
/// Why: the 50ms poll timeout bounds input latency while still yielding
|
||||
/// regularly for the `Tick` action, which drives async work like LLM
|
||||
/// streaming without a separate polling thread.
|
||||
fn run_loop_inner(
|
||||
state: &mut app::state::rest::AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner).
|
||||
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
/// Build the fixed list of built-in agent definitions shipped with zesdex.
|
||||
///
|
||||
/// Flow: construct each `AgentDefinition` with a name, system prompt, and
|
||||
/// allowed tool list, then collect into a `Vec`.
|
||||
///
|
||||
/// Why: these agents are always available regardless of global/session
|
||||
/// config, giving users a baseline set of roles out of the box.
|
||||
///
|
||||
/// Return: a freshly-built `Vec<AgentDefinition>` (coder, reviewer,
|
||||
/// researcher, planner).
|
||||
pub fn builtin_agents() -> Vec<AgentDefinition> {
|
||||
vec![
|
||||
AgentDefinition::new(
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
//! Load, save, and remove user-defined agent definitions stored globally
|
||||
//! (under the store's `agents/` directory), independent of any session.
|
||||
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
/// Load all globally-registered agent definitions from disk.
|
||||
///
|
||||
/// Flow: resolve `<store>/agents/` → read directory → parse each `*.json`
|
||||
/// file into an `AgentDefinition`, skipping any that fail to read or parse.
|
||||
///
|
||||
/// Why: missing directory or unreadable/invalid files are silently
|
||||
/// skipped rather than failing the whole load, so one corrupt file
|
||||
/// doesn't break agent loading.
|
||||
///
|
||||
/// Return: a `Vec<AgentDefinition>`, empty if the directory doesn't exist
|
||||
/// or contains no valid definitions.
|
||||
pub fn load_global_agents() -> Vec<AgentDefinition> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
@@ -22,6 +36,16 @@ pub fn load_global_agents() -> Vec<AgentDefinition> {
|
||||
agents
|
||||
}
|
||||
|
||||
/// Persist a global agent definition as `<store>/agents/<name>.json`.
|
||||
///
|
||||
/// Flow: ensure the `agents/` directory exists → serialize `def` to
|
||||
/// pretty JSON → write to a file named after `def.name`.
|
||||
///
|
||||
/// Why: writing by name overwrites any existing definition with the
|
||||
/// same name, acting as an upsert.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an error if directory creation,
|
||||
/// serialization, or the write fails.
|
||||
pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
@@ -32,6 +56,14 @@ pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a global agent definition by name, if it exists.
|
||||
///
|
||||
/// Flow: resolve `<store>/agents/<name>.json` → remove the file if present.
|
||||
///
|
||||
/// Why: a no-op (not an error) when the file is already absent.
|
||||
///
|
||||
/// Return: `Ok(())` whether or not the file existed; `Err` only on an
|
||||
/// actual filesystem removal failure.
|
||||
pub fn remove_global_agent(name: &str) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let path = store.base_dir.join("agents").join(format!("{}.json", name));
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Agent definition sources: built-in defaults, global (user-wide), and
|
||||
//! per-session overrides.
|
||||
|
||||
pub mod builtin;
|
||||
pub mod global;
|
||||
pub mod session;
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
//! Load, save, add, and remove agent definitions scoped to a single
|
||||
//! session (`<session_dir>/agents.json`).
|
||||
|
||||
use std::path::Path;
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
/// Load agent definitions saved for a specific session.
|
||||
///
|
||||
/// Flow: check `<session_dir>/agents.json` exists → read → JSON-decode
|
||||
/// into `Vec<AgentDefinition>`.
|
||||
///
|
||||
/// Why: a missing file or a parse failure both degrade gracefully to an
|
||||
/// empty list (parse errors are logged via `tracing::warn!`), so a
|
||||
/// corrupt session file doesn't crash agent loading.
|
||||
///
|
||||
/// Return: the session's agent definitions, or an empty `Vec` if none
|
||||
/// exist or the file is malformed.
|
||||
pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
if !agents_file.exists() {
|
||||
@@ -17,6 +31,13 @@ pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite `<session_dir>/agents.json` with the given agent list.
|
||||
///
|
||||
/// Flow: serialize `agents` to pretty JSON → write to
|
||||
/// `<session_dir>/agents.json`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an error if serialization or the
|
||||
/// write fails.
|
||||
pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
let content = serde_json::to_string_pretty(agents)?;
|
||||
@@ -24,6 +45,14 @@ pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> an
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add or replace a session agent definition by name.
|
||||
///
|
||||
/// Flow: load existing session agents → drop any with the same name as
|
||||
/// `def` → push `def` → save the updated list.
|
||||
///
|
||||
/// Why: name-based dedup makes this an upsert rather than an append.
|
||||
///
|
||||
/// Return: `Ok(())` on success, propagating any load/save error.
|
||||
pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Result<()> {
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != def.name);
|
||||
@@ -31,6 +60,12 @@ pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Re
|
||||
save_session_agents(session_dir, &agents)
|
||||
}
|
||||
|
||||
/// Remove a session agent definition by name, if present.
|
||||
///
|
||||
/// Flow: load existing session agents → filter out entries matching
|
||||
/// `name` → save the updated list.
|
||||
///
|
||||
/// Return: `Ok(())` whether or not an entry with `name` existed.
|
||||
pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<()> {
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != name);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
//! Application-level configuration: LLM providers, model roles, and defaults,
|
||||
//! persisted to `app_config.json` in the store directory.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Top-level application config: registered providers, named model roles,
|
||||
/// and which provider/model to use by default.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub providers: HashMap<String, ProviderConfig>,
|
||||
@@ -9,6 +14,7 @@ pub struct AppConfig {
|
||||
pub default_model: String,
|
||||
}
|
||||
|
||||
/// Connection details for a single LLM provider (base URL, API key source).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_base: String,
|
||||
@@ -17,6 +23,8 @@ pub struct ProviderConfig {
|
||||
pub default_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// A named role (e.g. "default") mapping to a specific provider/model and
|
||||
/// its generation parameters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelRole {
|
||||
pub provider: String,
|
||||
@@ -57,6 +65,17 @@ impl Default for AppConfig {
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Load app config from disk, falling back to defaults on any failure.
|
||||
///
|
||||
/// Flow: read `<store>/app_config.json` → JSON-parse → on missing file
|
||||
/// or parse error, use `Self::default()` → merge any default providers
|
||||
/// not already present in the loaded config.
|
||||
///
|
||||
/// Why: the merge step lets newly-added default providers (e.g. a new
|
||||
/// release adding a provider) appear even in configs saved by older
|
||||
/// versions, without clobbering user-edited entries with the same name.
|
||||
///
|
||||
/// Return: a fully-populated `AppConfig`, never fails.
|
||||
pub fn load() -> Self {
|
||||
let store = super::store::Store::new();
|
||||
let path = store.base_dir.join("app_config.json");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! In-memory conversation state: message history plus the system prompt and
|
||||
//! model parameters used to drive the LLM.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single conversation's message history and generation settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
|
||||
@@ -11,6 +15,8 @@ pub struct Conversation {
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
/// Create an empty conversation with the given system prompt and
|
||||
/// session id, using default model/token/temperature settings.
|
||||
pub fn new(system_prompt: String, session_id: String) -> Self {
|
||||
Conversation {
|
||||
messages: Vec::new(),
|
||||
@@ -22,10 +28,17 @@ impl Conversation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message to the conversation history.
|
||||
pub fn push(&mut self, msg: crate::dto::chat::message::ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
/// Replace the system prompt and strip any prior `System`-role
|
||||
/// messages from history.
|
||||
///
|
||||
/// Why: the system prompt is re-injected fresh at request time via
|
||||
/// `to_api_messages`, so stale `System` messages in `self.messages`
|
||||
/// would be redundant/conflicting if left in place.
|
||||
pub fn rebuild_system(&mut self, new_prompt: String) {
|
||||
self.system_prompt = new_prompt;
|
||||
self.messages.retain(|m| {
|
||||
@@ -33,6 +46,11 @@ impl Conversation {
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the message list to send to the LLM API, with the system
|
||||
/// prompt prepended.
|
||||
///
|
||||
/// Return: a new `Vec` (clone of history) with a synthesized system
|
||||
/// message at index 0.
|
||||
pub fn to_api_messages(&self) -> Vec<crate::dto::chat::message::ChatMessage> {
|
||||
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
|
||||
msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt));
|
||||
@@ -40,6 +58,8 @@ impl Conversation {
|
||||
msgs
|
||||
}
|
||||
|
||||
/// Number of messages in the conversation history (excluding the
|
||||
/// synthesized system message).
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
//! Append-only JSONL edit log recording every file mutation made by tools,
|
||||
//! for audit and undo/history purposes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single recorded file edit: which tool made it, to which path, why,
|
||||
/// and a content hash/size delta for verification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditLogEntry {
|
||||
pub ts: i64,
|
||||
@@ -12,6 +17,7 @@ pub struct EditLogEntry {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// In-memory view of a session's edit log, backed by `edits.jsonl` on disk.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditLog {
|
||||
pub entries: Vec<EditLogEntry>,
|
||||
@@ -19,6 +25,8 @@ pub struct EditLog {
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
/// Open (or start tracking) the edit log for a session directory,
|
||||
/// replaying any existing `edits.jsonl` into memory.
|
||||
pub fn new(session_dir: &std::path::Path) -> Self {
|
||||
let path = session_dir.join("edits.jsonl");
|
||||
let entries = Self::load_from_disk(&path);
|
||||
@@ -40,6 +48,17 @@ impl EditLog {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Append one entry to `edits.jsonl` on disk and to the in-memory log.
|
||||
///
|
||||
/// Flow: serialize `entry` to a JSON line → ensure parent dir exists →
|
||||
/// open the file in append mode → write the line → push into
|
||||
/// `self.entries`.
|
||||
///
|
||||
/// Why: appending (not rewriting) keeps the log durable and cheap even
|
||||
/// as it grows across a long session.
|
||||
///
|
||||
/// Return: `Ok(())` on success; an `io::Error` if serialization or
|
||||
/// any filesystem operation fails.
|
||||
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
|
||||
let line = serde_json::to_string(&entry)? + "\n";
|
||||
let parent = self.path.parent().unwrap();
|
||||
@@ -54,6 +73,7 @@ impl EditLog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of edit entries recorded so far in this log.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
//! Long-term agent memory: markdown files with YAML-ish frontmatter storing
|
||||
//! lessons/references, plus slugified filenames and export/import helpers.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single memory entry (lesson, reference, etc.) with frontmatter
|
||||
/// metadata and free-form markdown content.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Memory {
|
||||
pub name: String,
|
||||
@@ -18,6 +23,17 @@ pub struct Memory {
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
/// Convert an arbitrary string into a filesystem-safe slug.
|
||||
///
|
||||
/// Flow: lowercase → replace non-alphanumeric chars with `-` →
|
||||
/// collapse/trim repeated `-` by splitting on it and rejoining
|
||||
/// non-empty parts.
|
||||
///
|
||||
/// Why: rejects empty or overly long (>80 char) results so callers
|
||||
/// don't write memories with degenerate or unwieldy filenames.
|
||||
///
|
||||
/// Return: `Some(slug)` on success, `None` if the input slugifies to
|
||||
/// empty or exceeds 80 characters.
|
||||
pub fn slugify(s: &str) -> Option<String> {
|
||||
let slug: String = s
|
||||
.to_lowercase()
|
||||
@@ -35,11 +51,27 @@ impl Memory {
|
||||
Some(slug)
|
||||
}
|
||||
|
||||
/// Compute the on-disk path for a memory of the given name.
|
||||
///
|
||||
/// Why: falls back to a fixed `"memory"` slug when `name` slugifies
|
||||
/// to nothing, so a path is always produced.
|
||||
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
|
||||
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
|
||||
slug_path(memory_dir, &format!("{}.md", slug))
|
||||
}
|
||||
|
||||
/// Serialize this memory to markdown-with-frontmatter and write it
|
||||
/// atomically to disk.
|
||||
///
|
||||
/// Flow: build the frontmatter block (name/description/kind/timestamps/
|
||||
/// lifecycle/optional fields) → concatenate with body content → write
|
||||
/// to a temp file → rename into place.
|
||||
///
|
||||
/// Why: write-then-rename avoids leaving a half-written memory file if
|
||||
/// the process is interrupted mid-write.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `io::Error` from directory
|
||||
/// creation, the temp write, or the rename.
|
||||
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
|
||||
let path = Self::path(memory_dir, &self.name);
|
||||
let parent = path.parent().unwrap();
|
||||
@@ -65,12 +97,29 @@ impl Memory {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read and parse a memory file by name.
|
||||
///
|
||||
/// Return: the parsed `Memory`, or an `io::Error` if the file is
|
||||
/// missing or its frontmatter is malformed (see `parse`).
|
||||
pub fn read(memory_dir: &Path, name: &str) -> std::io::Result<Self> {
|
||||
let path = Self::path(memory_dir, name);
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
Self::parse(&content)
|
||||
}
|
||||
|
||||
/// Parse a memory file's contents (frontmatter + body) into a `Memory`.
|
||||
///
|
||||
/// Flow: strip leading `---\n` → split on the first `\n---\n` into
|
||||
/// frontmatter and body → parse frontmatter lines as `key: value`
|
||||
/// pairs into a map → build `Memory` fields from the map with
|
||||
/// sensible defaults for missing keys.
|
||||
///
|
||||
/// Why: unknown/missing frontmatter keys degrade to defaults (e.g.
|
||||
/// `kind` → "reference", `lifecycle` → "new") rather than failing,
|
||||
/// so older or hand-edited memory files still parse.
|
||||
///
|
||||
/// Return: `Err(InvalidData)` only if the `---` frontmatter delimiter
|
||||
/// itself is missing; otherwise `Ok(Memory)`.
|
||||
pub fn parse(content: &str) -> std::io::Result<Self> {
|
||||
let content = content.strip_prefix("---\n").unwrap_or(content);
|
||||
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
|
||||
@@ -103,6 +152,9 @@ impl Memory {
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a memory file by name, if it exists.
|
||||
///
|
||||
/// Return: `Ok(())` whether or not the file existed.
|
||||
pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> {
|
||||
let path = Self::path(memory_dir, name);
|
||||
if path.exists() {
|
||||
@@ -111,6 +163,13 @@ impl Memory {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List the slugs of all memory files in a directory.
|
||||
///
|
||||
/// Flow: read the directory → keep entries ending in `.md` → exclude
|
||||
/// the special `MEMORY.md` summary file → strip the `.md` suffix.
|
||||
///
|
||||
/// Return: slugs (without extension); empty `Vec` if the directory
|
||||
/// can't be read.
|
||||
pub fn list(memory_dir: &Path) -> Vec<String> {
|
||||
let entries = match std::fs::read_dir(memory_dir) {
|
||||
Ok(e) => e,
|
||||
@@ -129,6 +188,14 @@ impl Memory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a raw filename into a safe path under `memory_dir`.
|
||||
///
|
||||
/// Flow: replace any char that isn't alphanumeric, `.`, or `-` with `-` →
|
||||
/// strip leading dots (prevents dotfiles / path traversal via `..`) →
|
||||
/// join to `memory_dir`, falling back to `"memory.md"` if empty.
|
||||
///
|
||||
/// Why: leading-dot stripping specifically blocks accidental hidden
|
||||
/// files and `..`-style traversal attempts embedded in `raw`.
|
||||
pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
||||
let clean: String = raw.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' })
|
||||
@@ -137,6 +204,14 @@ pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
||||
memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean })
|
||||
}
|
||||
|
||||
/// Export all memories in `memory_dir` to a single JSON file.
|
||||
///
|
||||
/// Flow: list memory slugs → read+parse each into a `Memory` (skipping
|
||||
/// any that fail) → serialize the collected `Vec<Memory>` to pretty JSON
|
||||
/// → write to `output`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `io::Error` from serialization or
|
||||
/// the write.
|
||||
pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
||||
let names = Memory::list(memory_dir);
|
||||
let lessons: Vec<Memory> = names.iter()
|
||||
@@ -147,6 +222,17 @@ pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
||||
std::fs::write(output, data)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Import memories from a JSON export file into `memory_dir`, skipping
|
||||
/// duplicates.
|
||||
///
|
||||
/// Flow: read+JSON-decode `input` into `Vec<Memory>` → build a set of
|
||||
/// existing slugs in `memory_dir` → for each lesson not already present
|
||||
/// (by slug), write it to disk and count it.
|
||||
///
|
||||
/// Why: slug-based dedup makes repeated imports idempotent — re-running
|
||||
/// import on the same file won't overwrite or duplicate existing memories.
|
||||
///
|
||||
/// Return: the number of memories actually imported (skips existing ones).
|
||||
pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize> {
|
||||
let data = std::fs::read_to_string(input)?;
|
||||
let lessons: Vec<Memory> = serde_json::from_str(&data)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Persistence and domain model layer: sessions, conversations, memory,
|
||||
//! message log (SQLite), edit log, and app/settings config.
|
||||
|
||||
pub mod app_config;
|
||||
pub mod editlog;
|
||||
pub mod memory;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
//! Binary blob storage in the message-log SQLite database (e.g. images,
|
||||
//! attachments), keyed by session id and an arbitrary blob key.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Insert or overwrite a blob for a session under `blob_key`.
|
||||
///
|
||||
/// Flow: compute current timestamp → `INSERT OR REPLACE` into `blobs`
|
||||
/// keyed on `(session_id, blob_key)`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or the underlying SQLite error.
|
||||
pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
conn.execute(
|
||||
@@ -10,6 +19,10 @@ pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a blob's bytes for a session by key.
|
||||
///
|
||||
/// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row
|
||||
/// exists, `Err` for any other SQLite failure.
|
||||
pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> {
|
||||
let result = conn.query_row(
|
||||
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||
@@ -23,6 +36,10 @@ pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Res
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a blob for a session by key.
|
||||
///
|
||||
/// Return: `Ok(true)` if a row was deleted, `Ok(false)` if no matching
|
||||
/// row existed.
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<bool> {
|
||||
let rows = conn.execute(
|
||||
@@ -32,6 +49,10 @@ pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Resul
|
||||
Ok(rows > 0)
|
||||
}
|
||||
|
||||
/// List all blob keys stored for a session, oldest first.
|
||||
///
|
||||
/// Return: `Ok(Vec<String>)` of keys ordered by `created_at`, or the
|
||||
/// underlying SQLite error.
|
||||
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! SQLite-backed message log: per-session `messages.sqlite` storing chat
|
||||
//! messages, blobs, and archive/summary metadata.
|
||||
|
||||
pub mod blobs;
|
||||
pub mod query;
|
||||
pub mod schema;
|
||||
@@ -5,6 +8,14 @@ pub mod schema;
|
||||
pub use blobs::store_blob;
|
||||
pub use query::insert_message;
|
||||
|
||||
/// Open (creating if needed) a session's `messages.sqlite` and ensure its
|
||||
/// schema is initialized.
|
||||
///
|
||||
/// Flow: resolve `<session_dir>/messages.sqlite` → create parent dirs →
|
||||
/// open a SQLite connection → run `schema::init_schema`.
|
||||
///
|
||||
/// Return: an open, schema-ready `Connection`, or an error if any step
|
||||
/// fails.
|
||||
pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
|
||||
let path = session_dir.join("messages.sqlite");
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
//! Insert queries against the message log's `messages` table.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use anyhow::Result;
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
|
||||
/// Insert a chat message into the session's message log.
|
||||
///
|
||||
/// Flow: extract optional content/tool_call_id/tool_name → serialize
|
||||
/// `tool_calls` to a JSON string if present → map `Role` to its string
|
||||
/// column value → `INSERT` the row with the current timestamp.
|
||||
///
|
||||
/// Return: the new row's `rowid` on success, or the underlying error.
|
||||
pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result<i64> {
|
||||
let content = msg.content.as_deref();
|
||||
let tool_call_id = msg.tool_call_id.as_deref();
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
//! SQLite schema definition for the message log database.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Create the message log's tables and indexes if they don't already
|
||||
/// exist (`messages`, `archives`, `blobs`).
|
||||
///
|
||||
/// Why: idempotent via `CREATE TABLE/INDEX IF NOT EXISTS`, so it's safe
|
||||
/// to call on every `open_or_create`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or the underlying SQLite error.
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Session archive/summary metadata tracked alongside the message log
|
||||
//! (title, model, counts, and a rolling text summary).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Summary metadata for one archived/summarized session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SummaryRecord {
|
||||
pub session_id: String,
|
||||
@@ -13,6 +17,8 @@ pub struct SummaryRecord {
|
||||
}
|
||||
|
||||
impl SummaryRecord {
|
||||
/// Create a fresh summary record with zeroed counts and an empty
|
||||
/// summary, timestamped to now.
|
||||
pub fn new(session_id: String, title: String, model: String) -> Self {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
SummaryRecord {
|
||||
@@ -27,11 +33,13 @@ impl SummaryRecord {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the summary text and bump `updated_at`.
|
||||
pub fn update_summary(&mut self, summary: String) {
|
||||
self.summary = summary;
|
||||
self.updated_at = chrono::Utc::now().timestamp_millis();
|
||||
}
|
||||
|
||||
/// Add to the running message/token counts and bump `updated_at`.
|
||||
pub fn increment_counts(&mut self, messages: usize, tokens: usize) {
|
||||
self.message_count += messages;
|
||||
self.token_count += tokens;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
//! Session metadata: id, title, workspace roots, and message/token counts,
|
||||
//! persisted as `session.json` per session directory.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::Utc;
|
||||
|
||||
/// Metadata for one conversation session (distinct from the message
|
||||
/// history itself, which lives in `Conversation`/the msglog).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
@@ -17,6 +22,8 @@ pub struct Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session with the given id/title, defaulting the
|
||||
/// model, workspace root (current dir), and counters.
|
||||
pub fn new(id: String, title: String) -> Self {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
Session {
|
||||
@@ -33,14 +40,25 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
|
||||
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("sessions").join(&self.id)
|
||||
}
|
||||
|
||||
/// Compute this session's `conversation.json` path.
|
||||
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("conversation.json")
|
||||
}
|
||||
|
||||
/// Persist this session's metadata to `session.json`, atomically.
|
||||
///
|
||||
/// Flow: ensure the session directory exists → serialize to pretty
|
||||
/// JSON → write to `session.json.tmp` → rename over `session.json`.
|
||||
///
|
||||
/// Why: write-then-rename avoids a torn/partial `session.json` if
|
||||
/// interrupted mid-write.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `io::Error` from any step.
|
||||
pub fn save(&self, base_dir: &Path) -> std::io::Result<()> {
|
||||
let dir = self.session_dir(base_dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
@@ -52,6 +70,10 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a session's metadata by id from `<base_dir>/sessions/<id>/session.json`.
|
||||
///
|
||||
/// Return: the parsed `Session`, or an `io::Error` if the file is
|
||||
/// missing or malformed.
|
||||
pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> {
|
||||
let path = base_dir.join("sessions").join(id).join("session.json");
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
@@ -59,6 +81,14 @@ impl Session {
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// List all loadable sessions under `<base_dir>/sessions/`.
|
||||
///
|
||||
/// Flow: read the sessions directory → keep subdirectories → attempt
|
||||
/// `Session::load` for each by its directory name, discarding any
|
||||
/// that fail to load.
|
||||
///
|
||||
/// Return: a `Vec<Session>`, empty if the directory can't be read or
|
||||
/// contains no valid sessions.
|
||||
pub fn list(base_dir: &Path) -> Vec<Self> {
|
||||
let sessions_dir = base_dir.join("sessions");
|
||||
let entries = match std::fs::read_dir(&sessions_dir) {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! PID-file based advisory lock preventing two processes from operating on
|
||||
//! the same session directory concurrently.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
|
||||
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
||||
/// auto-removed on drop.
|
||||
pub struct SessionLock {
|
||||
path: PathBuf,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
/// Construct a lock handle for a session directory (does not acquire
|
||||
/// the lock yet — call `try_lock`).
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
SessionLock {
|
||||
path: session_dir.join(".lock"),
|
||||
@@ -14,6 +21,19 @@ impl SessionLock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to acquire the session lock.
|
||||
///
|
||||
/// Flow: if `.lock` exists, read the PID inside it and check
|
||||
/// `is_alive` — if that process is still running, fail to acquire →
|
||||
/// otherwise (no lock file, unreadable PID, or dead owner) write our
|
||||
/// own PID into `.lock` and succeed.
|
||||
///
|
||||
/// Why: a stale lock file from a crashed process must not permanently
|
||||
/// block new sessions, so liveness is re-checked via `kill(pid, 0)`
|
||||
/// rather than trusting the file's mere existence.
|
||||
///
|
||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||
/// process holds it, `Err` on I/O failure.
|
||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||
if self.path.exists() {
|
||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||
@@ -27,10 +47,12 @@ impl SessionLock {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Explicitly release the lock by removing the lock file.
|
||||
pub fn unlock(&self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
/// Check whether a process with the given PID is currently alive.
|
||||
fn is_alive(&self, pid: u32) -> bool {
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal
|
||||
@@ -40,6 +62,8 @@ impl SessionLock {
|
||||
}
|
||||
|
||||
impl Drop for SessionLock {
|
||||
/// Release the lock automatically when the guard goes out of scope,
|
||||
/// so an ungracefully-exited process doesn't leave a dangling lock.
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
//! User-configurable settings persisted as JSON in the store's base directory.
|
||||
//!
|
||||
//! `Settings::load` / `Settings::save` are the only entry points; every field
|
||||
//! falls back to a hardcoded default via `Default for Settings` when the file
|
||||
//! is missing or fails to parse.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Controls how much network access the agent is permitted during a session.
|
||||
///
|
||||
/// `Off` disables outbound requests entirely, `ReadOnly` allows fetches but
|
||||
/// no mutating calls, `Full` permits everything. Defaults to `Off`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Default)]
|
||||
pub enum InternetMode {
|
||||
@@ -11,6 +21,10 @@ pub enum InternetMode {
|
||||
|
||||
|
||||
|
||||
/// Top-level application settings, serialized to `settings.json` in the store dir.
|
||||
///
|
||||
/// Why: a single flat struct rather than nested config so the JSON file stays
|
||||
/// human-editable; unknown/missing fields on load fall back to `Default`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub internet_mode: InternetMode,
|
||||
@@ -49,6 +63,12 @@ impl Default for Settings {
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Load settings from `<store_base_dir>/settings.json`.
|
||||
///
|
||||
/// Flow: read file → parse JSON → fall back to `Settings::default()` on
|
||||
/// any failure (missing file, unreadable, malformed JSON).
|
||||
///
|
||||
/// Return: always succeeds; never surfaces I/O or parse errors to the caller.
|
||||
pub fn load() -> Self {
|
||||
let store = super::store::Store::new();
|
||||
let path = store.base_dir.join("settings.json");
|
||||
@@ -58,6 +78,11 @@ impl Settings {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Serialize and write settings to `<store_base_dir>/settings.json`.
|
||||
///
|
||||
/// Flow: ensure base dir exists → pretty-print JSON → write to disk.
|
||||
///
|
||||
/// Return: `Err` if the directory can't be created or the write fails.
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
let store = super::store::Store::new();
|
||||
std::fs::create_dir_all(&store.base_dir)?;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
//! Filesystem layout for zesdex's persistent and scratch data directories.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Resolved paths for all data directories zesdex reads from and writes to.
|
||||
///
|
||||
/// Why: centralizing path computation here means every consumer agrees on
|
||||
/// where memory, scratch, session images, and downloads live.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Store {
|
||||
pub base_dir: PathBuf,
|
||||
@@ -11,6 +17,12 @@ pub struct Store {
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Compute the standard set of zesdex data directory paths.
|
||||
///
|
||||
/// Flow: OS data dir (or `.local/share` fallback) + "zesdex" → base dir;
|
||||
/// scratch root comes from the OS temp dir instead, since it's disposable.
|
||||
///
|
||||
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
||||
pub fn new() -> Self {
|
||||
let base = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share"))
|
||||
@@ -25,6 +37,9 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create all store directories (base, memory, scratch, session images, downloads) if missing.
|
||||
///
|
||||
/// Return: `Err` on the first directory that fails to create.
|
||||
pub fn ensure_dirs(&self) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&self.base_dir)?;
|
||||
std::fs::create_dir_all(&self.memory_dir)?;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Compile-time embedded text resources: the system prompt, tool descriptions,
|
||||
//! and the in-app help screen shown on Ctrl+H.
|
||||
|
||||
pub const SYSTEM_PROMPT: &str = include_str!("../src-misc/system-prompt.txt");
|
||||
pub const SYSTEM_TOOLS: &str = include_str!("../src-misc/system-tools.txt");
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
//! External service integrations: the LLM provider HTTP client and OAuth flows.
|
||||
|
||||
pub mod provider;
|
||||
pub mod oauth;
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
|
||||
/// `?code=...` redirect and serves back a static confirmation page.
|
||||
pub struct LoopbackServer {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl LoopbackServer {
|
||||
/// Bind to an OS-assigned free port on localhost.
|
||||
///
|
||||
/// Return: `Err` if the loopback interface can't be bound.
|
||||
pub fn bind() -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(LoopbackServer { listener, port })
|
||||
}
|
||||
|
||||
/// The redirect URI to hand to the OAuth authorization endpoint.
|
||||
pub fn redirect_uri(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/callback", self.port)
|
||||
}
|
||||
|
||||
/// Block until one HTTP request arrives, then extract its `code` query param.
|
||||
///
|
||||
/// Flow: accept one connection → apply read timeout → parse request line
|
||||
/// → respond 200/400 depending on whether a code was found.
|
||||
///
|
||||
/// Return: `Err(InvalidData)` if no `code` param is present in the request.
|
||||
pub fn wait_for_code(&self, timeout_ms: u64) -> std::io::Result<String> {
|
||||
let (mut stream, _) = self.listener.accept()?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||
Self::read_callback(&mut stream)
|
||||
}
|
||||
|
||||
/// Read and parse a single HTTP callback request off `stream`, replying with a status page.
|
||||
///
|
||||
/// Why: writes the HTTP response before returning so the browser tab
|
||||
/// shows a result regardless of whether the code was found.
|
||||
fn read_callback(stream: &mut TcpStream) -> std::io::Result<String> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf)?;
|
||||
@@ -38,6 +56,9 @@ impl LoopbackServer {
|
||||
code.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback"))
|
||||
}
|
||||
|
||||
/// Extract and percent-decode the `code` query parameter from an HTTP request line.
|
||||
///
|
||||
/// Return: `None` if the request is malformed or has no `code` param.
|
||||
fn extract_code(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
@@ -52,6 +73,11 @@ impl LoopbackServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode a string (e.g. `%20` -> space).
|
||||
///
|
||||
/// Why: invalid escape sequences (missing/non-hex digits) are passed through
|
||||
/// literally as `%` rather than erroring, since this only handles a redirect
|
||||
/// query param, not untrusted binary data.
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthToken {
|
||||
pub access_token: String,
|
||||
@@ -12,6 +15,7 @@ pub struct OAuthToken {
|
||||
impl OAuthToken {
|
||||
}
|
||||
|
||||
/// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
pub auth_url: String,
|
||||
@@ -33,6 +37,7 @@ impl Default for OAuthConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives one OAuth flow: holds config, the current token (if any), and an HTTP client.
|
||||
pub struct OAuthManager {
|
||||
pub config: OAuthConfig,
|
||||
pub token: Option<OAuthToken>,
|
||||
@@ -40,6 +45,7 @@ pub struct OAuthManager {
|
||||
}
|
||||
|
||||
impl OAuthManager {
|
||||
/// Create a manager for the given provider config with no token yet acquired.
|
||||
pub fn new(config: OAuthConfig) -> Self {
|
||||
OAuthManager {
|
||||
config,
|
||||
@@ -48,6 +54,12 @@ impl OAuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange an authorization code for an access token via the provider's token endpoint.
|
||||
///
|
||||
/// Flow: POST form-encoded grant to `token_url` → parse JSON body →
|
||||
/// compute absolute `expires_at` from `expires_in` → store on `self.token`.
|
||||
///
|
||||
/// Return: `Err(String)` on network failure, non-2xx status, or a missing `access_token` field.
|
||||
pub fn exchange_code(&mut self, code: &str, redirect_uri: &str, code_verifier: &str) -> Result<(), String> {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
params.insert("grant_type", "authorization_code");
|
||||
@@ -83,13 +95,17 @@ impl OAuthManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the provider's authorization URL with PKCE and state params attached.
|
||||
///
|
||||
/// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously
|
||||
/// this silently fell back to https://example.com, which produced a valid-looking
|
||||
/// auth URL pointing at the wrong server and leaked client credentials in
|
||||
/// query params. Returning an empty string signals failure to callers, who
|
||||
/// can prompt the user to fix the OAuth config instead of starting a flow
|
||||
/// against a wrong host.
|
||||
///
|
||||
/// Return: the full authorization URL, or `""` if `auth_url` is empty/unparseable.
|
||||
pub fn build_auth_url(&self, redirect_uri: &str, state: &str, code_challenge: &str) -> String {
|
||||
// Refuse to build a URL if `auth_url` is missing or invalid. Previously this
|
||||
// silently fell back to https://example.com, which produced a valid-looking
|
||||
// auth URL pointing at the wrong server and leaked client credentials in
|
||||
// query params. Returning an empty string signals failure to callers, who
|
||||
// can prompt the user to fix the OAuth config instead of starting a flow
|
||||
// against a wrong host.
|
||||
let mut url = match url::Url::parse(&self.config.auth_url) {
|
||||
Ok(u) if !self.config.auth_url.is_empty() => u,
|
||||
_ => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! OAuth 2.0 authorization-code + PKCE support: verifier/challenge generation,
|
||||
//! the loopback redirect server, and the token-exchange manager.
|
||||
|
||||
pub mod pkce;
|
||||
pub mod loopback;
|
||||
pub mod manager;
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
//! PKCE (Proof Key for Code Exchange) verifier/challenge pair generation for OAuth flows.
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
const VERIFIER_LENGTH: usize = 64;
|
||||
|
||||
/// A randomly generated, base64url-encoded PKCE code verifier.
|
||||
pub struct CodeVerifier(String);
|
||||
|
||||
impl CodeVerifier {
|
||||
/// Generate a fresh random code verifier.
|
||||
pub fn new() -> Self {
|
||||
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
|
||||
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes))
|
||||
}
|
||||
|
||||
/// Borrow the verifier as a string, to send in the token exchange request.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Derive the S256 code challenge (SHA-256 hash, base64url-encoded) to send
|
||||
/// in the authorization request.
|
||||
pub fn challenge(&self) -> CodeChallenge {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.0.as_bytes());
|
||||
@@ -23,6 +30,11 @@ impl CodeVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce one pseudo-random byte from the sub-second component of the system clock.
|
||||
///
|
||||
/// Why: avoids pulling in a `rand` dependency for a short-lived, non-cryptographic
|
||||
/// verifier; each byte only needs to be unpredictable enough to prevent code
|
||||
/// interception, not cryptographically secure.
|
||||
fn rand_byte() -> u8 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
@@ -35,9 +47,11 @@ fn rand_byte() -> u8 {
|
||||
(nanos & 0xFF) as u8
|
||||
}
|
||||
|
||||
/// The S256-derived code challenge sent in the authorization request URL.
|
||||
pub struct CodeChallenge(String);
|
||||
|
||||
impl CodeChallenge {
|
||||
/// Borrow the challenge as a string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Blocking HTTP client for OpenAI/Anthropic-compatible chat completion APIs,
|
||||
//! supporting both non-streaming and SSE-streaming requests with automatic retry.
|
||||
|
||||
use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -12,6 +15,10 @@ pub const DEFAULT_API_KEY: &str = "sk-5dd268d88adb496b-818beb-6bc7498e";
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Blocking HTTP client for a single LLM provider endpoint.
|
||||
///
|
||||
/// Holds the reqwest client, credentials, and model/base URL selection used
|
||||
/// by both the non-streaming and streaming chat completion calls.
|
||||
pub struct LlmClient {
|
||||
pub client: reqwest::blocking::Client,
|
||||
pub api_key: String,
|
||||
@@ -20,6 +27,14 @@ pub struct LlmClient {
|
||||
}
|
||||
|
||||
impl LlmClient {
|
||||
/// Construct a client, falling back to built-in defaults for empty inputs.
|
||||
///
|
||||
/// Flow: empty api_key/model → substitute defaults → build reqwest client
|
||||
/// with connect/request timeouts (falling back to an untimed client if
|
||||
/// the builder fails) → normalize base_url.
|
||||
///
|
||||
/// Why: empty strings are treated as "unset" rather than errors so callers
|
||||
/// can pass through unconfigured settings without special-casing them.
|
||||
pub fn new(mut api_key: String, model: String, base_url: Option<String>) -> Self {
|
||||
if api_key.is_empty() {
|
||||
api_key = DEFAULT_API_KEY.to_string();
|
||||
@@ -48,6 +63,16 @@ impl LlmClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a non-streaming chat completion request and return the assistant's reply.
|
||||
///
|
||||
/// Flow: build request → POST with retry loop (up to 10 attempts, 2s backoff)
|
||||
/// → parse JSON response → extract first choice's message and token usage.
|
||||
///
|
||||
/// Why: retries transient failures but aborts immediately on 401/403, since
|
||||
/// those indicate a bad API key that retrying won't fix.
|
||||
///
|
||||
/// Return: `Err` if all retries are exhausted, an auth error occurs, or the
|
||||
/// response has no choices.
|
||||
pub fn chat_with_tools_non_streaming(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
@@ -177,6 +202,17 @@ impl LlmClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform one streaming chat completion request, parsing SSE events until completion.
|
||||
///
|
||||
/// Flow: POST → read body in chunks → advance past valid UTF-8 boundary →
|
||||
/// feed into `SseParser` → dispatch each `StreamEvent` to `on_event` and
|
||||
/// accumulate in `StreamedTurn` → return assembled assistant message on `Done`.
|
||||
///
|
||||
/// Why: chunk-by-chunk UTF-8-aware reads avoid splitting multi-byte sequences;
|
||||
/// returns `aborted` error if `on_event` returns false so the caller can cancel.
|
||||
///
|
||||
/// Return: assembled message + optional usage on success, `Err` on read
|
||||
/// failure, non-2xx status, or callback-initiated abort.
|
||||
fn try_stream_once(
|
||||
&self,
|
||||
req: &ChatRequest,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
//! Tool implementations for interacting with background bash jobs: `bash_output`
|
||||
//! and `bash_kill`. Both take a `job_id` produced by `bash` with `run_in_background=true`.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
/// Tool: fetch buffered output from a background bash job by `job_id`.
|
||||
pub struct BashOutput;
|
||||
|
||||
impl Tool for BashOutput {
|
||||
@@ -39,6 +43,7 @@ impl Tool for BashOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: terminate a running background bash job by `job_id`.
|
||||
pub struct BashKill;
|
||||
|
||||
impl Tool for BashKill {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Tool: `delete` — remove a file or empty directory relative to a workspace root.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use serde_json::{json, Value};
|
||||
@@ -7,6 +9,7 @@ use super::super::ToolCtx;
|
||||
use super::super::resolve_path;
|
||||
use super::helpers::arg_str;
|
||||
|
||||
/// Tool: delete a file or empty directory. Refuses non-empty directories.
|
||||
pub struct Delete;
|
||||
|
||||
impl Tool for Delete {
|
||||
@@ -31,6 +34,10 @@ impl Tool for Delete {
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a file or empty directory. Returns success message or errors on failure.
|
||||
///
|
||||
/// Flow: resolve path → check existence → check dir/file → remove.
|
||||
/// Only empty directories are deletable (non-empty returns an error).
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = arg_str(args, "path")?;
|
||||
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Tool: `edit` — replace a substring in a file with a new string.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use serde_json::{json, Value};
|
||||
@@ -8,6 +10,7 @@ use super::super::resolve_path;
|
||||
use super::super::check_graduated_checks;
|
||||
use super::helpers::arg_str;
|
||||
|
||||
/// Tool: replace text in a file. Requires the old string to be unique unless `replace_all` is true.
|
||||
pub struct Edit;
|
||||
|
||||
impl Tool for Edit {
|
||||
@@ -48,6 +51,13 @@ impl Tool for Edit {
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform the in-file string replacement.
|
||||
///
|
||||
/// Flow: validate args → resolve path → read file → count occurrences →
|
||||
/// replace one or all → write back → report byte delta (+ optional graduated checks).
|
||||
///
|
||||
/// Why: requires a non-empty `reason` and a non-empty `old` string to prevent
|
||||
/// accidental identity edits. Enforces uniqueness unless `replace_all` is set.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = arg_str(args, "path")?;
|
||||
let old = arg_str(args, "old")?;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! Shared helpers for filesystem tools: extracting string arguments from JSON
|
||||
//! and producing user-friendly "not found" diagnostics.
|
||||
|
||||
use std::path::Path;
|
||||
use serde_json::Value;
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
/// Extract a required string argument from a JSON args map.
|
||||
///
|
||||
/// Return: the value as `String` if present and a string type; `Err` if missing
|
||||
/// or of a different JSON type (null, number, boolean, array, object).
|
||||
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
|
||||
args.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -9,6 +16,12 @@ pub fn arg_str(args: &Value, name: &str) -> Result<String> {
|
||||
.ok_or_else(|| anyhow!("missing required argument: {}", name))
|
||||
}
|
||||
|
||||
/// Produce a user-friendly diagnostic string when a path doesn't resolve or exist.
|
||||
///
|
||||
/// Checks whether the resolved path canonically falls inside any workspace root
|
||||
/// and reports either "path outside workspaces" or "path does not exist" accordingly.
|
||||
///
|
||||
/// Return: a one-line description of the resolution failure.
|
||||
pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String {
|
||||
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let in_ws = ctx.workspaces.iter().any(|w| {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Filesystem tool implementations: read, write, edit, and delete operations
|
||||
//! on workspace-rooted paths.
|
||||
|
||||
pub mod delete;
|
||||
pub mod edit;
|
||||
pub mod helpers;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Tool: `read` — display file contents with line numbers.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use serde_json::{json, Value};
|
||||
@@ -7,6 +9,7 @@ use super::super::ToolCtx;
|
||||
use super::super::resolve_path;
|
||||
use super::helpers::{arg_str, not_found_help};
|
||||
|
||||
/// Tool: read a file and display it with line numbers, optionally truncated to `limit` lines.
|
||||
pub struct Read;
|
||||
|
||||
impl Tool for Read {
|
||||
@@ -35,6 +38,13 @@ impl Tool for Read {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read and display a file with line numbers.
|
||||
///
|
||||
/// Flow: resolve path → if not found, call `not_found_help` for diagnostic →
|
||||
/// read entire file → enumerate and format lines → optionally truncate by `limit`.
|
||||
///
|
||||
/// Return: line-numbered content; `not_found_help` message if the path doesn't
|
||||
/// exist; a "is a directory" message if the path points at a directory.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = arg_str(args, "path")?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Tool: `write` — write content to a file, creating parent directories on demand.
|
||||
|
||||
use std::fs;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
@@ -7,6 +9,7 @@ use super::super::resolve_path;
|
||||
use super::super::check_graduated_checks;
|
||||
use super::helpers::arg_str;
|
||||
|
||||
/// Tool: write content to a file, auto-creating parent directories as needed.
|
||||
pub struct Write;
|
||||
|
||||
impl Tool for Write {
|
||||
@@ -39,6 +42,13 @@ impl Tool for Write {
|
||||
})
|
||||
}
|
||||
|
||||
/// Write content to a file, creating parent directories as needed.
|
||||
///
|
||||
/// Flow: validate args (non-empty reason) → resolve path → create parent
|
||||
/// dirs → write file → report byte count (+ optional graduated checks).
|
||||
///
|
||||
/// Why: requires a non-empty `reason` to discourage stray writes; parent
|
||||
/// directories are created silently so the tool works for new paths.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = arg_str(args, "path")?;
|
||||
let content = arg_str(args, "content")?;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Tool wrapper around `git credential` for store/get/erase operations.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::process::Command;
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
/// Tool that shells out to `git credential <op>` to store, retrieve, or erase credentials.
|
||||
pub struct GitCred;
|
||||
|
||||
impl Tool for GitCred {
|
||||
@@ -29,6 +32,15 @@ impl Tool for GitCred {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `git credential <operation>`, forwarding stdin-less invocation to the git binary.
|
||||
///
|
||||
/// Flow: extract `operation` arg → spawn `git credential <operation>` → capture output.
|
||||
///
|
||||
/// Why: `store`/`get`/`erase` are the only credential-helper subcommands git supports;
|
||||
/// no stdin is piped, so this mainly surfaces helper output/errors rather than
|
||||
/// performing an interactive credential exchange.
|
||||
///
|
||||
/// Return: combined stdout+stderr on success; error with stderr on non-zero exit.
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let operation = args.get("operation")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Generic tool for running arbitrary git subcommands.
|
||||
|
||||
use std::process::Command;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
/// Tool that runs `git <operation> [args...]` and returns combined stdout/stderr.
|
||||
pub struct GitOperator;
|
||||
|
||||
impl Tool for GitOperator {
|
||||
@@ -33,6 +36,16 @@ impl Tool for GitOperator {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `git <operation> [args...]` and return its combined output.
|
||||
///
|
||||
/// Flow: extract `operation` + `args` → spawn `git <operation> <args>` → trim and
|
||||
/// join stdout/stderr.
|
||||
///
|
||||
/// Why: no allowlist here — the model may run any git subcommand; destructive
|
||||
/// operations are blocked upstream by `shell_filter::git`, not by this tool.
|
||||
///
|
||||
/// Return: trimmed combined output on success; error including exit code and
|
||||
/// stderr on failure.
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let operation = args.get("operation")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Tool for creating git worktrees under the session's worktrees directory.
|
||||
|
||||
use std::process::Command;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
/// Tool that creates a new git worktree (`git worktree add`) from a given base ref.
|
||||
pub struct GitWorktree;
|
||||
|
||||
impl Tool for GitWorktree {
|
||||
@@ -32,6 +35,13 @@ impl Tool for GitWorktree {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create the worktree directory and run `git worktree add --checkout <path> <base_ref>`.
|
||||
///
|
||||
/// Flow: extract name/base_ref → create worktree dir under `ctx.worktrees_dir` →
|
||||
/// spawn `git worktree add` → combine stdout/stderr.
|
||||
///
|
||||
/// Return: success message with combined output on success; error including exit
|
||||
/// code and stderr on failure.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let name = args.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Tool for deleting a persisted memory entry by name.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use crate::model::memory::Memory;
|
||||
|
||||
/// Tool that removes a single memory entry from `ctx.memory_dir` by exact name.
|
||||
pub struct Forget;
|
||||
|
||||
impl Tool for Forget {
|
||||
@@ -28,6 +31,12 @@ impl Tool for Forget {
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the memory file matching `name` from disk.
|
||||
///
|
||||
/// Flow: extract `name` → `Memory::remove` → confirmation string.
|
||||
///
|
||||
/// Return: confirmation message on success; error if the memory does not exist
|
||||
/// or the file could not be removed.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let name = args.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user