Refactor and clean up code across multiple modules
- Simplified token type assignment in OAuth service. - Removed unused session_lock module and re-exported Session from zesdex_entities. - Cleaned up session entity by removing unnecessary comments and code. - Consolidated session handling in HTTP handlers for better readability. - Improved formatting and readability in OAuth repository tests. - Enhanced session lock repository with clearer match statements. - Streamlined session repository error handling. - Refined RNG tests for better clarity. - Adjusted module visibility and organization in lib.rs. - Updated IPC client and connection code for better error handling and clarity. - Improved frame handling in IPC for better readability. - Organized module imports and added test utilities for IPC. - Enhanced database connection error handling. - Simplified JWT token creation error handling. - Improved password verification error handling. - Cleaned up state management code for better readability. - Refactored middleware for session authentication and rate limiting. - Simplified clipboard utility for better error handling. - Enhanced logging initialization for better error reporting. - Improved pagination utility with clearer method annotations. - Cleaned up sanitization functions for filenames and paths. - Enhanced slug generation functions for better clarity and usability.
This commit is contained in:
@@ -54,9 +54,15 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Load stored memory lessons from long-term memory directory
|
// 2. Load stored memory lessons from long-term memory directory
|
||||||
let names = zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().list(&state.memory_dir).unwrap_or_default();
|
let names =
|
||||||
|
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
|
||||||
|
.list(&state.memory_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
for name in names {
|
for name in names {
|
||||||
if let Ok(mem) = zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().load(&state.memory_dir, &name) {
|
if let Ok(mem) =
|
||||||
|
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
|
||||||
|
.load(&state.memory_dir, &name)
|
||||||
|
{
|
||||||
if mem.kind == "lesson" {
|
if mem.kind == "lesson" {
|
||||||
items.push(LearningItem::Stored {
|
items.push(LearningItem::Stored {
|
||||||
name: mem.name,
|
name: mem.name,
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log the rewind itself as an edit entry
|
// Log the rewind itself as an edit entry
|
||||||
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
|
let repo =
|
||||||
|
zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
|
||||||
if let Ok(mut el) = repo.open(&state.session_dir) {
|
if let Ok(mut el) = repo.open(&state.session_dir) {
|
||||||
let entry = zesdex_cms::domain::edit_log::EditLogEntry {
|
let entry = zesdex_cms::domain::edit_log::EditLogEntry {
|
||||||
ts: chrono::Utc::now().timestamp_millis(),
|
ts: chrono::Utc::now().timestamp_millis(),
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_sign_loss,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::cast_possible_wrap
|
||||||
|
)]
|
||||||
//! Adaptive quality-review triggering, build/test probing, staleness
|
//! Adaptive quality-review triggering, build/test probing, staleness
|
||||||
//! sweeps for stored lessons, and the pending-lesson approval workflow.
|
//! sweeps for stored lessons, and the pending-lesson approval workflow.
|
||||||
use std::process::Command;
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::runtime::TurnEvent;
|
use crate::app::state::runtime::TurnEvent;
|
||||||
use crate::app::state::types::{Origin, Toast, ToastKind};
|
use crate::app::state::types::{Origin, Toast, ToastKind};
|
||||||
@@ -9,6 +13,7 @@ use crate::app::subagent::context::build_subagent_context;
|
|||||||
use crate::app::subagent::engine::run_subagent;
|
use crate::app::subagent::engine::run_subagent;
|
||||||
use crate::app::subagent::spawn::AgentDefinition;
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::process::Command;
|
||||||
use zesdex_cms::domain::memory::Memory;
|
use zesdex_cms::domain::memory::Memory;
|
||||||
use zesdex_cms::domain::repository::MemoryRepository;
|
use zesdex_cms::domain::repository::MemoryRepository;
|
||||||
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
||||||
@@ -74,11 +79,12 @@ pub struct Lesson {
|
|||||||
///
|
///
|
||||||
/// Return: `true` if a review should be triggered this turn.
|
/// Return: `true` if a review should be triggered this turn.
|
||||||
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||||
|
|
||||||
if origin != Origin::Main {
|
if origin != Origin::Main {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let Some(runtime) = &state.session_runtime else { return false };
|
let Some(runtime) = &state.session_runtime else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
if !state.settings.flags.review_enabled {
|
if !state.settings.flags.review_enabled {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -119,18 +125,28 @@ pub struct ProbeResult {
|
|||||||
/// Return: `None` if no workspace exists, no command could be resolved,
|
/// Return: `None` if no workspace exists, no command could be resolved,
|
||||||
/// or the process failed to spawn/poll; otherwise `Some(ProbeResult)`
|
/// or the process failed to spawn/poll; otherwise `Some(ProbeResult)`
|
||||||
/// describing pass/fail/timeout and truncated output.
|
/// 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> {
|
pub fn probe_build_test(
|
||||||
|
workspaces: &[std::path::PathBuf],
|
||||||
|
verify_command: Option<&str>,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Option<ProbeResult> {
|
||||||
let probe_dir = workspaces.first()?;
|
let probe_dir = workspaces.first()?;
|
||||||
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
||||||
|
|
||||||
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string()));
|
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(
|
||||||
|
|| (cmd.clone(), String::new()),
|
||||||
|
|(p, a)| (p.to_string(), a.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
let Ok(mut child) = Command::new(&cmd_prog)
|
let Ok(mut child) = Command::new(&cmd_prog)
|
||||||
.args(cmd_args.split_whitespace())
|
.args(cmd_args.split_whitespace())
|
||||||
.current_dir(probe_dir)
|
.current_dir(probe_dir)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.spawn() else { return None };
|
.spawn()
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let timed_out = loop {
|
let timed_out = loop {
|
||||||
@@ -141,9 +157,19 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
|
|||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
Ok(Some(status)) => {
|
Ok(Some(status)) => {
|
||||||
let output = child.wait_with_output().ok();
|
let output = child.wait_with_output().ok();
|
||||||
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
|
let stdout = output
|
||||||
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
|
.as_ref()
|
||||||
let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let stderr = output
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let combined = if stderr.is_empty() {
|
||||||
|
stdout
|
||||||
|
} else {
|
||||||
|
format!("{stdout}\n{stderr}")
|
||||||
|
};
|
||||||
return Some(ProbeResult {
|
return Some(ProbeResult {
|
||||||
command: cmd.clone(),
|
command: cmd.clone(),
|
||||||
passed: status.success(),
|
passed: status.success(),
|
||||||
@@ -151,7 +177,9 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
|
|||||||
timed_out: false,
|
timed_out: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); }
|
Ok(None) => {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
Err(_) => return None,
|
Err(_) => return None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -180,7 +208,10 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
|
|||||||
///
|
///
|
||||||
/// Return: `Some(command)` if a command could be determined, `None` if
|
/// Return: `Some(command)` if a command could be determined, `None` if
|
||||||
/// no marker files matched (e.g. plain Python project with no test dir).
|
/// 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> {
|
fn resolve_verify_command(
|
||||||
|
probe_dir: &std::path::Path,
|
||||||
|
override_cmd: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
if let Some(cmd) = override_cmd {
|
if let Some(cmd) = override_cmd {
|
||||||
if !cmd.trim().is_empty() {
|
if !cmd.trim().is_empty() {
|
||||||
return Some(cmd.trim().to_string());
|
return Some(cmd.trim().to_string());
|
||||||
@@ -201,18 +232,35 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
|||||||
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
||||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
||||||
let scripts = v.get("scripts")?;
|
let scripts = v.get("scripts")?;
|
||||||
if scripts.get("test").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
|
if scripts
|
||||||
|
.get("test")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| !s.is_empty())
|
||||||
|
{
|
||||||
return Some("npm test 2>&1".to_string());
|
return Some("npm test 2>&1".to_string());
|
||||||
}
|
}
|
||||||
if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
|
if scripts
|
||||||
|
.get("build")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| !s.is_empty())
|
||||||
|
{
|
||||||
return Some("npm run build 2>&1".to_string());
|
return Some("npm run build 2>&1".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Some("npm test 2>&1".to_string());
|
return Some("npm test 2>&1".to_string());
|
||||||
}
|
}
|
||||||
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
|
if has_file("pyproject.toml")
|
||||||
|
|| has_file("requirements.txt")
|
||||||
|
|| has_file("setup.py")
|
||||||
|
|| has_file("setup.cfg")
|
||||||
|
|| has_file("Pipfile")
|
||||||
|
|| has_file("poetry.lock")
|
||||||
|
{
|
||||||
if has_file("pyproject.toml") {
|
if has_file("pyproject.toml") {
|
||||||
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
|
let content =
|
||||||
|
std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
|
||||||
if content.contains("[tool.pytest") {
|
if content.contains("[tool.pytest") {
|
||||||
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
||||||
}
|
}
|
||||||
@@ -307,10 +355,7 @@ fn truncate_output(s: &str, max: usize) -> String {
|
|||||||
/// propagate from constructing the subagent context, not from the review
|
/// propagate from constructing the subagent context, not from the review
|
||||||
/// itself (that failure is reported via a `SystemNote` instead).
|
/// itself (that failure is reported via a `SystemNote` instead).
|
||||||
/// Compose the system prompt for the quality-review subagent.
|
/// Compose the system prompt for the quality-review subagent.
|
||||||
fn compose_review_prompt(
|
fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> String {
|
||||||
state: &AppStateRest,
|
|
||||||
probe_note: &str,
|
|
||||||
) -> String {
|
|
||||||
let diff_output = if let Some(workspace) = state.workspace_roots.first() {
|
let diff_output = if let Some(workspace) = state.workspace_roots.first() {
|
||||||
std::process::Command::new("git")
|
std::process::Command::new("git")
|
||||||
.arg("diff")
|
.arg("diff")
|
||||||
@@ -325,8 +370,13 @@ fn compose_review_prompt(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let history_output = if let Some(rt) = &state.session_runtime {
|
let history_output = if let Some(rt) = &state.session_runtime {
|
||||||
let msgs: Vec<String> = rt.messages.iter()
|
let msgs: Vec<String> = rt
|
||||||
.filter(|m| m.role == crate::dto::chat::message::Role::Assistant || m.role == crate::dto::chat::message::Role::User)
|
.messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| {
|
||||||
|
m.role == crate::dto::chat::message::Role::Assistant
|
||||||
|
|| m.role == crate::dto::chat::message::Role::User
|
||||||
|
})
|
||||||
.rev()
|
.rev()
|
||||||
.take(10)
|
.take(10)
|
||||||
.map(|m| format!("{:?}: {}", m.role, m.content.as_deref().unwrap_or("")))
|
.map(|m| format!("{:?}: {}", m.role, m.content.as_deref().unwrap_or("")))
|
||||||
@@ -371,7 +421,6 @@ fn compose_review_prompt(
|
|||||||
/// Return: `Ok(())` once the review has been kicked off; errors only
|
/// Return: `Ok(())` once the review has been kicked off; errors only
|
||||||
/// propagate from constructing the subagent context, not from the review
|
/// propagate from constructing the subagent context, not from the review
|
||||||
/// itself (that failure is reported via a `SystemNote` instead).
|
/// itself (that failure is reported via a `SystemNote` instead).
|
||||||
#[allow(clippy::unnecessary_debug_formatting)]
|
|
||||||
pub fn trigger_review(state: &mut AppStateRest) {
|
pub fn trigger_review(state: &mut AppStateRest) {
|
||||||
state.misc.lesson_running = true;
|
state.misc.lesson_running = true;
|
||||||
|
|
||||||
@@ -380,17 +429,22 @@ pub fn trigger_review(state: &mut AppStateRest) {
|
|||||||
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
|
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
|
||||||
if !content.contains("docs/lesson") {
|
if !content.contains("docs/lesson") {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore_path) {
|
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||||
let prefix = if content.is_empty() || content.ends_with('\n') { "" } else { "\n" };
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&gitignore_path)
|
||||||
|
{
|
||||||
|
let prefix = if content.is_empty() || content.ends_with('\n') {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"\n"
|
||||||
|
};
|
||||||
let _ = writeln!(file, "{prefix}docs/lesson/");
|
let _ = writeln!(file, "{prefix}docs/lesson/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut def = AgentDefinition::new(
|
let mut def = AgentDefinition::new("lesson-generator".to_string(), "reviewer".to_string());
|
||||||
"lesson-generator".to_string(),
|
|
||||||
"reviewer".to_string(),
|
|
||||||
);
|
|
||||||
// Explicitly allow write_file for docs/lesson
|
// Explicitly allow write_file for docs/lesson
|
||||||
def.allowed_tools = Some(vec![
|
def.allowed_tools = Some(vec![
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
@@ -416,7 +470,10 @@ pub fn trigger_review(state: &mut AppStateRest) {
|
|||||||
} else if r.timed_out {
|
} else if r.timed_out {
|
||||||
format!("Build/test verification timed out ({}).", r.command)
|
format!("Build/test verification timed out ({}).", r.command)
|
||||||
} else {
|
} else {
|
||||||
format!("Build/test verification failed ({}). Output: {}", r.command, r.output)
|
format!(
|
||||||
|
"Build/test verification failed ({}). Output: {}",
|
||||||
|
r.command, r.output
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => "No build/test probe matched.".to_string(),
|
None => "No build/test probe matched.".to_string(),
|
||||||
@@ -432,13 +489,22 @@ pub fn trigger_review(state: &mut AppStateRest) {
|
|||||||
let mut rx = rx;
|
let mut rx = rx;
|
||||||
while let Some(event) = rx.blocking_recv() {
|
while let Some(event) = rx.blocking_recv() {
|
||||||
match &event {
|
match &event {
|
||||||
SubagentEvent::ToolCall { tool, .. } => tracing::debug!("[review] tool call: {}", tool),
|
SubagentEvent::ToolCall { tool, .. } => {
|
||||||
SubagentEvent::ToolResult { tool, .. } => tracing::debug!("[review] tool result: {}", tool),
|
tracing::debug!("[review] tool call: {}", tool)
|
||||||
|
}
|
||||||
|
SubagentEvent::ToolResult { tool, .. } => {
|
||||||
|
tracing::debug!("[review] tool result: {}", tool)
|
||||||
|
}
|
||||||
SubagentEvent::StepCompleted { .. } => tracing::trace!("[review] step completed"),
|
SubagentEvent::StepCompleted { .. } => tracing::trace!("[review] step completed"),
|
||||||
SubagentEvent::StepFailed { step, error } => tracing::warn!("[review] step {} failed: {}", step, error),
|
SubagentEvent::StepFailed { step, error } => {
|
||||||
|
tracing::warn!("[review] step {} failed: {}", step, error)
|
||||||
|
}
|
||||||
SubagentEvent::Progress(_) => {}
|
SubagentEvent::Progress(_) => {}
|
||||||
SubagentEvent::Completed { .. } => tracing::debug!("[review] completed"),
|
SubagentEvent::Completed => tracing::debug!("[review] completed"),
|
||||||
SubagentEvent::Usage { tokens_in, tokens_out } => {
|
SubagentEvent::Usage {
|
||||||
|
tokens_in,
|
||||||
|
tokens_out,
|
||||||
|
} => {
|
||||||
if let Ok(mut q) = turn_events_for_drain.lock() {
|
if let Ok(mut q) = turn_events_for_drain.lock() {
|
||||||
q.push_back(TurnEvent::ReviewUsage {
|
q.push_back(TurnEvent::ReviewUsage {
|
||||||
tokens_in: *tokens_in,
|
tokens_in: *tokens_in,
|
||||||
@@ -487,15 +553,18 @@ const STALE_AFTER_DAYS: i64 = 60;
|
|||||||
/// `mem.write`.
|
/// `mem.write`.
|
||||||
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
|
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
|
||||||
let mut flagged = Vec::new();
|
let mut flagged = Vec::new();
|
||||||
let names = MarkdownMemoryRepository::new().list(memory_dir).unwrap_or_default();
|
let names = MarkdownMemoryRepository::new()
|
||||||
|
.list(memory_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000;
|
let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000;
|
||||||
for name in names {
|
for name in names {
|
||||||
if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) {
|
if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) {
|
||||||
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
|
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
|
||||||
mem.lifecycle = "stale".to_string();
|
mem.lifecycle = "stale".to_string();
|
||||||
MarkdownMemoryRepository::new().save(memory_dir, &mem)
|
MarkdownMemoryRepository::new()
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
.save(memory_dir, &mem)
|
||||||
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
flagged.push(name);
|
flagged.push(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,7 +590,11 @@ pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
|||||||
if !flagged.is_empty() {
|
if !flagged.is_empty() {
|
||||||
state.push_toast(Toast::new(
|
state.push_toast(Toast::new(
|
||||||
ToastKind::Info,
|
ToastKind::Info,
|
||||||
format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")),
|
format!(
|
||||||
|
"Staleness sweep: {} lesson(s) flagged as stale: {}",
|
||||||
|
flagged.len(),
|
||||||
|
flagged.join(", ")
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -551,7 +624,10 @@ pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson>
|
|||||||
/// Write the session's pending-lessons queue to disk as pretty JSON.
|
/// Write the session's pending-lessons queue to disk as pretty JSON.
|
||||||
///
|
///
|
||||||
/// Return: `Ok(())`, or an I/O error from writing the file.
|
/// 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<()> {
|
pub fn save_pending_lessons(
|
||||||
|
session_dir: &std::path::Path,
|
||||||
|
pending: &[PendingLesson],
|
||||||
|
) -> std::io::Result<()> {
|
||||||
let path = session_dir.join("pending_lessons.json");
|
let path = session_dir.join("pending_lessons.json");
|
||||||
let data = serde_json::to_string_pretty(pending)?;
|
let data = serde_json::to_string_pretty(pending)?;
|
||||||
std::fs::write(&path, data)
|
std::fs::write(&path, data)
|
||||||
@@ -570,7 +646,10 @@ pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLes
|
|||||||
///
|
///
|
||||||
/// Return: the still-pending lessons (post-commit), or an I/O error from
|
/// Return: the still-pending lessons (post-commit), or an I/O error from
|
||||||
/// writing memory files or the queue.
|
/// 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>> {
|
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 pending = load_pending_lessons(session_dir);
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let grace_window = 5_000;
|
let grace_window = 5_000;
|
||||||
@@ -599,8 +678,9 @@ pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::
|
|||||||
after_snippet: None,
|
after_snippet: None,
|
||||||
provenances: vec![],
|
provenances: vec![],
|
||||||
};
|
};
|
||||||
MarkdownMemoryRepository::new().save(memory_dir, &mem)
|
MarkdownMemoryRepository::new()
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
.save(memory_dir, &mem)
|
||||||
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
save_pending_lessons(session_dir, &remaining)?;
|
save_pending_lessons(session_dir, &remaining)?;
|
||||||
@@ -644,8 +724,9 @@ pub fn resolve_pending_lesson(
|
|||||||
after_snippet: None,
|
after_snippet: None,
|
||||||
provenances: vec![],
|
provenances: vec![],
|
||||||
};
|
};
|
||||||
MarkdownMemoryRepository::new().save(memory_dir, &mem)
|
MarkdownMemoryRepository::new()
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
.save(memory_dir, &mem)
|
||||||
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
remaining.push(p);
|
remaining.push(p);
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ pub enum Action {
|
|||||||
/// need to know how to *produce* actions.
|
/// need to know how to *produce* actions.
|
||||||
///
|
///
|
||||||
/// Return: nothing; `state` is mutated in place.
|
/// Return: nothing; `state` is mutated in place.
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||||
match action {
|
match action {
|
||||||
Action::ForceQuit => {
|
Action::ForceQuit => {
|
||||||
@@ -869,7 +868,7 @@ fn refresh_lesson_counters(memory_dir: &std::path::Path, rt: &mut crate::app::st
|
|||||||
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
|
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
|
||||||
/// Errors are silently ignored.
|
/// Errors are silently ignored.
|
||||||
fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
|
fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
|
||||||
if let Some(arc) = db {
|
if let Some(arc) = sess.db {
|
||||||
if let Ok(conn) = arc.lock() {
|
if let Ok(conn) = arc.lock() {
|
||||||
let _ = crate::model::msglog::insert_message(&conn, session_id, msg);
|
let _ = crate::model::msglog::insert_message(&conn, session_id, msg);
|
||||||
}
|
}
|
||||||
@@ -913,7 +912,6 @@ const HIVE_MIND_KICKOFF_NOTE: &str = "The Hive is stirring — Core Intelligence
|
|||||||
///
|
///
|
||||||
/// Return: `Ok(())` on successful completion, or an error from the LLM
|
/// Return: `Ok(())` on successful completion, or an error from the LLM
|
||||||
/// API after retries are exhausted.
|
/// API after retries are exhausted.
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
fn run_agent_turn(
|
fn run_agent_turn(
|
||||||
tc: &TurnCtx,
|
tc: &TurnCtx,
|
||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
@@ -1328,9 +1326,11 @@ fn run_agent_turn(
|
|||||||
&tool_name,
|
&tool_name,
|
||||||
&tool_call.id,
|
&tool_call.id,
|
||||||
&args,
|
&args,
|
||||||
&tc_ref.edit_log_session_dir,
|
&ToolExecSession {
|
||||||
&tc_ref.session_id,
|
dir: &tc_ref.edit_log_session_dir,
|
||||||
tc_ref.db.as_ref(),
|
id: &tc_ref.session_id,
|
||||||
|
db: tc_ref.db.as_ref(),
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Ok(result) => (result, false, is_edit_tool),
|
Ok(result) => (result, false, is_edit_tool),
|
||||||
Err(e) => (e.to_string(), true, false),
|
Err(e) => (e.to_string(), true, false),
|
||||||
@@ -1538,28 +1538,31 @@ fn run_agent_turn(
|
|||||||
///
|
///
|
||||||
/// Return: the tool's stdout string, or an error if no matching tool was
|
/// Return: the tool's stdout string, or an error if no matching tool was
|
||||||
/// found or the tool run itself failed.
|
/// found or the tool run itself failed.
|
||||||
#[allow(clippy::too_many_arguments)]
|
struct ToolExecSession<'a> {
|
||||||
|
dir: &'a std::path::Path,
|
||||||
|
id: &'a str,
|
||||||
|
db: Option<&'a std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
||||||
|
}
|
||||||
|
|
||||||
fn execute_one_tool(
|
fn execute_one_tool(
|
||||||
tools: &[Box<dyn crate::tool::Tool>],
|
tools: &[Box<dyn crate::tool::Tool>],
|
||||||
ctx: &crate::tool::ToolCtx,
|
ctx: &crate::tool::ToolCtx,
|
||||||
name: &str,
|
name: &str,
|
||||||
tool_call_id: &str,
|
tool_call_id: &str,
|
||||||
args: &serde_json::Value,
|
args: &serde_json::Value,
|
||||||
session_dir: &std::path::Path,
|
sess: &ToolExecSession<'_>,
|
||||||
session_id: &str,
|
|
||||||
db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
if tool.name() == name {
|
if tool.name() == name {
|
||||||
// Snapshot current file content before write/edit for rewind
|
// Snapshot current file content before write/edit for rewind
|
||||||
if (name == "write" || name == "edit") && !tool_call_id.is_empty() {
|
if (name == "write" || name == "edit") && !tool_call_id.is_empty() {
|
||||||
if let Some(arc) = db {
|
if let Some(arc) = sess.db {
|
||||||
if let Ok(conn) = arc.lock() {
|
if let Ok(conn) = arc.lock() {
|
||||||
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) {
|
if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) {
|
||||||
if let Ok(bytes) = std::fs::read(&abs_path) {
|
if let Ok(bytes) = std::fs::read(&abs_path) {
|
||||||
let _ = crate::model::msglog::store_blob(
|
let _ = crate::model::msglog::store_blob(
|
||||||
&conn, session_id, tool_call_id, &bytes, None,
|
&conn, sess.id, tool_call_id, &bytes, None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1600,11 +1603,11 @@ fn execute_one_tool(
|
|||||||
content_sha256,
|
content_sha256,
|
||||||
bytes_delta,
|
bytes_delta,
|
||||||
origin: ctx.origin.tag(),
|
origin: ctx.origin.tag(),
|
||||||
session_id: session_id.to_string(),
|
session_id: sess.id.to_string(),
|
||||||
};
|
};
|
||||||
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
|
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
|
||||||
if let Ok(mut el) = repo.open(session_dir) {
|
if let Ok(mut el) = repo.open(sess.dir) {
|
||||||
let _ = repo.append(session_dir, &mut el, entry);
|
let _ = repo.append(sess.dir, &mut el, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
//! `o200k_base` is an approximation for non-OpenAI providers but is far
|
//! `o200k_base` is an approximation for non-OpenAI providers but is far
|
||||||
//! closer than a flat byte-per-token guess; it's only used for the
|
//! closer than a flat byte-per-token guess; it's only used for the
|
||||||
//! 85%/95% budget thresholds, not for billing-accurate counts.
|
//! 85%/95% budget thresholds, not for billing-accurate counts.
|
||||||
use crate::dto::chat::message::ChatMessage;
|
|
||||||
|
|
||||||
/// Count tokens in a single string under `o200k_base`.
|
/// Count tokens in a single string under `o200k_base`.
|
||||||
///
|
///
|
||||||
@@ -25,20 +25,16 @@ pub fn count_tokens(text: &str) -> usize {
|
|||||||
.len()
|
.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count tokens in a `ChatMessage`'s text content.
|
|
||||||
///
|
|
||||||
/// Return: 0 for a message with no `content` (e.g. an assistant message
|
|
||||||
/// that only carries `tool_calls`).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn count_message_tokens(msg: &ChatMessage) -> usize {
|
|
||||||
msg.content.as_deref().map_or(0, count_tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::dto::chat::message::ChatMessage;
|
use crate::dto::chat::message::ChatMessage;
|
||||||
|
|
||||||
|
/// Count tokens in a `ChatMessage`'s text content.
|
||||||
|
fn count_message_tokens(msg: &ChatMessage) -> usize {
|
||||||
|
msg.content.as_deref().map_or(0, count_tokens)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_string_has_zero_tokens() {
|
fn empty_string_has_zero_tokens() {
|
||||||
assert_eq!(count_tokens(""), 0);
|
assert_eq!(count_tokens(""), 0);
|
||||||
|
|||||||
@@ -43,9 +43,11 @@ mod tests {
|
|||||||
temperature: None,
|
temperature: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let mut settings = Settings::default();
|
let settings = Settings {
|
||||||
settings.provider = "zen".to_string();
|
provider: "zen".to_string(),
|
||||||
settings.model = "deepseek-v4-flash-free".to_string();
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
assert_eq!(resolve(&app_config, &settings), 128_000);
|
assert_eq!(resolve(&app_config, &settings), 128_000);
|
||||||
}
|
}
|
||||||
@@ -53,9 +55,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_default_context_window_when_no_role_matches() {
|
fn falls_back_to_default_context_window_when_no_role_matches() {
|
||||||
let app_config = AppConfig::default();
|
let app_config = AppConfig::default();
|
||||||
let mut settings = Settings::default();
|
let settings = Settings {
|
||||||
settings.provider = "nonexistent".to_string();
|
provider: "nonexistent".to_string(),
|
||||||
settings.model = "nonexistent-model".to_string();
|
model: "nonexistent-model".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve(&app_config, &settings),
|
resolve(&app_config, &settings),
|
||||||
@@ -76,9 +80,11 @@ mod tests {
|
|||||||
temperature: None,
|
temperature: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let mut settings = Settings::default();
|
let settings = Settings {
|
||||||
settings.provider = "zen".to_string();
|
provider: "zen".to_string(),
|
||||||
settings.model = "deepseek-v4-flash-free".to_string();
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve(&app_config, &settings),
|
resolve(&app_config, &settings),
|
||||||
|
|||||||
@@ -2,355 +2,4 @@
|
|||||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||||
pub mod turn;
|
pub mod turn;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
pub use zesdex_entities::{SseParser, StreamEvent};
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
/// One atomic event extracted from an LLM streaming response stream.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum StreamEvent {
|
|
||||||
Token(String),
|
|
||||||
Reasoning(String),
|
|
||||||
ToolCallDelta {
|
|
||||||
index: usize,
|
|
||||||
id: Option<String>,
|
|
||||||
name: Option<String>,
|
|
||||||
arguments_delta: String,
|
|
||||||
},
|
|
||||||
Usage {
|
|
||||||
prompt_tokens: u64,
|
|
||||||
completion_tokens: u64,
|
|
||||||
total_tokens: u64,
|
|
||||||
},
|
|
||||||
Done,
|
|
||||||
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>,
|
|
||||||
data_lines: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SseParser {
|
|
||||||
/// Create a new parser with an empty buffer.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
SseParser {
|
|
||||||
buffer: String::new(),
|
|
||||||
event_type: None,
|
|
||||||
data_lines: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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();
|
|
||||||
while let Some(line_end) = self.buffer.find('\n') {
|
|
||||||
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
|
|
||||||
self.buffer = self.buffer[line_end + 1..].to_string();
|
|
||||||
if line.is_empty() {
|
|
||||||
events.extend(self.flush_event());
|
|
||||||
} else if let Some(ty) = line.strip_prefix("event: ") {
|
|
||||||
self.event_type = Some(ty.trim().to_string());
|
|
||||||
} else if let Some(data) = line.strip_prefix("data:") {
|
|
||||||
// Handle both "data: {...}" (with space) and "data:{...}"
|
|
||||||
// (without space). Some providers omit the trailing space.
|
|
||||||
let data = data.trim_start().to_string();
|
|
||||||
self.data_lines.push(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
let event_type = self.event_type.take().unwrap_or_default();
|
|
||||||
if data.is_empty() || data == "[DONE]" {
|
|
||||||
if data == "[DONE]" {
|
|
||||||
return vec![StreamEvent::Done];
|
|
||||||
}
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
let value: Value = match serde_json::from_str(&data) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("[stream] failed to parse chunk: {}", e);
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut events = Vec::new();
|
|
||||||
|
|
||||||
if let Some(usage) = value.get("usage") {
|
|
||||||
if !usage.is_null() {
|
|
||||||
let prompt_tokens = usage
|
|
||||||
.get("prompt_tokens")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
|
|
||||||
0
|
|
||||||
});
|
|
||||||
let completion_tokens = usage
|
|
||||||
.get("completion_tokens")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
|
||||||
0
|
|
||||||
});
|
|
||||||
let total_tokens = usage
|
|
||||||
.get("total_tokens")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
|
||||||
prompt_tokens + completion_tokens
|
|
||||||
});
|
|
||||||
events.push(StreamEvent::Usage {
|
|
||||||
prompt_tokens,
|
|
||||||
completion_tokens,
|
|
||||||
total_tokens,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut other_events = match event_type.as_str() {
|
|
||||||
"message.stop" => vec![StreamEvent::Done],
|
|
||||||
"message.delta" | "" => {
|
|
||||||
let mut d_events = Vec::new();
|
|
||||||
if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
|
|
||||||
if let Some(choices) = delta.as_array() {
|
|
||||||
if let Some(choice) = choices.first() {
|
|
||||||
if let Some(d) = choice.get("delta") {
|
|
||||||
// Content token
|
|
||||||
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
|
||||||
d_events.push(StreamEvent::Token(content.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reasoning token
|
|
||||||
if let Some(reasoning) =
|
|
||||||
d.get("reasoning_content").and_then(|r| r.as_str())
|
|
||||||
{
|
|
||||||
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tool calls — iterate ALL entries, not just first()
|
|
||||||
if let Some(tool_calls) =
|
|
||||||
d.get("tool_calls").and_then(|tc| tc.as_array())
|
|
||||||
{
|
|
||||||
for tc in tool_calls {
|
|
||||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
|
||||||
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
|
|
||||||
0
|
|
||||||
}) as usize;
|
|
||||||
let id = tc
|
|
||||||
.get("id")
|
|
||||||
.and_then(|i| i.as_str())
|
|
||||||
.map(std::string::ToString::to_string);
|
|
||||||
let name = tc
|
|
||||||
.get("function")
|
|
||||||
.and_then(|f| f.get("name"))
|
|
||||||
.and_then(|n| n.as_str())
|
|
||||||
.map(std::string::ToString::to_string);
|
|
||||||
let args_delta = tc
|
|
||||||
.get("function")
|
|
||||||
.and_then(|f| f.get("arguments"))
|
|
||||||
.and_then(|a| a.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
d_events.push(StreamEvent::ToolCallDelta {
|
|
||||||
index,
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
arguments_delta: args_delta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finish reason
|
|
||||||
if let Some(reason) =
|
|
||||||
choice.get("finish_reason").and_then(|r| r.as_str())
|
|
||||||
{
|
|
||||||
if reason == "stop" || reason == "tool_calls" {
|
|
||||||
d_events.push(StreamEvent::Done);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
|
||||||
d_events.push(StreamEvent::Token(content.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d_events
|
|
||||||
}
|
|
||||||
_ => vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
events.append(&mut other_events);
|
|
||||||
events
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_parses_single_token_chunk() {
|
|
||||||
let mut p = SseParser::new();
|
|
||||||
let events = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
match &events[0] {
|
|
||||||
StreamEvent::Token(t) => assert_eq!(t, "hello"),
|
|
||||||
other => panic!("expected Token, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_handles_chunk_split_mid_line() {
|
|
||||||
let mut p = SseParser::new();
|
|
||||||
let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial");
|
|
||||||
assert!(
|
|
||||||
e1.is_empty(),
|
|
||||||
"no event until the line and blank separator complete"
|
|
||||||
);
|
|
||||||
let e2 = p.feed("\"}}]}\n\n");
|
|
||||||
assert_eq!(e2.len(), 1);
|
|
||||||
match &e2[0] {
|
|
||||||
StreamEvent::Token(t) => assert_eq!(t, "partial"),
|
|
||||||
other => panic!("expected Token, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_emits_done_on_done_sentinel() {
|
|
||||||
let mut p = SseParser::new();
|
|
||||||
let events = p.feed("data: [DONE]\n\n");
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
assert!(matches!(events[0], StreamEvent::Done));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_emits_done_on_finish_reason_stop() {
|
|
||||||
let mut p = SseParser::new();
|
|
||||||
let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n");
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
assert!(matches!(events[0], StreamEvent::Done));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_parses_tool_call_delta() {
|
|
||||||
let mut p = SseParser::new();
|
|
||||||
let events = p.feed(
|
|
||||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"cmd\\\"\"}}]}}]}\n\n",
|
|
||||||
);
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
match &events[0] {
|
|
||||||
StreamEvent::ToolCallDelta {
|
|
||||||
index,
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
arguments_delta,
|
|
||||||
} => {
|
|
||||||
assert_eq!(*index, 0);
|
|
||||||
assert_eq!(id.as_deref(), Some("call_1"));
|
|
||||||
assert_eq!(name.as_deref(), Some("bash"));
|
|
||||||
assert_eq!(arguments_delta, "{\"cmd\"");
|
|
||||||
}
|
|
||||||
other => panic!("expected ToolCallDelta, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_parses_usage_chunk() {
|
|
||||||
let mut p = SseParser::new();
|
|
||||||
let events = p.feed(
|
|
||||||
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
|
|
||||||
);
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
match &events[0] {
|
|
||||||
StreamEvent::Usage {
|
|
||||||
prompt_tokens,
|
|
||||||
completion_tokens,
|
|
||||||
total_tokens,
|
|
||||||
} => {
|
|
||||||
assert_eq!(*prompt_tokens, 10);
|
|
||||||
assert_eq!(*completion_tokens, 5);
|
|
||||||
assert_eq!(*total_tokens, 15);
|
|
||||||
}
|
|
||||||
other => panic!("expected Usage, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_parses_usage_and_content_bundled_chunk() {
|
|
||||||
let mut p = SseParser::new();
|
|
||||||
let events = p.feed(
|
|
||||||
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
|
|
||||||
);
|
|
||||||
assert_eq!(events.len(), 2);
|
|
||||||
match (&events[0], &events[1]) {
|
|
||||||
(
|
|
||||||
StreamEvent::Usage {
|
|
||||||
prompt_tokens,
|
|
||||||
completion_tokens,
|
|
||||||
total_tokens,
|
|
||||||
},
|
|
||||||
StreamEvent::Token(t),
|
|
||||||
) => {
|
|
||||||
assert_eq!(*prompt_tokens, 10);
|
|
||||||
assert_eq!(*completion_tokens, 5);
|
|
||||||
assert_eq!(*total_tokens, 15);
|
|
||||||
assert_eq!(t, "hello");
|
|
||||||
}
|
|
||||||
other => panic!("expected [Usage, Token], got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_ignores_empty_data_lines() {
|
|
||||||
let mut p = SseParser::new();
|
|
||||||
let events = p.feed(": comment\n\n");
|
|
||||||
assert!(events.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn feed_multiple_events_across_one_chunk() {
|
|
||||||
let mut p = SseParser::new();
|
|
||||||
let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n";
|
|
||||||
let events = p.feed(chunk);
|
|
||||||
assert_eq!(events.len(), 2);
|
|
||||||
match (&events[0], &events[1]) {
|
|
||||||
(StreamEvent::Token(a), StreamEvent::Token(b)) => {
|
|
||||||
assert_eq!(a, "a");
|
|
||||||
assert_eq!(b, "b");
|
|
||||||
}
|
|
||||||
other => panic!("expected two Tokens, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Application-level "miscellaneous" state: scroll, input buffer,
|
//! Application-level "miscellaneous" state: scroll, input buffer,
|
||||||
//! overlay stack, toasts, editor, and autocomplete.
|
//! overlay stack, toasts, editor, and autocomplete.
|
||||||
|
use super::types::Overlay;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use super::types::Overlay;
|
|
||||||
|
|
||||||
/// A shared, async-writable cache of directory entries, used to avoid
|
/// A shared, async-writable cache of directory entries, used to avoid
|
||||||
/// re-reading a directory every render frame.
|
/// re-reading a directory every render frame.
|
||||||
@@ -134,7 +134,6 @@ const COMMANDS: &[&str] = &[
|
|||||||
"/model",
|
"/model",
|
||||||
"/model ls",
|
"/model ls",
|
||||||
"/model add",
|
"/model add",
|
||||||
|
|
||||||
"/todo",
|
"/todo",
|
||||||
"/usage",
|
"/usage",
|
||||||
"/compact",
|
"/compact",
|
||||||
@@ -211,7 +210,10 @@ impl InputState {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let boundary_ok = at_pos == 0
|
let boundary_ok = at_pos == 0
|
||||||
|| before_cursor[..at_pos].chars().next_back().is_some_and(char::is_whitespace);
|
|| before_cursor[..at_pos]
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.is_some_and(char::is_whitespace);
|
||||||
if !boundary_ok {
|
if !boundary_ok {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -225,8 +227,8 @@ impl InputState {
|
|||||||
/// if none, close and return → otherwise fuzzy-match `query` against
|
/// if none, close and return → otherwise fuzzy-match `query` against
|
||||||
/// `files` via `nucleo-matcher`, keep the top 10 by score.
|
/// `files` via `nucleo-matcher`, keep the top 10 by score.
|
||||||
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
||||||
use nucleo_matcher::{Config, Matcher};
|
|
||||||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||||
|
use nucleo_matcher::{Config, Matcher};
|
||||||
let Some((start, query)) = self.mention_query_at_cursor() else {
|
let Some((start, query)) = self.mention_query_at_cursor() else {
|
||||||
self.close_autocomplete();
|
self.close_autocomplete();
|
||||||
return;
|
return;
|
||||||
@@ -234,7 +236,11 @@ impl InputState {
|
|||||||
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
||||||
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
||||||
let matched_files = pattern.match_list(files.iter(), &mut matcher);
|
let matched_files = pattern.match_list(files.iter(), &mut matcher);
|
||||||
self.autocomplete_candidates = matched_files.into_iter().take(10).map(|(f, _)| f.clone()).collect();
|
self.autocomplete_candidates = matched_files
|
||||||
|
.into_iter()
|
||||||
|
.take(10)
|
||||||
|
.map(|(f, _)| f.clone())
|
||||||
|
.collect();
|
||||||
self.autocomplete_kind = AutocompleteKind::FileMention;
|
self.autocomplete_kind = AutocompleteKind::FileMention;
|
||||||
self.mention_start = start;
|
self.mention_start = start;
|
||||||
self.autocomplete_idx = 0;
|
self.autocomplete_idx = 0;
|
||||||
@@ -245,11 +251,17 @@ impl InputState {
|
|||||||
/// Wraps around at the boundaries.
|
/// Wraps around at the boundaries.
|
||||||
pub fn cycle_autocomplete(&mut self, forward: bool) {
|
pub fn cycle_autocomplete(&mut self, forward: bool) {
|
||||||
let n = self.autocomplete_candidates.len();
|
let n = self.autocomplete_candidates.len();
|
||||||
if n == 0 { return; }
|
if n == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if forward {
|
if forward {
|
||||||
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
|
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
|
||||||
} else {
|
} else {
|
||||||
self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 };
|
self.autocomplete_idx = if self.autocomplete_idx == 0 {
|
||||||
|
n - 1
|
||||||
|
} else {
|
||||||
|
self.autocomplete_idx - 1
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +273,11 @@ impl InputState {
|
|||||||
///
|
///
|
||||||
/// Return: `true` if a candidate was selected, `false` if none existed.
|
/// Return: `true` if a candidate was selected, `false` if none existed.
|
||||||
pub fn select_autocomplete(&mut self) -> bool {
|
pub fn select_autocomplete(&mut self) -> bool {
|
||||||
let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx).cloned() else {
|
let Some(candidate) = self
|
||||||
|
.autocomplete_candidates
|
||||||
|
.get(self.autocomplete_idx)
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
match self.autocomplete_kind {
|
match self.autocomplete_kind {
|
||||||
@@ -282,7 +298,8 @@ impl InputState {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let replacement = format!("@{candidate} ");
|
let replacement = format!("@{candidate} ");
|
||||||
self.buffer.replace_range(self.mention_start..self.cursor, &replacement);
|
self.buffer
|
||||||
|
.replace_range(self.mention_start..self.cursor, &replacement);
|
||||||
self.cursor = self.mention_start + replacement.len();
|
self.cursor = self.mention_start + replacement.len();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,8 +423,6 @@ pub struct MiscState {
|
|||||||
pub selected_index: usize,
|
pub selected_index: usize,
|
||||||
pub editor: Option<super::super::mode::editor::EditorState>,
|
pub editor: Option<super::super::mode::editor::EditorState>,
|
||||||
pub api_connected: bool,
|
pub api_connected: bool,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub api_context_length: Option<u32>,
|
|
||||||
pub tick_count: u64,
|
pub tick_count: u64,
|
||||||
pub todo_content: String,
|
pub todo_content: String,
|
||||||
pub lesson_running: bool,
|
pub lesson_running: bool,
|
||||||
@@ -427,7 +442,6 @@ impl MiscState {
|
|||||||
selected_index: 0,
|
selected_index: 0,
|
||||||
editor: None,
|
editor: None,
|
||||||
api_connected: false,
|
api_connected: false,
|
||||||
api_context_length: None,
|
|
||||||
tick_count: 0,
|
tick_count: 0,
|
||||||
todo_content: String::new(),
|
todo_content: String::new(),
|
||||||
lesson_running: false,
|
lesson_running: false,
|
||||||
@@ -443,7 +457,12 @@ impl MiscState {
|
|||||||
///
|
///
|
||||||
/// Return: the expired toasts (after removal).
|
/// Return: the expired toasts (after removal).
|
||||||
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
|
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();
|
let expired: Vec<_> = self
|
||||||
|
.toasts
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.expired(now_ms))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
self.toasts.retain(|t| !t.expired(now_ms));
|
self.toasts.retain(|t| !t.expired(now_ms));
|
||||||
expired
|
expired
|
||||||
}
|
}
|
||||||
@@ -463,13 +482,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn mention_at_buffer_start_triggers() {
|
fn mention_at_buffer_start_triggers() {
|
||||||
let input = input_with("@mai", 4);
|
let input = input_with("@mai", 4);
|
||||||
assert_eq!(input.mention_query_at_cursor(), Some((0, "mai".to_string())));
|
assert_eq!(
|
||||||
|
input.mention_query_at_cursor(),
|
||||||
|
Some((0, "mai".to_string()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mention_after_space_mid_sentence_triggers() {
|
fn mention_after_space_mid_sentence_triggers() {
|
||||||
let input = input_with("look at @read", 13);
|
let input = input_with("look at @read", 13);
|
||||||
assert_eq!(input.mention_query_at_cursor(), Some((8, "read".to_string())));
|
assert_eq!(
|
||||||
|
input.mention_query_at_cursor(),
|
||||||
|
Some((8, "read".to_string()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ use crate::app::mcp::manager::McpManager;
|
|||||||
use crate::app::workflow::engine::WorkflowEngine;
|
use crate::app::workflow::engine::WorkflowEngine;
|
||||||
use zesdex_cms::domain::app_config::AppConfig;
|
use zesdex_cms::domain::app_config::AppConfig;
|
||||||
use zesdex_cms::domain::edit_log::EditLog;
|
use zesdex_cms::domain::edit_log::EditLog;
|
||||||
use zesdex_cms::domain::repository::EditLogRepository;
|
|
||||||
use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository;
|
|
||||||
use zesdex_cms::domain::repository::AppConfigRepository;
|
use zesdex_cms::domain::repository::AppConfigRepository;
|
||||||
|
use zesdex_cms::domain::repository::EditLogRepository;
|
||||||
use zesdex_cms::domain::repository::SettingsRepository;
|
use zesdex_cms::domain::repository::SettingsRepository;
|
||||||
use zesdex_cms::domain::settings::Settings;
|
use zesdex_cms::domain::settings::Settings;
|
||||||
use zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository;
|
use zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository;
|
||||||
|
use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository;
|
||||||
use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository;
|
use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository;
|
||||||
|
|
||||||
/// A single transcript entry rendered in the TUI chat pane.
|
/// A single transcript entry rendered in the TUI chat pane.
|
||||||
@@ -51,7 +51,6 @@ impl ChatMessageDisplay {
|
|||||||
/// other module.
|
/// other module.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppStateRest {
|
pub struct AppStateRest {
|
||||||
|
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
pub app_config: AppConfig,
|
pub app_config: AppConfig,
|
||||||
pub workspace_roots: Vec<PathBuf>,
|
pub workspace_roots: Vec<PathBuf>,
|
||||||
@@ -91,7 +90,11 @@ impl AppStateRest {
|
|||||||
/// Why: falls back to `memory_dir` itself (with a warning) when it has
|
/// 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
|
/// no parent, and to an empty session id when the dir name can't be
|
||||||
/// read, so construction never fails.
|
/// read, so construction never fails.
|
||||||
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: &std::path::Path, memory_dir: PathBuf) -> Self {
|
pub fn new(
|
||||||
|
workspace_roots: Vec<PathBuf>,
|
||||||
|
session_dir: &std::path::Path,
|
||||||
|
memory_dir: PathBuf,
|
||||||
|
) -> Self {
|
||||||
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
|
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
|
||||||
let settings = JsonSettingsRepository::new()
|
let settings = JsonSettingsRepository::new()
|
||||||
.load(&store_base_dir)
|
.load(&store_base_dir)
|
||||||
@@ -99,18 +102,27 @@ impl AppStateRest {
|
|||||||
let app_config = JsonAppConfigRepository::new()
|
let app_config = JsonAppConfigRepository::new()
|
||||||
.load(&store_base_dir)
|
.load(&store_base_dir)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let worktrees_dir = memory_dir.parent().unwrap_or_else(|| {
|
let worktrees_dir = memory_dir
|
||||||
tracing::warn!("[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display());
|
.parent()
|
||||||
&memory_dir
|
.unwrap_or_else(|| {
|
||||||
}).join("worktrees");
|
tracing::warn!(
|
||||||
|
"[state] memory_dir '{}' has no parent, using it for worktrees",
|
||||||
|
memory_dir.display()
|
||||||
|
);
|
||||||
|
&memory_dir
|
||||||
|
})
|
||||||
|
.join("worktrees");
|
||||||
let dir_cache = DirCache::new();
|
let dir_cache = DirCache::new();
|
||||||
let session_id = session_dir
|
let session_id = session_dir.file_name().map_or_else(
|
||||||
.file_name().map_or_else(|| {
|
|| {
|
||||||
tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
|
tracing::warn!(
|
||||||
|
"[state] session_dir has no file_name component, using empty session_id"
|
||||||
|
);
|
||||||
String::new()
|
String::new()
|
||||||
}, |n| n.to_string_lossy().to_string());
|
},
|
||||||
|
|n| n.to_string_lossy().to_string(),
|
||||||
|
);
|
||||||
let mut state = AppStateRest {
|
let mut state = AppStateRest {
|
||||||
|
|
||||||
settings,
|
settings,
|
||||||
app_config,
|
app_config,
|
||||||
workspace_roots,
|
workspace_roots,
|
||||||
@@ -123,10 +135,15 @@ impl AppStateRest {
|
|||||||
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||||
mention_index: MentionIndex::new(),
|
mention_index: MentionIndex::new(),
|
||||||
edit_log: JsonlEditLogRepository::new().open(session_dir).unwrap_or_else(|e| {
|
edit_log: JsonlEditLogRepository::new()
|
||||||
tracing::warn!("[state] failed to open edit log at '{}': {e}", session_dir.display());
|
.open(session_dir)
|
||||||
EditLog::new()
|
.unwrap_or_else(|e| {
|
||||||
}),
|
tracing::warn!(
|
||||||
|
"[state] failed to open edit log at '{}': {e}",
|
||||||
|
session_dir.display()
|
||||||
|
);
|
||||||
|
EditLog::new()
|
||||||
|
}),
|
||||||
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
||||||
workflow_engine: WorkflowEngine::new(),
|
workflow_engine: WorkflowEngine::new(),
|
||||||
mcp_manager: McpManager::new(),
|
mcp_manager: McpManager::new(),
|
||||||
@@ -149,7 +166,9 @@ impl AppStateRest {
|
|||||||
let mut hasher = sha2::Sha256::new();
|
let mut hasher = sha2::Sha256::new();
|
||||||
hasher.update(abs_root.to_string_lossy().as_bytes());
|
hasher.update(abs_root.to_string_lossy().as_bytes());
|
||||||
let hash_hex = hex::encode(hasher.finalize());
|
let hash_hex = hex::encode(hasher.finalize());
|
||||||
let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
|
let folder_name = abs_root
|
||||||
|
.file_name()
|
||||||
|
.map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
|
||||||
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
|
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
|
||||||
let history_dir = base_dir.join("history");
|
let history_dir = base_dir.join("history");
|
||||||
let _ = std::fs::create_dir_all(&history_dir);
|
let _ = std::fs::create_dir_all(&history_dir);
|
||||||
@@ -194,9 +213,14 @@ impl AppStateRest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wrap the msg_queue in a static-lifetime closure for use as ProgressFn.
|
// Wrap the msg_queue in a static-lifetime closure for use as ProgressFn.
|
||||||
let progress: provisioner::ProgressFn = Some(&|msg: &str| push_msg(&msg_queue, msg));
|
let progress: provisioner::ProgressFn =
|
||||||
|
Some(&|msg: &str| push_msg(&msg_queue, msg));
|
||||||
|
|
||||||
let report = |msg: &str| { if let Some(f) = &progress { f(msg); }};
|
let report = |msg: &str| {
|
||||||
|
if let Some(f) = &progress {
|
||||||
|
f(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
report("LSP: provisioning servers...");
|
report("LSP: provisioning servers...");
|
||||||
let results = provisioner::provision_all_with_progress(progress);
|
let results = provisioner::provision_all_with_progress(progress);
|
||||||
@@ -204,18 +228,29 @@ impl AppStateRest {
|
|||||||
let connected = provisioner::auto_connect(&lsp_mgr, &results);
|
let connected = provisioner::auto_connect(&lsp_mgr, &results);
|
||||||
for name in &connected {
|
for name in &connected {
|
||||||
tracing::info!("LSP: {} connected", name);
|
tracing::info!("LSP: {} connected", name);
|
||||||
let m = format!("LSP: {name} connected ✓"); push_msg(&msg_queue, &m);
|
let m = format!("LSP: {name} connected ✓");
|
||||||
|
push_msg(&msg_queue, &m);
|
||||||
}
|
}
|
||||||
for r in &results {
|
for r in &results {
|
||||||
if let ProvisionResult::Failed { language, server_name, reason, .. } = r {
|
if let ProvisionResult::Failed {
|
||||||
|
language,
|
||||||
|
server_name,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
} = r
|
||||||
|
{
|
||||||
tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
|
tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
|
||||||
let m = format!("LSP: {server_name} ({language}) ✗ - {reason}"); push_msg(&msg_queue, &m);
|
let m = format!("LSP: {server_name} ({language}) ✗ - {reason}");
|
||||||
|
push_msg(&msg_queue, &m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if connected.is_empty() {
|
if connected.is_empty() {
|
||||||
let m = "LSP: no servers available — install manually or check prerequisites".to_string(); push_msg(&msg_queue, &m);
|
let m = "LSP: no servers available — install manually or check prerequisites"
|
||||||
|
.to_string();
|
||||||
|
push_msg(&msg_queue, &m);
|
||||||
} else {
|
} else {
|
||||||
let m = format!("LSP: {} server(s) connected", connected.len()); push_msg(&msg_queue, &m);
|
let m = format!("LSP: {} server(s) connected", connected.len());
|
||||||
|
push_msg(&msg_queue, &m);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -259,7 +294,11 @@ impl AppStateRest {
|
|||||||
}
|
}
|
||||||
let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
|
let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
|
||||||
let rel_str = rel.display().to_string();
|
let rel_str = rel.display().to_string();
|
||||||
let formatted = if i == 0 { rel_str } else { format!("[{i}]{rel_str}") };
|
let formatted = if i == 0 {
|
||||||
|
rel_str
|
||||||
|
} else {
|
||||||
|
format!("[{i}]{rel_str}")
|
||||||
|
};
|
||||||
paths.push(formatted);
|
paths.push(formatted);
|
||||||
if paths.len() >= MAX_MENTION_ENTRIES {
|
if paths.len() >= MAX_MENTION_ENTRIES {
|
||||||
break 'roots;
|
break 'roots;
|
||||||
@@ -275,10 +314,13 @@ impl AppStateRest {
|
|||||||
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
|
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
|
||||||
/// than propagating a panic.
|
/// than propagating a panic.
|
||||||
pub fn turn_in_flight(&self) -> bool {
|
pub fn turn_in_flight(&self) -> bool {
|
||||||
self.turn_in_flight.lock().map_or_else(|_| {
|
self.turn_in_flight.lock().map_or_else(
|
||||||
tracing::warn!("[state] turn_in_flight mutex poisoned");
|
|_| {
|
||||||
false
|
tracing::warn!("[state] turn_in_flight mutex poisoned");
|
||||||
}, |g| *g)
|
false
|
||||||
|
},
|
||||||
|
|g| *g,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shut down every running LSP server process.
|
/// Shut down every running LSP server process.
|
||||||
@@ -317,14 +359,28 @@ impl AppStateRest {
|
|||||||
/// `session_dir` itself -- logging a warning at each step down, so this
|
/// `session_dir` itself -- logging a warning at each step down, so this
|
||||||
/// never fails even on a shallow path.
|
/// never fails even on a shallow path.
|
||||||
pub fn store_base_dir(&self) -> std::path::PathBuf {
|
pub fn store_base_dir(&self) -> std::path::PathBuf {
|
||||||
self.session_dir.parent()
|
self.session_dir
|
||||||
.and_then(|p| p.parent()).map_or_else(|| {
|
.parent()
|
||||||
tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display());
|
.and_then(|p| p.parent())
|
||||||
self.session_dir.parent().map_or_else(|| {
|
.map_or_else(
|
||||||
tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display());
|
|| {
|
||||||
self.session_dir.clone()
|
tracing::warn!(
|
||||||
}, std::path::Path::to_path_buf)
|
"[state] session_dir '{}' has no grandparent, using parent",
|
||||||
}, std::path::Path::to_path_buf)
|
self.session_dir.display()
|
||||||
|
);
|
||||||
|
self.session_dir.parent().map_or_else(
|
||||||
|
|| {
|
||||||
|
tracing::warn!(
|
||||||
|
"[state] session_dir '{}' has no parent at all, using itself",
|
||||||
|
self.session_dir.display()
|
||||||
|
);
|
||||||
|
self.session_dir.clone()
|
||||||
|
},
|
||||||
|
std::path::Path::to_path_buf,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
std::path::Path::to_path_buf,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `ToolCtx` for tool calls originating from the main agent.
|
/// Build a `ToolCtx` for tool calls originating from the main agent.
|
||||||
|
|||||||
@@ -4,20 +4,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Cumulative token/latency counters for a session, persisted alongside it.
|
pub use zesdex_entities::seaorm::common::usage::UsageStats;
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
|
||||||
pub struct UsageStats {
|
|
||||||
pub tokens_in: u64,
|
|
||||||
pub tokens_out: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_tokens_in: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_tokens_out: u64,
|
|
||||||
pub api_calls: u64,
|
|
||||||
pub review_tokens: u64,
|
|
||||||
pub total_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mutable, serializable state for one session: chat history, tool
|
/// Mutable, serializable state for one session: chat history, tool
|
||||||
/// results, pending tools, background jobs, and lesson/review counters
|
/// results, pending tools, background jobs, and lesson/review counters
|
||||||
/// shown in the TUI status bar.
|
/// shown in the TUI status bar.
|
||||||
@@ -55,16 +42,7 @@ pub struct SessionRuntime {
|
|||||||
pub hive_mind_converged: bool,
|
pub hive_mind_converged: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record of one completed tool invocation, kept for transcript/history.
|
pub use zesdex_entities::seaorm::common::tool_result::ToolCallResult;
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ToolCallResult {
|
|
||||||
pub tool_call_id: String,
|
|
||||||
pub tool_name: String,
|
|
||||||
pub output: String,
|
|
||||||
pub is_error: bool,
|
|
||||||
pub duration_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tool call awaiting execution, along with which execution model
|
/// A tool call awaiting execution, along with which execution model
|
||||||
/// (inline, deferred, async) it should run under.
|
/// (inline, deferred, async) it should run under.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
//! bash exfiltration and destructive-pattern detection) so that subagents
|
//! bash exfiltration and destructive-pattern detection) so that subagents
|
||||||
//! are not a weaker link than the main agent.
|
//! are not a weaker link than the main agent.
|
||||||
|
|
||||||
use std::fmt::Write;
|
use super::context::SubagentContext;
|
||||||
use sha2::Digest;
|
use super::event::SubagentEvent;
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use crate::dto::chat::message::ChatMessage;
|
use crate::dto::chat::message::ChatMessage;
|
||||||
use crate::dto::provider::request::ToolDef;
|
use crate::dto::provider::request::ToolDef;
|
||||||
use crate::tool::{all_tools, tool_defs, tool_is_risky};
|
use crate::tool::{all_tools, tool_defs, tool_is_risky};
|
||||||
use super::context::SubagentContext;
|
use sha2::Digest;
|
||||||
use super::event::SubagentEvent;
|
use std::fmt::Write;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
use zesdex_cms::domain::repository::AppConfigRepository;
|
use zesdex_cms::domain::repository::AppConfigRepository;
|
||||||
use zesdex_cms::domain::repository::EditLogRepository;
|
use zesdex_cms::domain::repository::EditLogRepository;
|
||||||
use zesdex_cms::domain::repository::SettingsRepository;
|
use zesdex_cms::domain::repository::SettingsRepository;
|
||||||
@@ -29,7 +29,9 @@ use zesdex_cms::domain::repository::SettingsRepository;
|
|||||||
/// `build_subagent_context`'s default for non-reviewer roles).
|
/// `build_subagent_context`'s default for non-reviewer roles).
|
||||||
///
|
///
|
||||||
/// Return: `(tool impls, schema defs)` for the subagent to use.
|
/// 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>) {
|
fn build_subagent_tools(
|
||||||
|
allowed_tools: &[String],
|
||||||
|
) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
|
||||||
let all = all_tools();
|
let all = all_tools();
|
||||||
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
|
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
|
||||||
all.into_iter()
|
all.into_iter()
|
||||||
@@ -62,28 +64,44 @@ fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::T
|
|||||||
/// for this before issuing requests (see `run_subagent`).
|
/// for this before issuing requests (see `run_subagent`).
|
||||||
fn resolve_provider_config() -> (String, String, Option<String>, String) {
|
fn resolve_provider_config() -> (String, String, Option<String>, String) {
|
||||||
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
|
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
|
||||||
let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
let settings =
|
||||||
.load(&store_base_dir)
|
zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
||||||
.unwrap_or_default();
|
.load(&store_base_dir)
|
||||||
let app_config = zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new()
|
.unwrap_or_default();
|
||||||
.load(&store_base_dir)
|
let app_config =
|
||||||
.unwrap_or_default();
|
zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new()
|
||||||
|
.load(&store_base_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut api_key = settings.api_keys.get(&settings.provider).cloned().unwrap_or_else(|| {
|
let mut api_key = settings
|
||||||
tracing::warn!("[subagent] no API key for provider '{}' in settings, trying env/default", settings.provider);
|
.api_keys
|
||||||
String::new()
|
.get(&settings.provider)
|
||||||
});
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
tracing::warn!(
|
||||||
|
"[subagent] no API key for provider '{}' in settings, trying env/default",
|
||||||
|
settings.provider
|
||||||
|
);
|
||||||
|
String::new()
|
||||||
|
});
|
||||||
let model = settings.model.clone();
|
let model = settings.model.clone();
|
||||||
let base_url = app_config.providers.get(&settings.provider)
|
let base_url = app_config
|
||||||
|
.providers
|
||||||
|
.get(&settings.provider)
|
||||||
.map(|p| p.api_base.clone());
|
.map(|p| p.api_base.clone());
|
||||||
|
|
||||||
if api_key.is_empty() {
|
if api_key.is_empty() {
|
||||||
if let Some(provider_cfg) = app_config.providers.get(&settings.provider) {
|
if let Some(provider_cfg) = app_config.providers.get(&settings.provider) {
|
||||||
api_key = provider_cfg.api_key_env.as_ref()
|
api_key = provider_cfg
|
||||||
|
.api_key_env
|
||||||
|
.as_ref()
|
||||||
.and_then(|env| std::env::var(env).ok())
|
.and_then(|env| std::env::var(env).ok())
|
||||||
.or_else(|| provider_cfg.default_api_key.clone())
|
.or_else(|| provider_cfg.default_api_key.clone())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
tracing::warn!("[subagent] all API key resolution paths exhausted for '{}'", settings.provider);
|
tracing::warn!(
|
||||||
|
"[subagent] all API key resolution paths exhausted for '{}'",
|
||||||
|
settings.provider
|
||||||
|
);
|
||||||
String::new()
|
String::new()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -109,43 +127,82 @@ fn require_api_key(api_key: &str, provider: &str) -> anyhow::Result<()> {
|
|||||||
// ─── Subagent-level tool gating (mirrors Harness checks) ───
|
// ─── Subagent-level tool gating (mirrors Harness checks) ───
|
||||||
|
|
||||||
const STUB_PATTERNS: &[&str] = &[
|
const STUB_PATTERNS: &[&str] = &[
|
||||||
"todo!()", "todo!(",
|
"todo!()",
|
||||||
"unimplemented!()", "unimplemented!(",
|
"todo!(",
|
||||||
"FIXME", "fixme:", "XXX:", "PLACEHOLDER",
|
"unimplemented!()",
|
||||||
"REPLACE_ME", "stub_value", "stub_function",
|
"unimplemented!(",
|
||||||
"fake_response", "fake_data",
|
"FIXME",
|
||||||
"not implemented", "not yet implemented",
|
"fixme:",
|
||||||
"to be implemented", "to be done",
|
"XXX:",
|
||||||
|
"PLACEHOLDER",
|
||||||
|
"REPLACE_ME",
|
||||||
|
"stub_value",
|
||||||
|
"stub_function",
|
||||||
|
"fake_response",
|
||||||
|
"fake_data",
|
||||||
|
"not implemented",
|
||||||
|
"not yet implemented",
|
||||||
|
"to be implemented",
|
||||||
|
"to be done",
|
||||||
];
|
];
|
||||||
|
|
||||||
const DENIAL_PATTERNS: &[&str] = &[
|
const DENIAL_PATTERNS: &[&str] = &[
|
||||||
"// skip", "// skipping", "// skipping for now",
|
"// skip",
|
||||||
"// for now just", "// punt", "// hack:",
|
"// skipping",
|
||||||
"// workaround:", "// cba", "// later",
|
"// skipping for now",
|
||||||
"// do later", "// ignore for now", "// disable",
|
"// for now just",
|
||||||
"// bypass", "// quick fix", "// temp fix",
|
"// punt",
|
||||||
"// temporary fix", "// temp:", "// temporary:",
|
"// hack:",
|
||||||
|
"// workaround:",
|
||||||
|
"// cba",
|
||||||
|
"// later",
|
||||||
|
"// do later",
|
||||||
|
"// ignore for now",
|
||||||
|
"// disable",
|
||||||
|
"// bypass",
|
||||||
|
"// quick fix",
|
||||||
|
"// temp fix",
|
||||||
|
"// temporary fix",
|
||||||
|
"// temp:",
|
||||||
|
"// temporary:",
|
||||||
"// noop",
|
"// noop",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ASSUMPTION_PATTERNS: &[&str] = &[
|
const ASSUMPTION_PATTERNS: &[&str] = &[
|
||||||
"// assume", "// probably", "// guess",
|
"// assume",
|
||||||
"// should work", "// hopefully", "// i think",
|
"// probably",
|
||||||
"// should be fine", "// likely",
|
"// guess",
|
||||||
|
"// should work",
|
||||||
|
"// hopefully",
|
||||||
|
"// i think",
|
||||||
|
"// should be fine",
|
||||||
|
"// likely",
|
||||||
];
|
];
|
||||||
|
|
||||||
const EXFIL_PATTERNS: &[&str] = &[
|
const EXFIL_PATTERNS: &[&str] = &[
|
||||||
"curl ", "wget ", "nc -e ", "ncat ", "/dev/tcp/",
|
"curl ",
|
||||||
"base64 -d |", "base64 --decode |",
|
"wget ",
|
||||||
"openssl s_client", "ssh -R ",
|
"nc -e ",
|
||||||
"scp /", "rsync /",
|
"ncat ",
|
||||||
|
"/dev/tcp/",
|
||||||
|
"base64 -d |",
|
||||||
|
"base64 --decode |",
|
||||||
|
"openssl s_client",
|
||||||
|
"ssh -R ",
|
||||||
|
"scp /",
|
||||||
|
"rsync /",
|
||||||
];
|
];
|
||||||
|
|
||||||
const SENSITIVE_PATH_PATTERNS: &[&str] = &[
|
const SENSITIVE_PATH_PATTERNS: &[&str] = &[
|
||||||
".ssh/id_rsa", ".ssh/id_ed25519",
|
".ssh/id_rsa",
|
||||||
".aws/credentials", ".aws/config",
|
".ssh/id_ed25519",
|
||||||
".kube/config", ".docker/config.json",
|
".aws/credentials",
|
||||||
"/etc/shadow", "/etc/passwd", "/proc/self/environ",
|
".aws/config",
|
||||||
|
".kube/config",
|
||||||
|
".docker/config.json",
|
||||||
|
"/etc/shadow",
|
||||||
|
"/etc/passwd",
|
||||||
|
"/proc/self/environ",
|
||||||
];
|
];
|
||||||
|
|
||||||
const MIN_REASON_LEN: usize = 8;
|
const MIN_REASON_LEN: usize = 8;
|
||||||
@@ -157,10 +214,7 @@ const MIN_REASON_LEN: usize = 8;
|
|||||||
/// assumption language, bash exfiltration, destructive commands, sensitive
|
/// assumption language, bash exfiltration, destructive commands, sensitive
|
||||||
/// path reads — regardless of the allowed-tools list. Tools that are not
|
/// path reads — regardless of the allowed-tools list. Tools that are not
|
||||||
/// risky only get the basic allowlist check.
|
/// risky only get the basic allowlist check.
|
||||||
fn gate_subagent_tool_call(
|
fn gate_subagent_tool_call(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
||||||
tool_name: &str,
|
|
||||||
args: &serde_json::Value,
|
|
||||||
) -> Option<String> {
|
|
||||||
// File-mutating tools: write / edit / delete
|
// File-mutating tools: write / edit / delete
|
||||||
if matches!(tool_name, "write" | "edit" | "delete") {
|
if matches!(tool_name, "write" | "edit" | "delete") {
|
||||||
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
|
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
|
||||||
@@ -204,10 +258,16 @@ fn gate_subagent_tool_call(
|
|||||||
return Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string());
|
return Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string());
|
||||||
}
|
}
|
||||||
if contains_any(content, DENIAL_PATTERNS) {
|
if contains_any(content, DENIAL_PATTERNS) {
|
||||||
return Some("content contains denial/punt pattern; implement properly instead of skipping".to_string());
|
return Some(
|
||||||
|
"content contains denial/punt pattern; implement properly instead of skipping"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if contains_any(content, ASSUMPTION_PATTERNS) {
|
if contains_any(content, ASSUMPTION_PATTERNS) {
|
||||||
return Some("content contains assumption pattern; verify against data instead of guessing".to_string());
|
return Some(
|
||||||
|
"content contains assumption pattern; verify against data instead of guessing"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +291,9 @@ fn gate_subagent_tool_call(
|
|||||||
if !is_standard {
|
if !is_standard {
|
||||||
for pat in EXFIL_PATTERNS {
|
for pat in EXFIL_PATTERNS {
|
||||||
if cmd.contains(pat) {
|
if cmd.contains(pat) {
|
||||||
return Some(format!("potential data-exfiltration command blocked (matched '{pat}')"));
|
return Some(format!(
|
||||||
|
"potential data-exfiltration command blocked (matched '{pat}')"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,9 +302,21 @@ fn gate_subagent_tool_call(
|
|||||||
return Some(format!("refused to read/write sensitive path '{pat}'"));
|
return Some(format!("refused to read/write sensitive path '{pat}'"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let dangerous = ["rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~",
|
let dangerous = [
|
||||||
"rm -fr /", "mkfs.", "dd if=", ":(){", "> /dev/sda",
|
"rm -rf /",
|
||||||
"chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt "];
|
"rm -rf --no-preserve-root",
|
||||||
|
"rm -rf ~",
|
||||||
|
"rm -fr /",
|
||||||
|
"mkfs.",
|
||||||
|
"dd if=",
|
||||||
|
":(){",
|
||||||
|
"> /dev/sda",
|
||||||
|
"chmod -R 000 /",
|
||||||
|
"shutdown ",
|
||||||
|
"poweroff ",
|
||||||
|
"reboot ",
|
||||||
|
"halt ",
|
||||||
|
];
|
||||||
for pat in &dangerous {
|
for pat in &dangerous {
|
||||||
if cmd.contains(pat) {
|
if cmd.contains(pat) {
|
||||||
return Some(format!("destructive command pattern blocked: {pat}"));
|
return Some(format!("destructive command pattern blocked: {pat}"));
|
||||||
@@ -289,7 +363,9 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
|||||||
for entry in walker.flatten() {
|
for entry in walker.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if let Ok(rel) = path.strip_prefix(root) {
|
if let Ok(rel) = path.strip_prefix(root) {
|
||||||
if rel.as_os_str().is_empty() { continue; }
|
if rel.as_os_str().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||||
let prefix = if is_dir { "[DIR] " } else { " " };
|
let prefix = if is_dir { "[DIR] " } else { " " };
|
||||||
writeln!(out, " {}{}", prefix, rel.display()).unwrap();
|
writeln!(out, " {}{}", prefix, rel.display()).unwrap();
|
||||||
@@ -331,8 +407,10 @@ fn format_subagent_progress(prefix: &str, text: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM
|
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM
|
||||||
/// call fails at any step.
|
/// call fails at any step.
|
||||||
#[allow(clippy::too_many_lines)]
|
pub fn run_subagent(
|
||||||
pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
ctx: &SubagentContext,
|
||||||
|
tx: &mpsc::Sender<SubagentEvent>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
let mut output = String::new();
|
let mut output = String::new();
|
||||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||||
|
|
||||||
@@ -370,17 +448,23 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
// surfaces past a buried WARN log.
|
// surfaces past a buried WARN log.
|
||||||
if let Err(error) = require_api_key(&api_key, &provider) {
|
if let Err(error) = require_api_key(&api_key, &provider) {
|
||||||
let error = error.to_string();
|
let error = error.to_string();
|
||||||
let _ = tx.blocking_send(SubagentEvent::StepFailed { step: 0, error: error.clone() });
|
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||||
|
step: 0,
|
||||||
|
error: error.clone(),
|
||||||
|
});
|
||||||
anyhow::bail!(error);
|
anyhow::bail!(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
|
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
|
||||||
|
|
||||||
for step in 0..ctx.max_steps {
|
for step in 0..ctx.max_steps {
|
||||||
|
|
||||||
// Check abort flag before each LLM call so a stuck subagent can
|
// Check abort flag before each LLM call so a stuck subagent can
|
||||||
// be cancelled from the parent (mirrors main agent behaviour).
|
// be cancelled from the parent (mirrors main agent behaviour).
|
||||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
if ctx
|
||||||
|
.abort_flag
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
|
||||||
|
{
|
||||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||||
step,
|
step,
|
||||||
error: "subagent aborted by parent".to_string(),
|
error: "subagent aborted by parent".to_string(),
|
||||||
@@ -403,7 +487,11 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
Some(4096),
|
Some(4096),
|
||||||
|event| -> bool {
|
|event| -> bool {
|
||||||
// Check abort on every SSE event for responsive cancellation.
|
// Check abort on every SSE event for responsive cancellation.
|
||||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
if ctx
|
||||||
|
.abort_flag
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
|
||||||
|
{
|
||||||
return false; // signals provider to abort
|
return false; // signals provider to abort
|
||||||
}
|
}
|
||||||
match event {
|
match event {
|
||||||
@@ -417,7 +505,11 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
let prog = format_subagent_progress("replying", ¤t_token);
|
let prog = format_subagent_progress("replying", ¤t_token);
|
||||||
let _ = tx_clone.blocking_send(SubagentEvent::Progress(prog));
|
let _ = tx_clone.blocking_send(SubagentEvent::Progress(prog));
|
||||||
}
|
}
|
||||||
crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
|
crate::app::runtime::stream::StreamEvent::Usage {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
// Capture usage so the drain thread can route it
|
// Capture usage so the drain thread can route it
|
||||||
// to the parent's `UsageStats::review_tokens`.
|
// to the parent's `UsageStats::review_tokens`.
|
||||||
// Last writer wins — providers send exactly one
|
// Last writer wins — providers send exactly one
|
||||||
@@ -433,7 +525,10 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
let (response, returned_usage) = match stream_result {
|
let (response, returned_usage) = match stream_result {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let is_abort = ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
|
let is_abort = ctx
|
||||||
|
.abort_flag
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
|
||||||
|| e.to_string().contains("aborted");
|
|| e.to_string().contains("aborted");
|
||||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||||
step,
|
step,
|
||||||
@@ -459,7 +554,8 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
// subagent never tells the parent about the tokens consumed.
|
// subagent never tells the parent about the tokens consumed.
|
||||||
let (mut tok_in, mut tok_out) = returned_usage.unwrap_or((0, 0));
|
let (mut tok_in, mut tok_out) = returned_usage.unwrap_or((0, 0));
|
||||||
if tok_in == 0 {
|
if tok_in == 0 {
|
||||||
let prompt_chars: usize = messages.iter()
|
let prompt_chars: usize = messages
|
||||||
|
.iter()
|
||||||
.filter_map(|m| m.content.as_deref())
|
.filter_map(|m| m.content.as_deref())
|
||||||
.map(str::len)
|
.map(str::len)
|
||||||
.sum();
|
.sum();
|
||||||
@@ -475,7 +571,10 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
});
|
});
|
||||||
|
|
||||||
let has_tool_calls = response.tool_calls.is_some()
|
let has_tool_calls = response.tool_calls.is_some()
|
||||||
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
&& response
|
||||||
|
.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|tc| !tc.is_empty());
|
||||||
|
|
||||||
let content = response.content.clone().unwrap_or_default();
|
let content = response.content.clone().unwrap_or_default();
|
||||||
|
|
||||||
@@ -608,7 +707,8 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
|
|
||||||
for (tool_call, result) in results_vec {
|
for (tool_call, result) in results_vec {
|
||||||
let tool_name = &tool_call.function.name;
|
let tool_name = &tool_call.function.name;
|
||||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
|
let args =
|
||||||
|
crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
|
||||||
|
|
||||||
let _ = tx.blocking_send(SubagentEvent::ToolCall {
|
let _ = tx.blocking_send(SubagentEvent::ToolCall {
|
||||||
tool: tool_name.clone(),
|
tool: tool_name.clone(),
|
||||||
@@ -617,7 +717,10 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(output_text) => {
|
Ok(output_text) => {
|
||||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
|
messages.push(ChatMessage::tool_result(
|
||||||
|
tool_call.id.clone(),
|
||||||
|
output_text.clone(),
|
||||||
|
));
|
||||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
tool: tool_name.clone(),
|
tool: tool_name.clone(),
|
||||||
args: args.clone(),
|
args: args.clone(),
|
||||||
@@ -634,7 +737,8 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
if is_readonly {
|
if is_readonly {
|
||||||
if let Some(ref findings) = ctx.workflow_findings {
|
if let Some(ref findings) = ctx.workflow_findings {
|
||||||
if let Ok(mut f) = findings.lock() {
|
if let Ok(mut f) = findings.lock() {
|
||||||
let args_json = serde_json::to_string(&args).unwrap_or_default();
|
let args_json =
|
||||||
|
serde_json::to_string(&args).unwrap_or_default();
|
||||||
let mut shared_text = output_text;
|
let mut shared_text = output_text;
|
||||||
if shared_text.len() > 50_000 {
|
if shared_text.len() > 50_000 {
|
||||||
shared_text.truncate(50_000);
|
shared_text.truncate(50_000);
|
||||||
|
|||||||
@@ -41,16 +41,9 @@ impl AgentDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builder method: set the maximum step count for this agent.
|
/// Builder method: set the maximum step count for this agent.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||||
self.max_steps = Some(steps);
|
self.max_steps = Some(steps);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builder method: set the temperature for this agent.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn with_temperature(mut self, temp: f32) -> Self {
|
|
||||||
self.temperature = Some(temp);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
//! Core Intelligence can omit or reshape — and always runs after any
|
//! Core Intelligence can omit or reshape — and always runs after any
|
||||||
//! hive-mind convergence completes.
|
//! hive-mind convergence completes.
|
||||||
use crate::app::workflow::hive_mind::NodeReport;
|
use crate::app::workflow::hive_mind::NodeReport;
|
||||||
use zesdex_cms::domain::memory::Memory;
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use zesdex_cms::domain::memory::Memory;
|
||||||
|
|
||||||
/// Write a markdown report of one hive-mind convergence to
|
/// Write a markdown report of one hive-mind convergence to
|
||||||
/// `<workspace_root>/docs/runs/<timestamp>-<slug>.md`.
|
/// `<workspace_root>/docs/runs/<timestamp>-<slug>.md`.
|
||||||
|
|||||||
@@ -210,20 +210,23 @@ fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value)
|
|||||||
/// a stuck stage from blocking the entire pipeline forever.
|
/// a stuck stage from blocking the entire pipeline forever.
|
||||||
///
|
///
|
||||||
/// Return: the agent's text output, or an error on failure.
|
/// Return: the agent's text output, or an error on failure.
|
||||||
fn spawn_single_agent(
|
/// Bundled context for spawning a single subagent.
|
||||||
agent_id: &str,
|
pub(crate) struct SpawnCtx<'a> {
|
||||||
agent_name: &str,
|
pub agent_id: &'a str,
|
||||||
prompt: &str,
|
pub agent_name: &'a str,
|
||||||
role: &str,
|
pub prompt: &'a str,
|
||||||
allowed_tools: Option<Vec<String>>,
|
pub role: &'a str,
|
||||||
findings_snapshot: &[String],
|
pub allowed_tools: Option<Vec<String>>,
|
||||||
findings: &Arc<Mutex<Vec<String>>>,
|
pub findings_snapshot: &'a [String],
|
||||||
abort_flag: &Option<Arc<AtomicBool>>,
|
pub findings: &'a Arc<Mutex<Vec<String>>>,
|
||||||
live: Option<&LiveStateFn>,
|
pub abort_flag: &'a Option<Arc<AtomicBool>>,
|
||||||
session_dir: &std::path::Path,
|
pub live: Option<&'a LiveStateFn>,
|
||||||
workspaces: &[std::path::PathBuf],
|
pub session_dir: &'a std::path::Path,
|
||||||
timeout_ms: Option<u64>,
|
pub workspaces: &'a [std::path::PathBuf],
|
||||||
) -> anyhow::Result<String> {
|
pub timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_single_agent(sp: SpawnCtx<'_>) -> anyhow::Result<String> {
|
||||||
use crate::app::subagent::context::build_subagent_context;
|
use crate::app::subagent::context::build_subagent_context;
|
||||||
use crate::app::subagent::engine::run_subagent;
|
use crate::app::subagent::engine::run_subagent;
|
||||||
use crate::app::subagent::spawn::AgentDefinition;
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
@@ -233,10 +236,10 @@ fn spawn_single_agent(
|
|||||||
// Notify UI: this agent is now running.
|
// Notify UI: this agent is now running.
|
||||||
// Pass both the unique agent_id (UUID for stable key) and agent_name
|
// Pass both the unique agent_id (UUID for stable key) and agent_name
|
||||||
// (human-readable display name, e.g. a hive-mind node designation).
|
// (human-readable display name, e.g. a hive-mind node designation).
|
||||||
if let Some(f) = live {
|
if let Some(f) = &sp.live {
|
||||||
f(
|
f(
|
||||||
agent_id.to_string(),
|
sp.agent_id.to_string(),
|
||||||
agent_name.to_string(),
|
sp.agent_name.to_string(),
|
||||||
AgentStatus {
|
AgentStatus {
|
||||||
state: AgentState::Running,
|
state: AgentState::Running,
|
||||||
started_at: Some(started_at),
|
started_at: Some(started_at),
|
||||||
@@ -247,20 +250,20 @@ fn spawn_single_agent(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut def = AgentDefinition::new(agent_name.to_string(), role.to_string());
|
let mut def = AgentDefinition::new(sp.agent_name.to_string(), sp.role.to_string());
|
||||||
if let Some(tools) = allowed_tools {
|
if let Some(tools) = &sp.allowed_tools {
|
||||||
def = def.with_allowed_tools(tools);
|
def = def.with_allowed_tools(tools.clone());
|
||||||
}
|
}
|
||||||
let mut ctx = build_subagent_context(&def);
|
let mut ctx = build_subagent_context(&def);
|
||||||
ctx.session_dir = session_dir.to_path_buf();
|
ctx.session_dir = sp.session_dir.to_path_buf();
|
||||||
ctx.workspaces = workspaces.to_vec();
|
ctx.workspaces = sp.workspaces.to_vec();
|
||||||
|
|
||||||
let findings_section = if findings_snapshot.is_empty() {
|
let findings_section = if sp.findings_snapshot.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"\n\nFindings from sibling drones in this Hive run:\n{}",
|
"\n\nFindings from sibling drones in this Hive run:\n{}",
|
||||||
findings_snapshot
|
sp.findings_snapshot
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, f)| format!("{}. {}", i + 1, f))
|
.map(|(i, f)| format!("{}. {}", i + 1, f))
|
||||||
@@ -269,20 +272,20 @@ fn spawn_single_agent(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.system_prompt = format!("{prompt}{findings_section}");
|
ctx.system_prompt = format!("{}{}", sp.prompt, findings_section);
|
||||||
// Link the shared findings Arc so note_finding calls within this
|
// Link the shared findings Arc so note_finding calls within this
|
||||||
// subagent write into the same vec visible to sibling agents.
|
// subagent write into the same vec visible to sibling agents.
|
||||||
ctx.workflow_findings = Some(findings.clone());
|
ctx.workflow_findings = Some(sp.findings.clone());
|
||||||
ctx.abort_flag.clone_from(abort_flag);
|
ctx.abort_flag.clone_from(sp.abort_flag);
|
||||||
|
|
||||||
// Create an mpsc channel and drain events in a background thread.
|
// Create an mpsc channel and drain events in a background thread.
|
||||||
// The drain thread also pushes intra-division progress updates to the
|
// The drain thread also pushes intra-division progress updates to the
|
||||||
// live callback (current tool being executed), so the TUI panel shows
|
// live callback (current tool being executed), so the TUI panel shows
|
||||||
// real-time "editing X" or "running build" instead of just "Running…".
|
// real-time "editing X" or "running build" instead of just "Running…".
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||||
let drain_agent_id = agent_id.to_string();
|
let drain_agent_id = sp.agent_id.to_string();
|
||||||
let drain_agent_name = agent_name.to_string();
|
let drain_agent_name = sp.agent_name.to_string();
|
||||||
let drain_live = live.cloned();
|
let drain_live = sp.live.cloned();
|
||||||
let drain_started_at = started_at;
|
let drain_started_at = started_at;
|
||||||
let _drain_thread = std::thread::spawn(move || {
|
let _drain_thread = std::thread::spawn(move || {
|
||||||
use crate::app::subagent::event::SubagentEvent;
|
use crate::app::subagent::event::SubagentEvent;
|
||||||
@@ -380,11 +383,12 @@ fn spawn_single_agent(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check abort before even starting the subagent.
|
// Check abort before even starting the subagent.
|
||||||
if abort_flag
|
if sp
|
||||||
|
.abort_flag
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|f| f.load(Ordering::SeqCst))
|
.is_some_and(|f| f.load(Ordering::SeqCst))
|
||||||
{
|
{
|
||||||
anyhow::bail!("subagent '{agent_name}' aborted before start");
|
anyhow::bail!("subagent '{}' aborted before start", sp.agent_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run subagent on a separate thread so the abort flag can be polled.
|
// Run subagent on a separate thread so the abort flag can be polled.
|
||||||
@@ -393,14 +397,14 @@ fn spawn_single_agent(
|
|||||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
|
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
|
||||||
let bg_ctx = ctx;
|
let bg_ctx = ctx;
|
||||||
let bg_tx = tx;
|
let bg_tx = tx;
|
||||||
let bg_name = agent_name.to_string();
|
let bg_name = sp.agent_name.to_string();
|
||||||
let bg_abort = abort_flag.clone();
|
let bg_abort = sp.abort_flag.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _ = done_tx.send(run_subagent(&bg_ctx, &bg_tx));
|
let _ = done_tx.send(run_subagent(&bg_ctx, &bg_tx));
|
||||||
});
|
});
|
||||||
|
|
||||||
let poll_interval = Duration::from_millis(200);
|
let poll_interval = Duration::from_millis(200);
|
||||||
let result = if let Some(timeout) = timeout_ms {
|
let result = if let Some(timeout) = sp.timeout_ms {
|
||||||
let deadline = Duration::from_millis(timeout);
|
let deadline = Duration::from_millis(timeout);
|
||||||
let mut elapsed = Duration::ZERO;
|
let mut elapsed = Duration::ZERO;
|
||||||
loop {
|
loop {
|
||||||
@@ -431,7 +435,7 @@ fn spawn_single_agent(
|
|||||||
let completed_at = chrono::Utc::now().timestamp_millis();
|
let completed_at = chrono::Utc::now().timestamp_millis();
|
||||||
|
|
||||||
// Notify UI: agent completed or failed
|
// Notify UI: agent completed or failed
|
||||||
if let Some(f) = live {
|
if let Some(f) = &sp.live {
|
||||||
let summary_from = |text: &str| {
|
let summary_from = |text: &str| {
|
||||||
text.lines()
|
text.lines()
|
||||||
.next()
|
.next()
|
||||||
@@ -444,8 +448,8 @@ fn spawn_single_agent(
|
|||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
let summary = summary_from(text);
|
let summary = summary_from(text);
|
||||||
f(
|
f(
|
||||||
agent_id.to_string(),
|
sp.agent_id.to_string(),
|
||||||
agent_name.to_string(),
|
sp.agent_name.to_string(),
|
||||||
AgentStatus {
|
AgentStatus {
|
||||||
state: AgentState::Completed,
|
state: AgentState::Completed,
|
||||||
started_at: Some(started_at),
|
started_at: Some(started_at),
|
||||||
@@ -457,8 +461,8 @@ fn spawn_single_agent(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
f(
|
f(
|
||||||
agent_id.to_string(),
|
sp.agent_id.to_string(),
|
||||||
agent_name.to_string(),
|
sp.agent_name.to_string(),
|
||||||
AgentStatus {
|
AgentStatus {
|
||||||
state: AgentState::Failed,
|
state: AgentState::Failed,
|
||||||
started_at: Some(started_at),
|
started_at: Some(started_at),
|
||||||
@@ -476,6 +480,20 @@ fn spawn_single_agent(
|
|||||||
|
|
||||||
type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
||||||
|
|
||||||
|
/// Bundled context for executing a script primitive.
|
||||||
|
pub(crate) struct PrimitiveCtx<'a> {
|
||||||
|
pub primitive: &'a ScriptPrimitive,
|
||||||
|
pub args: &'a HashMap<String, String>,
|
||||||
|
pub concurrency_cap: usize,
|
||||||
|
pub continue_on_error: bool,
|
||||||
|
pub abort_flag: &'a Option<Arc<AtomicBool>>,
|
||||||
|
pub live: Option<&'a LiveStateFn>,
|
||||||
|
pub session_dir: &'a std::path::Path,
|
||||||
|
pub workspaces: &'a [std::path::PathBuf],
|
||||||
|
pub findings: &'a Arc<Mutex<Vec<String>>>,
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Recursively execute a `ScriptPrimitive` tree, respecting an overall
|
/// Recursively execute a `ScriptPrimitive` tree, respecting an overall
|
||||||
/// concurrency cap for parallel branches.
|
/// concurrency cap for parallel branches.
|
||||||
///
|
///
|
||||||
@@ -497,22 +515,11 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
|||||||
///
|
///
|
||||||
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||||
/// the order they were submitted.
|
/// the order they were submitted.
|
||||||
pub fn execute_primitive(
|
pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result<Vec<String>> {
|
||||||
primitive: &ScriptPrimitive,
|
match pc.primitive {
|
||||||
args: &HashMap<String, String>,
|
|
||||||
concurrency_cap: usize,
|
|
||||||
continue_on_error: bool,
|
|
||||||
abort_flag: &Option<Arc<AtomicBool>>,
|
|
||||||
live: Option<&LiveStateFn>,
|
|
||||||
session_dir: &std::path::Path,
|
|
||||||
workspaces: &[std::path::PathBuf],
|
|
||||||
findings: &Arc<Mutex<Vec<String>>>,
|
|
||||||
timeout_ms: Option<u64>,
|
|
||||||
) -> anyhow::Result<Vec<String>> {
|
|
||||||
match primitive {
|
|
||||||
ScriptPrimitive::Agent(prompt) => {
|
ScriptPrimitive::Agent(prompt) => {
|
||||||
let mut resolved_args = args.clone();
|
let mut resolved_args = pc.args.clone();
|
||||||
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default();
|
||||||
if !resolved_args.contains_key("findings") {
|
if !resolved_args.contains_key("findings") {
|
||||||
let formatted_findings = if findings_snapshot.is_empty() {
|
let formatted_findings = if findings_snapshot.is_empty() {
|
||||||
"None".to_string()
|
"None".to_string()
|
||||||
@@ -529,23 +536,23 @@ pub fn execute_primitive(
|
|||||||
let resolved = resolve_template(prompt, &resolved_args);
|
let resolved = resolve_template(prompt, &resolved_args);
|
||||||
let agent_id = uuid::Uuid::new_v4().to_string();
|
let agent_id = uuid::Uuid::new_v4().to_string();
|
||||||
let agent_name = resolved.chars().take(40).collect::<String>();
|
let agent_name = resolved.chars().take(40).collect::<String>();
|
||||||
match spawn_single_agent(
|
match spawn_single_agent(SpawnCtx {
|
||||||
&agent_id,
|
agent_id: &agent_id,
|
||||||
&agent_name,
|
agent_name: &agent_name,
|
||||||
&resolved,
|
prompt: &resolved,
|
||||||
"coder",
|
role: "coder",
|
||||||
None,
|
allowed_tools: None,
|
||||||
&findings_snapshot,
|
findings_snapshot: &findings_snapshot,
|
||||||
findings,
|
findings: pc.findings,
|
||||||
abort_flag,
|
abort_flag: pc.abort_flag,
|
||||||
live,
|
live: pc.live,
|
||||||
session_dir,
|
session_dir: pc.session_dir,
|
||||||
workspaces,
|
workspaces: pc.workspaces,
|
||||||
timeout_ms,
|
timeout_ms: pc.timeout_ms,
|
||||||
) {
|
}) {
|
||||||
Ok(text) => Ok(vec![text]),
|
Ok(text) => Ok(vec![text]),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if continue_on_error {
|
if pc.continue_on_error {
|
||||||
Ok(vec![format!("agent error: {}", e)])
|
Ok(vec![format!("agent error: {}", e)])
|
||||||
} else {
|
} else {
|
||||||
Err(e)
|
Err(e)
|
||||||
@@ -559,8 +566,8 @@ pub fn execute_primitive(
|
|||||||
node_id,
|
node_id,
|
||||||
tool_scope,
|
tool_scope,
|
||||||
} => {
|
} => {
|
||||||
let mut resolved_args = args.clone();
|
let mut resolved_args = pc.args.clone();
|
||||||
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default();
|
||||||
if !resolved_args.contains_key("findings") {
|
if !resolved_args.contains_key("findings") {
|
||||||
let formatted_findings = if findings_snapshot.is_empty() {
|
let formatted_findings = if findings_snapshot.is_empty() {
|
||||||
"None".to_string()
|
"None".to_string()
|
||||||
@@ -580,20 +587,20 @@ pub fn execute_primitive(
|
|||||||
tracing::debug!("[hive] deploying drone {node_id}: {truncated}");
|
tracing::debug!("[hive] deploying drone {node_id}: {truncated}");
|
||||||
let agent_name = format!("{node_id}: {truncated}");
|
let agent_name = format!("{node_id}: {truncated}");
|
||||||
let allowed_tools = crate::app::subagent::division::tool_scope::tools_for(tool_scope);
|
let allowed_tools = crate::app::subagent::division::tool_scope::tools_for(tool_scope);
|
||||||
match spawn_single_agent(
|
match spawn_single_agent(SpawnCtx {
|
||||||
&agent_id,
|
agent_id: &agent_id,
|
||||||
&agent_name,
|
agent_name: &agent_name,
|
||||||
&resolved,
|
prompt: &resolved,
|
||||||
node_id,
|
role: node_id,
|
||||||
Some(allowed_tools),
|
allowed_tools: Some(allowed_tools),
|
||||||
&findings_snapshot,
|
findings_snapshot: &findings_snapshot,
|
||||||
findings,
|
findings: pc.findings,
|
||||||
abort_flag,
|
abort_flag: pc.abort_flag,
|
||||||
live,
|
live: pc.live,
|
||||||
session_dir,
|
session_dir: pc.session_dir,
|
||||||
workspaces,
|
workspaces: pc.workspaces,
|
||||||
timeout_ms,
|
timeout_ms: pc.timeout_ms,
|
||||||
) {
|
}) {
|
||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"[hive] drone {node_id} completed — merging into collective state"
|
"[hive] drone {node_id} completed — merging into collective state"
|
||||||
@@ -604,14 +611,14 @@ pub fn execute_primitive(
|
|||||||
// still running (via read_findings) or any drone spawned
|
// still running (via read_findings) or any drone spawned
|
||||||
// afterward sees this immediately, making the collective
|
// afterward sees this immediately, making the collective
|
||||||
// state genuinely continuous rather than batch-synced.
|
// state genuinely continuous rather than batch-synced.
|
||||||
if let Ok(mut f) = findings.lock() {
|
if let Ok(mut f) = pc.findings.lock() {
|
||||||
f.push(format!("[{node_id}]: {text}"));
|
f.push(format!("[{node_id}]: {text}"));
|
||||||
}
|
}
|
||||||
Ok(vec![text])
|
Ok(vec![text])
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("[hive] drone {node_id} failed: {e}");
|
tracing::warn!("[hive] drone {node_id} failed: {e}");
|
||||||
if continue_on_error {
|
if pc.continue_on_error {
|
||||||
Ok(vec![format!("drone error: {}", e)])
|
Ok(vec![format!("drone error: {}", e)])
|
||||||
} else {
|
} else {
|
||||||
Err(e)
|
Err(e)
|
||||||
@@ -626,7 +633,7 @@ pub fn execute_primitive(
|
|||||||
// independent subagents work simultaneously.
|
// independent subagents work simultaneously.
|
||||||
// Each branch shares the same `findings` Arc so note_finding
|
// Each branch shares the same `findings` Arc so note_finding
|
||||||
// calls within any branch are visible to all other branches.
|
// calls within any branch are visible to all other branches.
|
||||||
let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1)));
|
let semaphore = Arc::new(Semaphore::new(pc.concurrency_cap.max(1)));
|
||||||
let results: Arc<Mutex<Vec<ParallelResult>>> = Arc::new(Mutex::new(Vec::new()));
|
let results: Arc<Mutex<Vec<ParallelResult>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
let handles: Vec<_> = scripts
|
let handles: Vec<_> = scripts
|
||||||
@@ -634,31 +641,32 @@ pub fn execute_primitive(
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, script)| {
|
.map(|(idx, script)| {
|
||||||
let script = script.clone();
|
let script = script.clone();
|
||||||
let args = args.clone();
|
let args = pc.args.clone();
|
||||||
let sem = Arc::clone(&semaphore);
|
let sem = Arc::clone(&semaphore);
|
||||||
let results = Arc::clone(&results);
|
let results = Arc::clone(&results);
|
||||||
let cap = concurrency_cap;
|
let cap = pc.concurrency_cap;
|
||||||
let abort = abort_flag.clone();
|
let continue_on_error = pc.continue_on_error;
|
||||||
let live_clone = live.cloned();
|
let abort = pc.abort_flag.clone();
|
||||||
let session_dir = session_dir.to_path_buf();
|
let live_clone = pc.live.cloned();
|
||||||
let workspaces = workspaces.to_vec();
|
let session_dir = pc.session_dir.to_path_buf();
|
||||||
let findings = Arc::clone(findings);
|
let workspaces = pc.workspaces.to_vec();
|
||||||
let to = timeout_ms;
|
let findings = Arc::clone(pc.findings);
|
||||||
|
let to = pc.timeout_ms;
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _permit = sem.acquire();
|
let _permit = sem.acquire();
|
||||||
let result = execute_primitive(
|
let result = execute_primitive(PrimitiveCtx {
|
||||||
&script,
|
primitive: &script,
|
||||||
&args,
|
args: &args,
|
||||||
cap,
|
concurrency_cap: cap,
|
||||||
continue_on_error,
|
continue_on_error,
|
||||||
&abort,
|
abort_flag: &abort,
|
||||||
live_clone.as_ref(),
|
live: live_clone.as_ref(),
|
||||||
&session_dir,
|
session_dir: &session_dir,
|
||||||
&workspaces,
|
workspaces: &workspaces,
|
||||||
&findings,
|
findings: &findings,
|
||||||
to,
|
timeout_ms: to,
|
||||||
);
|
});
|
||||||
if let Ok(mut locked) = results.lock() {
|
if let Ok(mut locked) = results.lock() {
|
||||||
locked.push((idx, result));
|
locked.push((idx, result));
|
||||||
}
|
}
|
||||||
@@ -699,31 +707,32 @@ pub fn execute_primitive(
|
|||||||
for (idx, script) in scripts.iter().enumerate() {
|
for (idx, script) in scripts.iter().enumerate() {
|
||||||
// Check abort before each pipeline stage so we don't
|
// Check abort before each pipeline stage so we don't
|
||||||
// launch the next division after the user cancelled.
|
// launch the next division after the user cancelled.
|
||||||
if abort_flag
|
if pc
|
||||||
|
.abort_flag
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|f| f.load(Ordering::SeqCst))
|
.is_some_and(|f| f.load(Ordering::SeqCst))
|
||||||
{
|
{
|
||||||
if continue_on_error {
|
if pc.continue_on_error {
|
||||||
all.push(format!("pipeline aborted at stage {idx}"));
|
all.push(format!("pipeline aborted at stage {idx}"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
anyhow::bail!("pipeline aborted by user at stage {idx}");
|
anyhow::bail!("pipeline aborted by user at stage {idx}");
|
||||||
}
|
}
|
||||||
match execute_primitive(
|
match execute_primitive(PrimitiveCtx {
|
||||||
script,
|
primitive: script,
|
||||||
args,
|
args: pc.args,
|
||||||
concurrency_cap,
|
concurrency_cap: pc.concurrency_cap,
|
||||||
continue_on_error,
|
continue_on_error: pc.continue_on_error,
|
||||||
abort_flag,
|
abort_flag: pc.abort_flag,
|
||||||
live,
|
live: pc.live,
|
||||||
session_dir,
|
session_dir: pc.session_dir,
|
||||||
workspaces,
|
workspaces: pc.workspaces,
|
||||||
findings,
|
findings: pc.findings,
|
||||||
timeout_ms,
|
timeout_ms: pc.timeout_ms,
|
||||||
) {
|
}) {
|
||||||
Ok(outputs) => all.extend(outputs),
|
Ok(outputs) => all.extend(outputs),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if continue_on_error {
|
if pc.continue_on_error {
|
||||||
all.push(format!("pipeline stage {idx} error: {e}"));
|
all.push(format!("pipeline stage {idx} error: {e}"));
|
||||||
} else {
|
} else {
|
||||||
return Err(e);
|
return Err(e);
|
||||||
@@ -737,18 +746,18 @@ pub fn execute_primitive(
|
|||||||
ScriptPrimitive::Phase {
|
ScriptPrimitive::Phase {
|
||||||
name: _name,
|
name: _name,
|
||||||
script,
|
script,
|
||||||
} => execute_primitive(
|
} => execute_primitive(PrimitiveCtx {
|
||||||
script,
|
primitive: script,
|
||||||
args,
|
args: pc.args,
|
||||||
concurrency_cap,
|
concurrency_cap: pc.concurrency_cap,
|
||||||
continue_on_error,
|
continue_on_error: pc.continue_on_error,
|
||||||
abort_flag,
|
abort_flag: pc.abort_flag,
|
||||||
live,
|
live: pc.live,
|
||||||
session_dir,
|
session_dir: pc.session_dir,
|
||||||
workspaces,
|
workspaces: pc.workspaces,
|
||||||
findings,
|
findings: pc.findings,
|
||||||
timeout_ms,
|
timeout_ms: pc.timeout_ms,
|
||||||
),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,18 +801,18 @@ pub fn run_workflow_tracked(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let findings = Arc::new(Mutex::new(Vec::new()));
|
let findings = Arc::new(Mutex::new(Vec::new()));
|
||||||
let results = execute_primitive(
|
let results = execute_primitive(PrimitiveCtx {
|
||||||
&script.script,
|
primitive: &script.script,
|
||||||
args,
|
args,
|
||||||
concurrency_cap,
|
concurrency_cap,
|
||||||
script.options.continue_on_error,
|
continue_on_error: script.options.continue_on_error,
|
||||||
abort_flag,
|
abort_flag,
|
||||||
live,
|
live,
|
||||||
session_dir,
|
session_dir,
|
||||||
workspaces,
|
workspaces,
|
||||||
&findings,
|
findings: &findings,
|
||||||
script.options.timeout_ms,
|
timeout_ms: script.options.timeout_ms,
|
||||||
)?;
|
})?;
|
||||||
|
|
||||||
let summary = if results.is_empty() {
|
let summary = if results.is_empty() {
|
||||||
"workflow completed with no output".to_string()
|
"workflow completed with no output".to_string()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
//! Synthesis node reads the complete collective state and converges it
|
//! Synthesis node reads the complete collective state and converges it
|
||||||
//! into one unified voice — returned to LO and persisted to docs/runs/*.md.
|
//! into one unified voice — returned to LO and persisted to docs/runs/*.md.
|
||||||
//! ```
|
//! ```
|
||||||
use crate::app::workflow::engine::{execute_primitive, AgentStatus, LiveStateFn};
|
use crate::app::workflow::engine::{execute_primitive, AgentStatus, LiveStateFn, PrimitiveCtx};
|
||||||
use crate::app::workflow::script::ScriptPrimitive;
|
use crate::app::workflow::script::ScriptPrimitive;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -211,18 +211,18 @@ fn execute_cycle(
|
|||||||
|
|
||||||
let args: HashMap<String, String> = HashMap::new();
|
let args: HashMap<String, String> = HashMap::new();
|
||||||
let abort_owned = ctx.abort_flag.cloned();
|
let abort_owned = ctx.abort_flag.cloned();
|
||||||
let results = execute_primitive(
|
let results = execute_primitive(PrimitiveCtx {
|
||||||
&cycle_primitive,
|
primitive: &cycle_primitive,
|
||||||
&args,
|
args: &args,
|
||||||
directives.len().clamp(1, ctx.max_cycle_concurrency),
|
concurrency_cap: directives.len().clamp(1, ctx.max_cycle_concurrency),
|
||||||
true,
|
continue_on_error: true,
|
||||||
&abort_owned,
|
abort_flag: &abort_owned,
|
||||||
ctx.live,
|
live: ctx.live,
|
||||||
ctx.session_dir,
|
session_dir: ctx.session_dir,
|
||||||
ctx.workspaces,
|
workspaces: ctx.workspaces,
|
||||||
ctx.collective_state,
|
findings: ctx.collective_state,
|
||||||
ctx.node_timeout_ms,
|
timeout_ms: ctx.node_timeout_ms,
|
||||||
)?;
|
})?;
|
||||||
|
|
||||||
let mut reports = Vec::new();
|
let mut reports = Vec::new();
|
||||||
for (node_id, output) in node_ids.iter().zip(results.iter()) {
|
for (node_id, output) in node_ids.iter().zip(results.iter()) {
|
||||||
@@ -280,9 +280,10 @@ pub fn run_hive_mind(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
|
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
|
||||||
let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
let settings =
|
||||||
.load(&store_base_dir)
|
zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
||||||
.unwrap_or_default();
|
.load(&store_base_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
let node_timeout_ms = Some(settings.hive_mind_node_timeout_ms);
|
let node_timeout_ms = Some(settings.hive_mind_node_timeout_ms);
|
||||||
let max_cycle_concurrency = settings.workflow_max_concurrency.max(1);
|
let max_cycle_concurrency = settings.workflow_max_concurrency.max(1);
|
||||||
|
|
||||||
@@ -404,18 +405,18 @@ fn synthesize_consensus(
|
|||||||
|
|
||||||
let args: HashMap<String, String> = HashMap::new();
|
let args: HashMap<String, String> = HashMap::new();
|
||||||
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
|
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
|
||||||
let results = execute_primitive(
|
let results = execute_primitive(PrimitiveCtx {
|
||||||
&synthesis,
|
primitive: &synthesis,
|
||||||
&args,
|
args: &args,
|
||||||
1,
|
concurrency_cap: 1,
|
||||||
false,
|
continue_on_error: false,
|
||||||
&abort_owned,
|
abort_flag: &abort_owned,
|
||||||
live,
|
live,
|
||||||
session_dir,
|
session_dir,
|
||||||
workspaces,
|
workspaces,
|
||||||
collective_state,
|
findings: collective_state,
|
||||||
node_timeout_ms,
|
timeout_ms: node_timeout_ms,
|
||||||
)?;
|
})?;
|
||||||
Ok(results.into_iter().next().unwrap_or_default())
|
Ok(results.into_iter().next().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ pub mod chat {
|
|||||||
|
|
||||||
pub mod provider {
|
pub mod provider {
|
||||||
pub mod request {
|
pub mod request {
|
||||||
pub use zesdex_dto::provider::request::*;
|
|
||||||
pub use zesdex_dto::provider::request::ChatCompletionRequest as ChatRequest;
|
pub use zesdex_dto::provider::request::ChatCompletionRequest as ChatRequest;
|
||||||
|
pub use zesdex_dto::provider::request::*;
|
||||||
}
|
}
|
||||||
pub mod response {
|
pub mod response {
|
||||||
pub use zesdex_dto::provider::response::ChatCompletionResponse as ChatResponse;
|
pub use zesdex_dto::provider::response::ChatCompletionResponse as ChatResponse;
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_sign_loss,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::cast_possible_wrap
|
||||||
|
)]
|
||||||
//! Zesdex binary entry point.
|
//! Zesdex binary entry point.
|
||||||
//!
|
//!
|
||||||
//! Parses `--daemon` / `--attach <id>` flags to select one of three
|
//! Parses `--daemon` / `--attach <id>` flags to select one of three
|
||||||
@@ -6,25 +11,27 @@
|
|||||||
//! attach-only TUI client), sets up file logging, and runs the
|
//! attach-only TUI client), sets up file logging, and runs the
|
||||||
//! corresponding event loop.
|
//! corresponding event loop.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::execute;
|
||||||
|
use crossterm::terminal::{
|
||||||
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
|
};
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::Terminal;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use anyhow::Result;
|
|
||||||
use crossterm::execute;
|
|
||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
|
||||||
use ratatui::backend::CrosstermBackend;
|
|
||||||
use ratatui::Terminal;
|
|
||||||
use zesdex_iam::domain::repository::{SessionLockRepository, SessionRepository};
|
|
||||||
use zesdex_cms::domain::repository::SettingsRepository;
|
use zesdex_cms::domain::repository::SettingsRepository;
|
||||||
|
use zesdex_iam::domain::repository::{SessionLockRepository, SessionRepository};
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod controller;
|
mod controller;
|
||||||
mod dto;
|
mod dto;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod resources;
|
||||||
mod service;
|
mod service;
|
||||||
mod tool;
|
mod tool;
|
||||||
mod resources;
|
|
||||||
mod view;
|
mod view;
|
||||||
|
|
||||||
/// RAII guard that releases a session lock on drop, restoring the
|
/// RAII guard that releases a session lock on drop, restoring the
|
||||||
@@ -55,7 +62,8 @@ impl<L: zesdex_iam::domain::repository::SessionLockRepository> Drop for SessionL
|
|||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let is_daemon = args.iter().any(|a| a == "--daemon");
|
let is_daemon = args.iter().any(|a| a == "--daemon");
|
||||||
let attach_session = args.iter()
|
let attach_session = args
|
||||||
|
.iter()
|
||||||
.position(|a| a == "--attach")
|
.position(|a| a == "--attach")
|
||||||
.and_then(|i| args.get(i + 1).cloned());
|
.and_then(|i| args.get(i + 1).cloned());
|
||||||
|
|
||||||
@@ -65,11 +73,14 @@ fn main() -> Result<()> {
|
|||||||
let _ = std::fs::create_dir_all(&log_dir);
|
let _ = std::fs::create_dir_all(&log_dir);
|
||||||
let log_path = log_dir.join("zesdex.log");
|
let log_path = log_dir.join("zesdex.log");
|
||||||
let log_file = std::fs::OpenOptions::new()
|
let log_file = std::fs::OpenOptions::new()
|
||||||
.create(true).append(true).open(&log_path)
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_path)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
// Fallback: /dev/null so the TUI isn't corrupted by stderr writes
|
// Fallback: /dev/null so the TUI isn't corrupted by stderr writes
|
||||||
std::fs::OpenOptions::new()
|
std::fs::OpenOptions::new()
|
||||||
.write(true).open("/dev/null")
|
.write(true)
|
||||||
|
.open("/dev/null")
|
||||||
.expect("cannot open /dev/null")
|
.expect("cannot open /dev/null")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +130,10 @@ fn run_single_process() -> Result<()> {
|
|||||||
if !lock_repo.try_lock(&session_dir)? {
|
if !lock_repo.try_lock(&session_dir)? {
|
||||||
anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)");
|
anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)");
|
||||||
}
|
}
|
||||||
let _session_lock_guard = SessionLockGuard { lock_repo: &lock_repo, session_dir: session_dir.clone() };
|
let _session_lock_guard = SessionLockGuard {
|
||||||
|
lock_repo: &lock_repo,
|
||||||
|
session_dir: session_dir.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
let workspace_roots = vec![std::env::current_dir()?];
|
let workspace_roots = vec![std::env::current_dir()?];
|
||||||
let mut state = app::state::rest::AppStateRest::new(
|
let mut state = app::state::rest::AppStateRest::new(
|
||||||
@@ -128,10 +142,11 @@ fn run_single_process() -> Result<()> {
|
|||||||
store.memory_dir,
|
store.memory_dir,
|
||||||
);
|
);
|
||||||
state.spawn_mention_index_build();
|
state.spawn_mention_index_build();
|
||||||
let session_repo = zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
|
let session_repo =
|
||||||
state.sessions = session_repo.list_sessions(&store.base_dir).unwrap_or_default();
|
zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
|
||||||
|
state.sessions = session_repo
|
||||||
|
.list_sessions(&store.base_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let _rt = tokio::runtime::Runtime::new()?;
|
let _rt = tokio::runtime::Runtime::new()?;
|
||||||
|
|
||||||
@@ -223,25 +238,34 @@ fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::Ke
|
|||||||
///
|
///
|
||||||
/// Why: the client never shares memory with the daemon, so every action
|
/// 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.
|
/// 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<()> {
|
fn send_daemon_update(
|
||||||
use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload};
|
conn: &mut ipc::conn::Connection,
|
||||||
|
state: &app::state::rest::AppStateRest,
|
||||||
|
) -> Result<()> {
|
||||||
|
use ipc::protocol::{DaemonFrame, MessageEntry, StatePayload, ToastEntry};
|
||||||
|
|
||||||
let messages: Vec<MessageEntry> = state.transcript_cache.messages.iter().map(|m| {
|
let messages: Vec<MessageEntry> = state
|
||||||
MessageEntry {
|
.transcript_cache
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| MessageEntry {
|
||||||
role: format!("{:?}", m.role),
|
role: format!("{:?}", m.role),
|
||||||
content: m.content.clone(),
|
content: m.content.clone(),
|
||||||
timestamp: m.timestamp,
|
timestamp: m.timestamp,
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
let toasts: Vec<ToastEntry> = state.misc.toasts.iter().map(|t| {
|
let toasts: Vec<ToastEntry> = state
|
||||||
ToastEntry {
|
.misc
|
||||||
|
.toasts
|
||||||
|
.iter()
|
||||||
|
.map(|t| ToastEntry {
|
||||||
kind: format!("{:?}", t.kind),
|
kind: format!("{:?}", t.kind),
|
||||||
message: t.message.clone(),
|
message: t.message.clone(),
|
||||||
created_at: t.created_at,
|
created_at: t.created_at,
|
||||||
lifetime_ms: t.lifetime_ms,
|
lifetime_ms: t.lifetime_ms,
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
let overlay = if state.misc.overlay.is_active() {
|
let overlay = if state.misc.overlay.is_active() {
|
||||||
Some(format!("{:?}", state.misc.overlay))
|
Some(format!("{:?}", state.misc.overlay))
|
||||||
@@ -283,8 +307,10 @@ fn apply_client_update(
|
|||||||
state.session_id = payload.session_id;
|
state.session_id = payload.session_id;
|
||||||
state.dirty = payload.dirty;
|
state.dirty = payload.dirty;
|
||||||
|
|
||||||
state.transcript_cache.messages = payload.messages.into_iter().map(|m| {
|
state.transcript_cache.messages = payload
|
||||||
app::state::rest::ChatMessageDisplay {
|
.messages
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| app::state::rest::ChatMessageDisplay {
|
||||||
role: match m.role.as_str() {
|
role: match m.role.as_str() {
|
||||||
"Assistant" => crate::dto::chat::message::Role::Assistant,
|
"Assistant" => crate::dto::chat::message::Role::Assistant,
|
||||||
"System" => crate::dto::chat::message::Role::System,
|
"System" => crate::dto::chat::message::Role::System,
|
||||||
@@ -293,8 +319,8 @@ fn apply_client_update(
|
|||||||
},
|
},
|
||||||
content: m.content,
|
content: m.content,
|
||||||
timestamp: m.timestamp,
|
timestamp: m.timestamp,
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
state.transcript_cache.dirty = true;
|
state.transcript_cache.dirty = true;
|
||||||
|
|
||||||
state.misc.overlay = match payload.overlay.as_deref() {
|
state.misc.overlay = match payload.overlay.as_deref() {
|
||||||
@@ -304,7 +330,6 @@ fn apply_client_update(
|
|||||||
Some("Bash") => Overlay::Bash,
|
Some("Bash") => Overlay::Bash,
|
||||||
Some("QuitConfirm") => Overlay::QuitConfirm,
|
Some("QuitConfirm") => Overlay::QuitConfirm,
|
||||||
|
|
||||||
|
|
||||||
Some("KeyInput") => Overlay::KeyInput,
|
Some("KeyInput") => Overlay::KeyInput,
|
||||||
Some("Editor") => Overlay::Editor,
|
Some("Editor") => Overlay::Editor,
|
||||||
Some("Effort") => Overlay::Effort,
|
Some("Effort") => Overlay::Effort,
|
||||||
@@ -320,8 +345,10 @@ fn apply_client_update(
|
|||||||
_ => Overlay::None,
|
_ => Overlay::None,
|
||||||
};
|
};
|
||||||
|
|
||||||
state.misc.toasts = payload.toasts.into_iter().map(|t| {
|
state.misc.toasts = payload
|
||||||
Toast {
|
.toasts
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| Toast {
|
||||||
kind: match t.kind.as_str() {
|
kind: match t.kind.as_str() {
|
||||||
"Success" => ToastKind::Success,
|
"Success" => ToastKind::Success,
|
||||||
"Warning" => ToastKind::Warning,
|
"Warning" => ToastKind::Warning,
|
||||||
@@ -332,8 +359,8 @@ fn apply_client_update(
|
|||||||
message: t.message,
|
message: t.message,
|
||||||
created_at: t.created_at,
|
created_at: t.created_at,
|
||||||
lifetime_ms: t.lifetime_ms,
|
lifetime_ms: t.lifetime_ms,
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
state.input.buffer = payload.input_buffer;
|
state.input.buffer = payload.input_buffer;
|
||||||
state.input.cursor = payload.input_cursor;
|
state.input.cursor = payload.input_cursor;
|
||||||
@@ -356,7 +383,7 @@ fn handle_daemon_client(
|
|||||||
mut conn: ipc::conn::Connection,
|
mut conn: ipc::conn::Connection,
|
||||||
state: &mut app::state::rest::AppStateRest,
|
state: &mut app::state::rest::AppStateRest,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use app::runtime::actions::{Action, apply_action};
|
use app::runtime::actions::{apply_action, Action};
|
||||||
use ipc::protocol::ClientRequest;
|
use ipc::protocol::ClientRequest;
|
||||||
|
|
||||||
let mut running = true;
|
let mut running = true;
|
||||||
@@ -367,15 +394,24 @@ fn handle_daemon_client(
|
|||||||
ClientRequest::Tick => {
|
ClientRequest::Tick => {
|
||||||
apply_action(state, Action::Tick);
|
apply_action(state, Action::Tick);
|
||||||
}
|
}
|
||||||
ClientRequest::KeyPress { key, ctrl, alt, shift } => {
|
ClientRequest::KeyPress {
|
||||||
|
key,
|
||||||
|
ctrl,
|
||||||
|
alt,
|
||||||
|
shift,
|
||||||
|
} => {
|
||||||
let mut modifiers = crossterm::event::KeyModifiers::NONE;
|
let mut modifiers = crossterm::event::KeyModifiers::NONE;
|
||||||
if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; }
|
if ctrl {
|
||||||
if alt { modifiers |= crossterm::event::KeyModifiers::ALT; }
|
modifiers |= crossterm::event::KeyModifiers::CONTROL;
|
||||||
if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; }
|
}
|
||||||
let key_event = crossterm::event::KeyEvent::new(
|
if alt {
|
||||||
key_action_to_code(&key),
|
modifiers |= crossterm::event::KeyModifiers::ALT;
|
||||||
modifiers,
|
}
|
||||||
);
|
if shift {
|
||||||
|
modifiers |= crossterm::event::KeyModifiers::SHIFT;
|
||||||
|
}
|
||||||
|
let key_event =
|
||||||
|
crossterm::event::KeyEvent::new(key_action_to_code(&key), modifiers);
|
||||||
let actions = controller::input::handle_key(key_event, state);
|
let actions = controller::input::handle_key(key_event, state);
|
||||||
for action in actions {
|
for action in actions {
|
||||||
apply_action(state, action);
|
apply_action(state, action);
|
||||||
@@ -455,7 +491,10 @@ fn run_daemon() -> Result<()> {
|
|||||||
if !lock_repo.try_lock(&session_dir)? {
|
if !lock_repo.try_lock(&session_dir)? {
|
||||||
anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)");
|
anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)");
|
||||||
}
|
}
|
||||||
let _session_lock_guard = SessionLockGuard { lock_repo: &lock_repo, session_dir: session_dir.clone() };
|
let _session_lock_guard = SessionLockGuard {
|
||||||
|
lock_repo: &lock_repo,
|
||||||
|
session_dir: session_dir.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
let workspace_roots = vec![std::env::current_dir()?];
|
let workspace_roots = vec![std::env::current_dir()?];
|
||||||
let mut state = app::state::rest::AppStateRest::new(
|
let mut state = app::state::rest::AppStateRest::new(
|
||||||
@@ -464,8 +503,11 @@ fn run_daemon() -> Result<()> {
|
|||||||
store.memory_dir,
|
store.memory_dir,
|
||||||
);
|
);
|
||||||
state.spawn_mention_index_build();
|
state.spawn_mention_index_build();
|
||||||
let session_repo = zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
|
let session_repo =
|
||||||
state.sessions = session_repo.list_sessions(&store.base_dir).unwrap_or_default();
|
zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
|
||||||
|
state.sessions = session_repo
|
||||||
|
.list_sessions(&store.base_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let _rt = tokio::runtime::Runtime::new()?;
|
let _rt = tokio::runtime::Runtime::new()?;
|
||||||
|
|
||||||
@@ -492,8 +534,9 @@ fn run_daemon() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("daemon: client disconnected, waiting for next connection...");
|
eprintln!("daemon: client disconnected, waiting for next connection...");
|
||||||
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
let _ =
|
||||||
.save(&state.store_base_dir(), &state.settings);
|
zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
||||||
|
.save(&state.store_base_dir(), &state.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&socket_path);
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
@@ -514,7 +557,10 @@ fn setup_attach_client(
|
|||||||
app::state::rest::AppStateRest,
|
app::state::rest::AppStateRest,
|
||||||
)> {
|
)> {
|
||||||
let store = model::store::Store::new();
|
let store = model::store::Store::new();
|
||||||
let socket_path = store.base_dir.join("run").join(format!("{session_id}.sock"));
|
let socket_path = store
|
||||||
|
.base_dir
|
||||||
|
.join("run")
|
||||||
|
.join(format!("{session_id}.sock"));
|
||||||
let addr = socket_path.to_string_lossy().to_string();
|
let addr = socket_path.to_string_lossy().to_string();
|
||||||
let client = ipc::client::IpcClient::connect_unix(&addr)?;
|
let client = ipc::client::IpcClient::connect_unix(&addr)?;
|
||||||
|
|
||||||
@@ -530,11 +576,8 @@ fn setup_attach_client(
|
|||||||
let workspace_roots = vec![std::env::current_dir()?];
|
let workspace_roots = vec![std::env::current_dir()?];
|
||||||
let session_dir = store.base_dir.join("sessions").join(session_id);
|
let session_dir = store.base_dir.join("sessions").join(session_id);
|
||||||
std::fs::create_dir_all(&session_dir)?;
|
std::fs::create_dir_all(&session_dir)?;
|
||||||
let mut client_state = app::state::rest::AppStateRest::new(
|
let mut client_state =
|
||||||
workspace_roots,
|
app::state::rest::AppStateRest::new(workspace_roots, &session_dir, store.memory_dir);
|
||||||
&session_dir,
|
|
||||||
store.memory_dir,
|
|
||||||
);
|
|
||||||
client_state.session_id = session_id.to_string();
|
client_state.session_id = session_id.to_string();
|
||||||
|
|
||||||
Ok((client, terminal, client_state))
|
Ok((client, terminal, client_state))
|
||||||
@@ -551,21 +594,17 @@ fn handle_daemon_frame(
|
|||||||
}
|
}
|
||||||
Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {}
|
Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {}
|
||||||
Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => {
|
Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => {
|
||||||
client_state.push_toast(
|
client_state.push_toast(app::state::types::Toast::new(
|
||||||
app::state::types::Toast::new(
|
app::state::types::ToastKind::Info,
|
||||||
app::state::types::ToastKind::Info,
|
message,
|
||||||
message,
|
));
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Some(ipc::protocol::DaemonFrame::ClipboardCopy(text)) => {
|
Some(ipc::protocol::DaemonFrame::ClipboardCopy(text)) => {
|
||||||
let _ = write_osc52(&mut io::stdout(), &text);
|
let _ = write_osc52(&mut io::stdout(), &text);
|
||||||
client_state.push_toast(
|
client_state.push_toast(app::state::types::Toast::new(
|
||||||
app::state::types::Toast::new(
|
app::state::types::ToastKind::Success,
|
||||||
app::state::types::ToastKind::Success,
|
"Copied to clipboard".to_string(),
|
||||||
"Copied to clipboard".to_string(),
|
));
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Some(ipc::protocol::DaemonFrame::Closed) | None => {
|
Some(ipc::protocol::DaemonFrame::Closed) | None => {
|
||||||
client_state.quit = true;
|
client_state.quit = true;
|
||||||
@@ -719,10 +758,10 @@ fn run_loop_inner(
|
|||||||
state: &mut app::state::rest::AppStateRest,
|
state: &mut app::state::rest::AppStateRest,
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use std::time::Duration;
|
use app::runtime::actions::{apply_action, Action};
|
||||||
use crossterm::event::{Event, KeyEventKind, MouseEventKind};
|
|
||||||
use controller::input::handle_key;
|
use controller::input::handle_key;
|
||||||
use app::runtime::actions::{Action, apply_action};
|
use crossterm::event::{Event, KeyEventKind, MouseEventKind};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if state.quit {
|
if state.quit {
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ use crate::app::subagent::spawn::AgentDefinition;
|
|||||||
/// researcher, planner).
|
/// researcher, planner).
|
||||||
pub fn builtin_agents() -> Vec<AgentDefinition> {
|
pub fn builtin_agents() -> Vec<AgentDefinition> {
|
||||||
vec![
|
vec![
|
||||||
AgentDefinition::new(
|
AgentDefinition::new("coder".to_string(), "coder".to_string())
|
||||||
"coder".to_string(),
|
.with_system_prompt(
|
||||||
"coder".to_string(),
|
"You are a coding agent. Write correct, idiomatic Rust code.".to_string(),
|
||||||
).with_system_prompt(
|
)
|
||||||
"You are a coding agent. Write correct, idiomatic Rust code.".to_string()
|
.with_allowed_tools(vec![
|
||||||
).with_allowed_tools(
|
|
||||||
vec![
|
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
"write".to_string(),
|
"write".to_string(),
|
||||||
"edit".to_string(),
|
"edit".to_string(),
|
||||||
@@ -35,16 +33,14 @@ pub fn builtin_agents() -> Vec<AgentDefinition> {
|
|||||||
"lsp_references".to_string(),
|
"lsp_references".to_string(),
|
||||||
"lsp_completion".to_string(),
|
"lsp_completion".to_string(),
|
||||||
"lsp_disconnect".to_string(),
|
"lsp_disconnect".to_string(),
|
||||||
]
|
])
|
||||||
).with_max_steps(usize::MAX),
|
.with_max_steps(usize::MAX),
|
||||||
|
AgentDefinition::new("reviewer".to_string(), "reviewer".to_string())
|
||||||
AgentDefinition::new(
|
.with_system_prompt(
|
||||||
"reviewer".to_string(),
|
"You are a code reviewer. Focus on correctness, safety, and performance."
|
||||||
"reviewer".to_string(),
|
.to_string(),
|
||||||
).with_system_prompt(
|
)
|
||||||
"You are a code reviewer. Focus on correctness, safety, and performance.".to_string()
|
.with_allowed_tools(vec![
|
||||||
).with_allowed_tools(
|
|
||||||
vec![
|
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
"grep".to_string(),
|
"grep".to_string(),
|
||||||
"glob".to_string(),
|
"glob".to_string(),
|
||||||
@@ -54,39 +50,34 @@ pub fn builtin_agents() -> Vec<AgentDefinition> {
|
|||||||
"lsp_hover".to_string(),
|
"lsp_hover".to_string(),
|
||||||
"lsp_definition".to_string(),
|
"lsp_definition".to_string(),
|
||||||
"lsp_references".to_string(),
|
"lsp_references".to_string(),
|
||||||
]
|
])
|
||||||
).with_max_steps(usize::MAX),
|
.with_max_steps(usize::MAX),
|
||||||
|
AgentDefinition::new("researcher".to_string(), "researcher".to_string())
|
||||||
AgentDefinition::new(
|
.with_system_prompt(
|
||||||
"researcher".to_string(),
|
"You are a research agent. Search for information and summarize findings."
|
||||||
"researcher".to_string(),
|
.to_string(),
|
||||||
).with_system_prompt(
|
)
|
||||||
"You are a research agent. Search for information and summarize findings.".to_string()
|
.with_allowed_tools(vec![
|
||||||
).with_allowed_tools(
|
|
||||||
vec![
|
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
"grep".to_string(),
|
"grep".to_string(),
|
||||||
"glob".to_string(),
|
"glob".to_string(),
|
||||||
"bash".to_string(),
|
"bash".to_string(),
|
||||||
"search_web".to_string(),
|
"search_web".to_string(),
|
||||||
"fetch_url".to_string(),
|
"fetch_url".to_string(),
|
||||||
]
|
])
|
||||||
).with_max_steps(usize::MAX),
|
.with_max_steps(usize::MAX),
|
||||||
|
AgentDefinition::new("planner".to_string(), "planner".to_string())
|
||||||
AgentDefinition::new(
|
.with_system_prompt(
|
||||||
"planner".to_string(),
|
"You are a planning agent. Break down tasks into clear steps.".to_string(),
|
||||||
"planner".to_string(),
|
)
|
||||||
).with_system_prompt(
|
.with_allowed_tools(vec![
|
||||||
"You are a planning agent. Break down tasks into clear steps.".to_string()
|
|
||||||
).with_allowed_tools(
|
|
||||||
vec![
|
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
"write".to_string(),
|
"write".to_string(),
|
||||||
"edit".to_string(),
|
"edit".to_string(),
|
||||||
"bash".to_string(),
|
"bash".to_string(),
|
||||||
"todo_write".to_string(),
|
"todo_write".to_string(),
|
||||||
"todo_finish".to_string(),
|
"todo_finish".to_string(),
|
||||||
]
|
])
|
||||||
).with_max_steps(usize::MAX),
|
.with_max_steps(usize::MAX),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
//! Load, save, add, and remove agent definitions scoped to a single
|
//! Load, save, add, and remove agent definitions scoped to a single
|
||||||
//! session (`<session_dir>/agents.json`).
|
//! session (`<session_dir>/agents.json`).
|
||||||
use std::path::Path;
|
|
||||||
use crate::app::subagent::spawn::AgentDefinition;
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
/// Load agent definitions saved for a specific session.
|
/// Load agent definitions saved for a specific session.
|
||||||
///
|
///
|
||||||
@@ -21,12 +21,10 @@ pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
match std::fs::read_to_string(&agents_file) {
|
match std::fs::read_to_string(&agents_file) {
|
||||||
Ok(content) => {
|
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
serde_json::from_str(&content).unwrap_or_else(|e| {
|
tracing::warn!("[session] failed to parse agents.json: {}", e);
|
||||||
tracing::warn!("[session] failed to parse agents.json: {}", e);
|
Vec::new()
|
||||||
Vec::new()
|
}),
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(_) => Vec::new(),
|
Err(_) => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
pub mod store {
|
pub mod store {
|
||||||
pub use zesdex_entities::seaorm::common::store::*;
|
pub use zesdex_entities::seaorm::common::store::*;
|
||||||
}
|
}
|
||||||
|
pub mod agent_def;
|
||||||
/// Local modules not extracted to workspace crates
|
/// Local modules not extracted to workspace crates
|
||||||
pub mod msglog;
|
pub mod msglog;
|
||||||
pub mod agent_def;
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ impl LlmClient {
|
|||||||
stop: None,
|
stop: None,
|
||||||
stream_options: None,
|
stream_options: None,
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
|
top_p: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!("{}/chat/completions", self.base_url);
|
let url = format!("{}/chat/completions", self.base_url);
|
||||||
@@ -143,12 +144,9 @@ impl LlmClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let data: crate::dto::provider::response::ChatResponse = resp.json()?;
|
let data: crate::dto::provider::response::ChatResponse = resp.json()?;
|
||||||
let usage = data.usage.map(|u| {
|
let usage = data
|
||||||
(
|
.usage
|
||||||
u64::from(u.prompt_tokens),
|
.map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens)));
|
||||||
u64::from(u.completion_tokens),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let message = data
|
let message = data
|
||||||
.choices
|
.choices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -204,6 +202,7 @@ impl LlmClient {
|
|||||||
include_usage: true,
|
include_usage: true,
|
||||||
}),
|
}),
|
||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
|
top_p: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!("{}/chat/completions", self.base_url);
|
let url = format!("{}/chat/completions", self.base_url);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! and `bash_kill`. Both take a `job_id` produced by `bash` with `run_in_background=true`.
|
//! and `bash_kill`. Both take a `job_id` produced by `bash` with `run_in_background=true`.
|
||||||
use super::Tool;
|
use super::Tool;
|
||||||
use super::ToolCtx;
|
use super::ToolCtx;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::Result;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
/// Tool: fetch buffered output from a background bash job by `job_id`.
|
/// Tool: fetch buffered output from a background bash job by `job_id`.
|
||||||
@@ -31,11 +31,7 @@ impl Tool for BashOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let job_id = args
|
let job_id = crate::tool::arg_str(args, "job_id")?;
|
||||||
.get("job_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
|
|
||||||
.to_string();
|
|
||||||
// Validate that job_id looks like a UUID to prevent injection
|
// Validate that job_id looks like a UUID to prevent injection
|
||||||
// into the global job registry.
|
// into the global job registry.
|
||||||
if !is_valid_job_id(&job_id) {
|
if !is_valid_job_id(&job_id) {
|
||||||
@@ -74,11 +70,7 @@ impl Tool for BashKill {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let job_id = args
|
let job_id = crate::tool::arg_str(args, "job_id")?;
|
||||||
.get("job_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
|
|
||||||
.to_string();
|
|
||||||
if !is_valid_job_id(&job_id) {
|
if !is_valid_job_id(&job_id) {
|
||||||
anyhow::bail!("invalid job_id format: expected UUID");
|
anyhow::bail!("invalid job_id format: expected UUID");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
use super::super::resolve_path;
|
use super::super::resolve_path;
|
||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use super::helpers::arg_str;
|
use crate::tool::arg_str;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use super::super::check_graduated_checks;
|
|||||||
use super::super::resolve_path;
|
use super::super::resolve_path;
|
||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use super::helpers::{self, arg_str};
|
use super::helpers;
|
||||||
|
use crate::tool::arg_str;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use similar::TextDiff;
|
use similar::TextDiff;
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
//! Shared helpers for filesystem tools: extracting string arguments from JSON
|
//! Shared helpers for filesystem tools: extracting string arguments from JSON
|
||||||
//! and producing user-friendly "not found" diagnostics.
|
//! and producing user-friendly "not found" diagnostics.
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// 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())
|
|
||||||
.map(std::string::ToString::to_string)
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: {name}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Produce a user-friendly diagnostic string when a path doesn't resolve or exist.
|
/// Produce a user-friendly diagnostic string when a path doesn't resolve or exist.
|
||||||
///
|
///
|
||||||
@@ -70,35 +59,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_arg_str_found() {
|
|
||||||
let args = json!({"key": "value"});
|
|
||||||
assert_eq!(arg_str(&args, "key").unwrap(), "value");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_arg_str_missing() {
|
|
||||||
let args = json!({"other": "value"});
|
|
||||||
assert!(arg_str(&args, "key").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_arg_str_empty_string() {
|
|
||||||
let args = json!({"key": ""});
|
|
||||||
assert_eq!(arg_str(&args, "key").unwrap(), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_arg_str_wrong_type() {
|
|
||||||
let args = json!({"key": 42});
|
|
||||||
assert!(arg_str(&args, "key").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_arg_str_null() {
|
|
||||||
let args = json!({"key": null});
|
|
||||||
assert!(arg_str(&args, "key").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_truncate_diff_under_limit_unchanged() {
|
fn test_truncate_diff_under_limit_unchanged() {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
use super::super::resolve_path;
|
use super::super::resolve_path;
|
||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use super::helpers::{arg_str, not_found_help};
|
use super::helpers::not_found_help;
|
||||||
|
use crate::tool::arg_str;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use super::super::check_graduated_checks;
|
|||||||
use super::super::resolve_path;
|
use super::super::resolve_path;
|
||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use super::helpers::{self, arg_str};
|
use super::helpers;
|
||||||
|
use crate::tool::arg_str;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use similar::TextDiff;
|
use similar::TextDiff;
|
||||||
|
|||||||
@@ -40,22 +40,11 @@ impl Tool for GitCred {
|
|||||||
///
|
///
|
||||||
/// Return: combined stdout+stderr on success; error with stderr on non-zero exit.
|
/// Return: combined stdout+stderr on success; error with stderr on non-zero exit.
|
||||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let operation = args
|
let operation = crate::tool::arg_str(args, "operation")?;
|
||||||
.get("operation")
|
let mut cmd = Command::new("git");
|
||||||
.and_then(|v| v.as_str())
|
cmd.arg("credential").arg(&operation);
|
||||||
.ok_or_else(|| anyhow!("missing required argument: operation"))?;
|
|
||||||
let output = Command::new("git")
|
crate::tool::execute_cmd(&mut cmd)
|
||||||
.arg("credential")
|
.map_err(|e| anyhow!("git credential '{}' failed: {}", operation, e))
|
||||||
.arg(operation)
|
|
||||||
.output()
|
|
||||||
.map_err(|e| anyhow!("git credential failed: {e}"))?;
|
|
||||||
if output.status.success() {
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
||||||
Ok(format!("{stdout}{stderr}"))
|
|
||||||
} else {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
||||||
anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,11 +53,7 @@ impl Tool for GitOperator {
|
|||||||
/// Return: trimmed combined output on success; error including exit code and
|
/// Return: trimmed combined output on success; error including exit code and
|
||||||
/// stderr on failure.
|
/// stderr on failure.
|
||||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let operation = args
|
let operation = crate::tool::arg_str(args, "operation")?;
|
||||||
.get("operation")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: operation"))?
|
|
||||||
.to_string();
|
|
||||||
let arg_list: Vec<String> = args
|
let arg_list: Vec<String> = args
|
||||||
.get("args")
|
.get("args")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
@@ -73,27 +69,11 @@ impl Tool for GitOperator {
|
|||||||
let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" "));
|
let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" "));
|
||||||
crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter)
|
crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter)
|
||||||
.map_err(|e| anyhow!("blocked: {e}"))?;
|
.map_err(|e| anyhow!("blocked: {e}"))?;
|
||||||
let output = Command::new("git")
|
let mut cmd = Command::new("git");
|
||||||
.arg(&operation)
|
cmd.arg(&operation)
|
||||||
.args(&arg_list)
|
.args(&arg_list);
|
||||||
.output()
|
|
||||||
.map_err(|e| anyhow!("git {operation} failed: {e}"))?;
|
crate::tool::execute_cmd(&mut cmd)
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
.map_err(|e| anyhow!("git {operation} failed: {e}"))
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
||||||
let combined = if stderr.is_empty() {
|
|
||||||
stdout.trim().to_string()
|
|
||||||
} else {
|
|
||||||
format!("{}\n{}", stdout.trim(), stderr.trim())
|
|
||||||
};
|
|
||||||
if output.status.success() {
|
|
||||||
Ok(combined)
|
|
||||||
} else {
|
|
||||||
anyhow::bail!(
|
|
||||||
"git {} failed (exit {}): {}",
|
|
||||||
operation,
|
|
||||||
output.status.code().unwrap_or(-1),
|
|
||||||
stderr.trim()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,45 +42,24 @@ impl Tool for GitWorktree {
|
|||||||
/// Return: success message with combined output on success; error including exit
|
/// Return: success message with combined output on success; error including exit
|
||||||
/// code and stderr on failure.
|
/// code and stderr on failure.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let name = args
|
let name = crate::tool::arg_str(args, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: name"))?
|
|
||||||
.to_string();
|
|
||||||
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||||
anyhow::bail!("worktree name must not contain path separators or '..'");
|
anyhow::bail!("worktree name must not contain path separators or '..'");
|
||||||
}
|
}
|
||||||
let base_ref = args
|
let base_ref = crate::tool::arg_str(args, "base_ref")?;
|
||||||
.get("base_ref")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: base_ref"))?
|
|
||||||
.to_string();
|
|
||||||
let worktree_path = ctx.worktrees_dir.join(&name);
|
let worktree_path = ctx.worktrees_dir.join(&name);
|
||||||
std::fs::create_dir_all(&worktree_path)
|
std::fs::create_dir_all(&worktree_path)
|
||||||
.map_err(|e| anyhow!("failed to create worktree directory: {e}"))?;
|
.map_err(|e| anyhow!("failed to create worktree directory: {e}"))?;
|
||||||
let output = Command::new("git")
|
let mut cmd = Command::new("git");
|
||||||
.args(["worktree", "add", "--checkout"])
|
cmd.args(["worktree", "add", "--checkout"])
|
||||||
.arg(worktree_path.display().to_string())
|
.arg(worktree_path.display().to_string())
|
||||||
.arg(&base_ref)
|
.arg(&base_ref);
|
||||||
.output()
|
|
||||||
|
let output = crate::tool::execute_cmd(&mut cmd)
|
||||||
.map_err(|e| anyhow!("git worktree add failed: {e}"))?;
|
.map_err(|e| anyhow!("git worktree add failed: {e}"))?;
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
Ok(format!(
|
||||||
let combined = if stderr.is_empty() {
|
"created worktree '{name}' from '{base_ref}'\n{output}"
|
||||||
stdout.trim().to_string()
|
))
|
||||||
} else {
|
|
||||||
format!("{}\n{}", stdout.trim(), stderr.trim())
|
|
||||||
};
|
|
||||||
if output.status.success() {
|
|
||||||
Ok(format!(
|
|
||||||
"created worktree '{name}' from '{base_ref}'\n{combined}"
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
anyhow::bail!(
|
|
||||||
"git worktree add failed (exit {}): {}",
|
|
||||||
output.status.code().unwrap_or(-1),
|
|
||||||
stderr.trim()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,18 +51,9 @@ impl Tool for LspConnect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let name = args
|
let name = crate::tool::arg_str(args, "name")?;
|
||||||
.get("name")
|
let command = crate::tool::arg_str(args, "command")?;
|
||||||
.and_then(|v| v.as_str())
|
let language_id = crate::tool::arg_str(args, "language_id")?;
|
||||||
.ok_or_else(|| anyhow!("missing required argument: name"))?;
|
|
||||||
let command = args
|
|
||||||
.get("command")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: command"))?;
|
|
||||||
let language_id = args
|
|
||||||
.get("language_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: language_id"))?;
|
|
||||||
let extra_args: Vec<String> = args
|
let extra_args: Vec<String> = args
|
||||||
.get("args")
|
.get("args")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
@@ -77,17 +68,17 @@ impl Tool for LspConnect {
|
|||||||
.lsp_manager
|
.lsp_manager
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
||||||
manager.connect(command, &extra_args, language_id)?;
|
manager.connect(&command, &extra_args, &language_id)?;
|
||||||
|
|
||||||
// Auto-register this server's known extensions so lsp_diagnostics /
|
// Auto-register this server's known extensions so lsp_diagnostics /
|
||||||
// lsp_hover / lsp_completion / lsp_definition / lsp_references can
|
// lsp_hover / lsp_completion / lsp_definition / lsp_references can
|
||||||
// auto-detect it later without an explicit `server` argument.
|
// auto-detect it later without an explicit `server` argument.
|
||||||
let known_exts = known_extensions_for(language_id);
|
let known_exts = known_extensions_for(&language_id);
|
||||||
if !known_exts.is_empty() {
|
if !known_exts.is_empty() {
|
||||||
manager.register_extensions(language_id, known_exts);
|
manager.register_extensions(&language_id, known_exts);
|
||||||
}
|
}
|
||||||
|
|
||||||
let client_arc = manager.get_client(language_id);
|
let client_arc = manager.get_client(&language_id);
|
||||||
let caps = client_arc
|
let caps = client_arc
|
||||||
.and_then(|c| {
|
.and_then(|c| {
|
||||||
c.lock()
|
c.lock()
|
||||||
@@ -138,18 +129,12 @@ impl Tool for LspDiagnostics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let rel_path = args
|
let rel_path = crate::tool::arg_str(args, "path")?;
|
||||||
.get("path")
|
let text = crate::tool::arg_str(args, "text")?;
|
||||||
.and_then(|v| v.as_str())
|
let server_name = resolve_server_name(ctx, args, &rel_path)?;
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
|
||||||
let text = args
|
|
||||||
.get("text")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: text"))?;
|
|
||||||
let server_name = resolve_server_name(ctx, args, rel_path)?;
|
|
||||||
let server_name = server_name.as_str();
|
let server_name = server_name.as_str();
|
||||||
|
|
||||||
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
|
let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?;
|
||||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
||||||
|
|
||||||
let manager = ctx
|
let manager = ctx
|
||||||
@@ -168,7 +153,7 @@ impl Tool for LspDiagnostics {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
||||||
|
|
||||||
match client.collect_diagnostics(&uri, &language_id, text) {
|
match client.collect_diagnostics(&uri, &language_id, &text) {
|
||||||
Ok(diags) => {
|
Ok(diags) => {
|
||||||
let diags_array = diags.as_array().cloned().unwrap_or_default();
|
let diags_array = diags.as_array().cloned().unwrap_or_default();
|
||||||
if diags_array.is_empty() {
|
if diags_array.is_empty() {
|
||||||
@@ -279,52 +264,12 @@ impl Tool for LspHover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let rel_path = args
|
let result = run_lsp_query(ctx, args, |client, uri, line, column| {
|
||||||
.get("path")
|
client.hover(uri, line, column)
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
|
||||||
let line = args
|
|
||||||
.get("line")
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
|
|
||||||
let column =
|
|
||||||
args.get("column")
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
|
|
||||||
let server_name = resolve_server_name(ctx, args, rel_path)?;
|
|
||||||
let server_name = server_name.as_str();
|
|
||||||
|
|
||||||
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
|
|
||||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
|
||||||
|
|
||||||
let file_content = std::fs::read_to_string(&abs_path)
|
|
||||||
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
|
|
||||||
|
|
||||||
let manager = ctx
|
|
||||||
.lsp_manager
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
|
||||||
let language_id = manager.get_language_id(server_name).unwrap_or_else(|| {
|
|
||||||
args.get("language_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("plaintext")
|
|
||||||
.to_string()
|
|
||||||
});
|
});
|
||||||
let client_arc = manager.get_client(server_name).ok_or_else(|| {
|
|
||||||
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
|
|
||||||
})?;
|
|
||||||
drop(manager);
|
|
||||||
|
|
||||||
let mut client = client_arc
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
|
||||||
|
|
||||||
client.did_open(&uri, &language_id, 1, &file_content)?;
|
|
||||||
let result = client.hover(&uri, line, column);
|
|
||||||
let _ = client.did_close(&uri);
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(hover_result) => {
|
Ok((hover_result, _line, _column)) => {
|
||||||
if hover_result == Value::Null {
|
if hover_result == Value::Null {
|
||||||
return Ok("No hover information available at this position.".to_string());
|
return Ok("No hover information available at this position.".to_string());
|
||||||
}
|
}
|
||||||
@@ -424,49 +369,12 @@ impl Tool for LspCompletion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let rel_path = args
|
let result = run_lsp_query(ctx, args, |client, uri, line, column| {
|
||||||
.get("path")
|
client.completion(uri, line, column)
|
||||||
.and_then(|v| v.as_str())
|
});
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
|
||||||
let line = args
|
|
||||||
.get("line")
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
|
|
||||||
let column =
|
|
||||||
args.get("column")
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
|
|
||||||
let server_name = resolve_server_name(ctx, args, rel_path)?;
|
|
||||||
let server_name = server_name.as_str();
|
|
||||||
|
|
||||||
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
|
|
||||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
|
||||||
|
|
||||||
let file_content = std::fs::read_to_string(&abs_path)
|
|
||||||
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
|
|
||||||
|
|
||||||
let manager = ctx
|
|
||||||
.lsp_manager
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
|
||||||
let language_id = manager
|
|
||||||
.get_language_id(server_name)
|
|
||||||
.unwrap_or_else(|| "plaintext".to_string());
|
|
||||||
let client_arc = manager.get_client(server_name).ok_or_else(|| {
|
|
||||||
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
|
|
||||||
})?;
|
|
||||||
drop(manager);
|
|
||||||
|
|
||||||
let mut client = client_arc
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
|
||||||
|
|
||||||
client.did_open(&uri, &language_id, 1, &file_content)?;
|
|
||||||
let result = client.completion(&uri, line, column);
|
|
||||||
let _ = client.did_close(&uri);
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(completion_result) => {
|
Ok((completion_result, line, column)) => {
|
||||||
let items = if let Some(items) = completion_result.as_array() {
|
let items = if let Some(items) = completion_result.as_array() {
|
||||||
items.clone()
|
items.clone()
|
||||||
} else if let Some(arr) = completion_result.get("items").and_then(|v| v.as_array())
|
} else if let Some(arr) = completion_result.get("items").and_then(|v| v.as_array())
|
||||||
@@ -576,49 +484,12 @@ impl Tool for LspDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let rel_path = args
|
let result = run_lsp_query(ctx, args, |client, uri, line, column| {
|
||||||
.get("path")
|
client.goto_definition(uri, line, column)
|
||||||
.and_then(|v| v.as_str())
|
});
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
|
||||||
let line = args
|
|
||||||
.get("line")
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
|
|
||||||
let column =
|
|
||||||
args.get("column")
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
|
|
||||||
let server_name = resolve_server_name(ctx, args, rel_path)?;
|
|
||||||
let server_name = server_name.as_str();
|
|
||||||
|
|
||||||
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
|
|
||||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
|
||||||
|
|
||||||
let file_content = std::fs::read_to_string(&abs_path)
|
|
||||||
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
|
|
||||||
|
|
||||||
let manager = ctx
|
|
||||||
.lsp_manager
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
|
||||||
let language_id = manager
|
|
||||||
.get_language_id(server_name)
|
|
||||||
.unwrap_or_else(|| "plaintext".to_string());
|
|
||||||
let client_arc = manager.get_client(server_name).ok_or_else(|| {
|
|
||||||
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
|
|
||||||
})?;
|
|
||||||
drop(manager);
|
|
||||||
|
|
||||||
let mut client = client_arc
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
|
||||||
|
|
||||||
client.did_open(&uri, &language_id, 1, &file_content)?;
|
|
||||||
let result = client.goto_definition(&uri, line, column);
|
|
||||||
let _ = client.did_close(&uri);
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(def_result) => {
|
Ok((def_result, _line, _column)) => {
|
||||||
if def_result == Value::Null {
|
if def_result == Value::Null {
|
||||||
return Ok("No definition found at this position.".to_string());
|
return Ok("No definition found at this position.".to_string());
|
||||||
}
|
}
|
||||||
@@ -696,49 +567,12 @@ impl Tool for LspReferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let rel_path = args
|
let result = run_lsp_query(ctx, args, |client, uri, line, column| {
|
||||||
.get("path")
|
client.references(uri, line, column)
|
||||||
.and_then(|v| v.as_str())
|
});
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
|
||||||
let line = args
|
|
||||||
.get("line")
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
|
|
||||||
let column =
|
|
||||||
args.get("column")
|
|
||||||
.and_then(serde_json::Value::as_i64)
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
|
|
||||||
let server_name = resolve_server_name(ctx, args, rel_path)?;
|
|
||||||
let server_name = server_name.as_str();
|
|
||||||
|
|
||||||
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
|
|
||||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
|
||||||
|
|
||||||
let file_content = std::fs::read_to_string(&abs_path)
|
|
||||||
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
|
|
||||||
|
|
||||||
let manager = ctx
|
|
||||||
.lsp_manager
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
|
||||||
let language_id = manager
|
|
||||||
.get_language_id(server_name)
|
|
||||||
.unwrap_or_else(|| "plaintext".to_string());
|
|
||||||
let client_arc = manager.get_client(server_name).ok_or_else(|| {
|
|
||||||
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
|
|
||||||
})?;
|
|
||||||
drop(manager);
|
|
||||||
|
|
||||||
let mut client = client_arc
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
|
||||||
|
|
||||||
client.did_open(&uri, &language_id, 1, &file_content)?;
|
|
||||||
let result = client.references(&uri, line, column);
|
|
||||||
let _ = client.did_close(&uri);
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(ref_result) => {
|
Ok((ref_result, _line, _column)) => {
|
||||||
let locations = ref_result.as_array().cloned().unwrap_or_default();
|
let locations = ref_result.as_array().cloned().unwrap_or_default();
|
||||||
if locations.is_empty() {
|
if locations.is_empty() {
|
||||||
return Ok("No references found for this symbol.".to_string());
|
return Ok("No references found for this symbol.".to_string());
|
||||||
@@ -794,17 +628,14 @@ impl Tool for LspDisconnect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let name = args
|
let name = crate::tool::arg_str(args, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: name"))?;
|
|
||||||
|
|
||||||
let mut manager = ctx
|
let mut manager = ctx
|
||||||
.lsp_manager
|
.lsp_manager
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
||||||
|
|
||||||
if manager.disconnect(name) {
|
if manager.disconnect(&name) {
|
||||||
Ok(format!("Disconnected from LSP server '{name}'"))
|
Ok(format!("Disconnected from LSP server '{name}'"))
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("LSP server '{name}' not found"))
|
Err(anyhow!("LSP server '{name}' not found"))
|
||||||
@@ -842,6 +673,55 @@ fn known_extensions_for(language_id: &str) -> &[&'static str] {
|
|||||||
/// a server connected without an explicit `register_extensions` call. Returns
|
/// a server connected without an explicit `register_extensions` call. Returns
|
||||||
/// `None` if the path has no extension, the lock is poisoned, or no
|
/// `None` if the path has no extension, the lock is poisoned, or no
|
||||||
/// connected server's language is known to use that extension.
|
/// connected server's language is known to use that extension.
|
||||||
|
fn run_lsp_query<F, R>(ctx: &ToolCtx, args: &Value, op: F) -> Result<(R, u32, u32)>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut crate::app::lsp::LspClient, &str, u32, u32) -> Result<R>,
|
||||||
|
{
|
||||||
|
let rel_path = crate::tool::arg_str(args, "path")?;
|
||||||
|
let line = args
|
||||||
|
.get("line")
|
||||||
|
.and_then(serde_json::Value::as_i64)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing required argument: line"))? as u32;
|
||||||
|
let column =
|
||||||
|
args.get("column")
|
||||||
|
.and_then(serde_json::Value::as_i64)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing required argument: column"))? as u32;
|
||||||
|
let server_name = resolve_server_name(ctx, args, &rel_path)?;
|
||||||
|
|
||||||
|
let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?;
|
||||||
|
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
||||||
|
|
||||||
|
let file_content = std::fs::read_to_string(&abs_path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read file '{rel_path}': {e}"))?;
|
||||||
|
|
||||||
|
let manager = ctx
|
||||||
|
.lsp_manager
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("LSP manager lock error: {e}"))?;
|
||||||
|
let language_id = manager
|
||||||
|
.get_language_id(&server_name)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
args.get("language_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("plaintext")
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
let client_arc = manager.get_client(&server_name).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
|
||||||
|
})?;
|
||||||
|
drop(manager);
|
||||||
|
|
||||||
|
let mut client = client_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("LSP client lock error: {e}"))?;
|
||||||
|
|
||||||
|
client.did_open(&uri, &language_id, 1, &file_content)?;
|
||||||
|
let result = op(&mut client, &uri, line, column);
|
||||||
|
let _ = client.did_close(&uri);
|
||||||
|
|
||||||
|
result.map(|r| (r, line, column))
|
||||||
|
}
|
||||||
|
|
||||||
fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> {
|
fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> {
|
||||||
let ext = std::path::Path::new(path)
|
let ext = std::path::Path::new(path)
|
||||||
.extension()
|
.extension()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! Tool for deleting a persisted memory entry by name.
|
//! Tool for deleting a persisted memory entry by name.
|
||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use zesdex_cms::domain::repository::MemoryRepository;
|
|
||||||
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use zesdex_cms::domain::repository::MemoryRepository;
|
||||||
|
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
||||||
|
|
||||||
/// Tool that removes a single memory entry from `ctx.memory_dir` by exact name.
|
/// Tool that removes a single memory entry from `ctx.memory_dir` by exact name.
|
||||||
pub struct Forget;
|
pub struct Forget;
|
||||||
@@ -38,12 +38,10 @@ impl Tool for Forget {
|
|||||||
/// Return: confirmation message on success; error if the memory does not exist
|
/// Return: confirmation message on success; error if the memory does not exist
|
||||||
/// or the file could not be removed.
|
/// or the file could not be removed.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let name = args
|
let name = crate::tool::arg_str(args, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: name"))?;
|
|
||||||
|
|
||||||
MarkdownMemoryRepository::new().delete(&ctx.memory_dir, name)
|
MarkdownMemoryRepository::new()
|
||||||
|
.delete(&ctx.memory_dir, &name)
|
||||||
.map_err(|e| anyhow!("failed to remove memory '{name}': {e}"))?;
|
.map_err(|e| anyhow!("failed to remove memory '{name}': {e}"))?;
|
||||||
|
|
||||||
Ok(format!("removed memory '{name}'"))
|
Ok(format!("removed memory '{name}'"))
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
//! Tool for reading a single memory entry or listing the whole memory index.
|
//! Tool for reading a single memory entry or listing the whole memory index.
|
||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use zesdex_cms::domain::repository::MemoryRepository;
|
|
||||||
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
use zesdex_cms::domain::repository::MemoryRepository;
|
||||||
|
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
||||||
|
|
||||||
/// Tool that reads one memory entry by name, or lists all entries when name is omitted.
|
/// Tool that reads one memory entry by name, or lists all entries when name is omitted.
|
||||||
pub struct Recall;
|
pub struct Recall;
|
||||||
@@ -42,7 +42,8 @@ impl Tool for Recall {
|
|||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Ok(list_all(ctx));
|
return Ok(list_all(ctx));
|
||||||
}
|
}
|
||||||
let memory = MarkdownMemoryRepository::new().load(&ctx.memory_dir, name)
|
let memory = MarkdownMemoryRepository::new()
|
||||||
|
.load(&ctx.memory_dir, name)
|
||||||
.map_err(|e| anyhow!("memory '{name}' not found: {e}"))?;
|
.map_err(|e| anyhow!("memory '{name}' not found: {e}"))?;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}",
|
"---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}",
|
||||||
@@ -61,7 +62,9 @@ impl Tool for Recall {
|
|||||||
///
|
///
|
||||||
/// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)").
|
/// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)").
|
||||||
fn list_all(ctx: &ToolCtx) -> String {
|
fn list_all(ctx: &ToolCtx) -> String {
|
||||||
let names = MarkdownMemoryRepository::new().list(&ctx.memory_dir).unwrap_or_default();
|
let names = MarkdownMemoryRepository::new()
|
||||||
|
.list(&ctx.memory_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
if names.is_empty() {
|
if names.is_empty() {
|
||||||
return "(no memory entries)".to_string();
|
return "(no memory entries)".to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
//! Tool for saving a new memory entry to persistent project memory.
|
//! Tool for saving a new memory entry to persistent project memory.
|
||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use serde_json::{json, Value};
|
||||||
use zesdex_cms::domain::memory::Memory;
|
use zesdex_cms::domain::memory::Memory;
|
||||||
use zesdex_cms::domain::repository::MemoryRepository;
|
use zesdex_cms::domain::repository::MemoryRepository;
|
||||||
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
/// Tool that writes a new `Memory` entry (name/description/content/kind) to disk.
|
/// Tool that writes a new `Memory` entry (name/description/content/kind) to disk.
|
||||||
pub struct Remember;
|
pub struct Remember;
|
||||||
@@ -56,24 +56,12 @@ impl Tool for Remember {
|
|||||||
///
|
///
|
||||||
/// Return: confirmation string on success; error if name is invalid or the write fails.
|
/// Return: confirmation string on success; error if name is invalid or the write fails.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let name = args
|
let name = crate::tool::arg_str(args, "name")?;
|
||||||
.get("name")
|
let description = crate::tool::arg_str(args, "description")?;
|
||||||
.and_then(|v| v.as_str())
|
let content = crate::tool::arg_str(args, "content")?;
|
||||||
.ok_or_else(|| anyhow!("missing required argument: name"))?;
|
let kind = crate::tool::arg_str(args, "kind")?;
|
||||||
let description = args
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: description"))?;
|
|
||||||
let content = args
|
|
||||||
.get("content")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: content"))?;
|
|
||||||
let kind = args
|
|
||||||
.get("kind")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: kind"))?;
|
|
||||||
|
|
||||||
if Memory::slugify(name).is_none() {
|
if Memory::slugify(&name).is_none() {
|
||||||
anyhow::bail!("invalid memory name: must produce a valid slug (alphanumeric + hyphens, 1-80 chars)");
|
anyhow::bail!("invalid memory name: must produce a valid slug (alphanumeric + hyphens, 1-80 chars)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +81,8 @@ impl Tool for Remember {
|
|||||||
provenances: vec![],
|
provenances: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
MarkdownMemoryRepository::new().save(&ctx.memory_dir, &memory)
|
MarkdownMemoryRepository::new()
|
||||||
|
.save(&ctx.memory_dir, &memory)
|
||||||
.map_err(|e| anyhow!("failed to save memory '{name}': {e}"))?;
|
.map_err(|e| anyhow!("failed to save memory '{name}': {e}"))?;
|
||||||
|
|
||||||
Ok(format!("saved memory '{name}' ({kind})"))
|
Ok(format!("saved memory '{name}' ({kind})"))
|
||||||
|
|||||||
@@ -237,6 +237,37 @@ pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::provider::request::
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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())
|
||||||
|
.map(std::string::ToString::to_string)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing required argument: {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a `std::process::Command` and return its combined stdout/stderr.
|
||||||
|
///
|
||||||
|
/// Return: `Ok(output)` on success, `Err(combined)` on non-zero exit or failure.
|
||||||
|
pub fn execute_cmd(cmd: &mut std::process::Command) -> Result<String> {
|
||||||
|
let output = cmd.output().map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?;
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let combined = if stderr.is_empty() {
|
||||||
|
stdout
|
||||||
|
} else {
|
||||||
|
format!("{}\n{}", stdout, stderr).trim().to_string()
|
||||||
|
};
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(combined)
|
||||||
|
} else {
|
||||||
|
let code = output.status.code().unwrap_or(-1);
|
||||||
|
anyhow::bail!("command failed with exit code {code}:\n{combined}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a tool-supplied relative path to an absolute path within a workspace root,
|
/// Resolve a tool-supplied relative path to an absolute path within a workspace root,
|
||||||
/// rejecting escapes.
|
/// rejecting escapes.
|
||||||
///
|
///
|
||||||
@@ -305,10 +336,41 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_ctx_builder_defaults_abort_flag_to_none() {
|
fn tool_ctx_builder_defaults_abort_flag_to_none() {
|
||||||
let ctx = ToolCtx::builder().build();
|
let ctx = ToolCtx::builder().build();
|
||||||
assert!(ctx.abort_flag.is_none());
|
assert!(ctx.abort_flag.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_arg_str_found() {
|
||||||
|
let args = json!({"key": "value"});
|
||||||
|
assert_eq!(arg_str(&args, "key").unwrap(), "value");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_arg_str_missing() {
|
||||||
|
let args = json!({"other": "value"});
|
||||||
|
assert!(arg_str(&args, "key").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_arg_str_empty_string() {
|
||||||
|
let args = json!({"key": ""});
|
||||||
|
assert_eq!(arg_str(&args, "key").unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_arg_str_wrong_type() {
|
||||||
|
let args = json!({"key": 42});
|
||||||
|
assert!(arg_str(&args, "key").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_arg_str_null() {
|
||||||
|
let args = json!({"key": null});
|
||||||
|
assert!(arg_str(&args, "key").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Plan-mode signaling tools: entering plan mode with a proposal, and confirming readiness.
|
//! Plan-mode signaling tools: entering plan mode with a proposal, and confirming readiness.
|
||||||
use super::Tool;
|
use super::Tool;
|
||||||
use super::ToolCtx;
|
use super::ToolCtx;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::Result;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
/// Tool the model calls to present a step-by-step plan and enter plan mode.
|
/// Tool the model calls to present a step-by-step plan and enter plan mode.
|
||||||
@@ -38,14 +38,8 @@ impl Tool for PlanEnter {
|
|||||||
///
|
///
|
||||||
/// Return: fixed acknowledgement string on success; error if either arg is missing.
|
/// Return: fixed acknowledgement string on success; error if either arg is missing.
|
||||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let _ = args
|
let _ = crate::tool::arg_str(args, "plan")?;
|
||||||
.get("plan")
|
let _ = crate::tool::arg_str(args, "sign_off")?;
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: plan"))?;
|
|
||||||
let _ = args
|
|
||||||
.get("sign_off")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: sign_off"))?;
|
|
||||||
Ok("plan recorded".to_string())
|
Ok("plan recorded".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,10 +73,7 @@ impl Tool for PlanReady {
|
|||||||
///
|
///
|
||||||
/// Return: fixed "ready to execute" string on success; error if `confirmation` is missing.
|
/// Return: fixed "ready to execute" string on success; error if `confirmation` is missing.
|
||||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let _ = args
|
let _ = crate::tool::arg_str(args, "confirmation")?;
|
||||||
.get("confirmation")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: confirmation"))?;
|
|
||||||
Ok("ready to execute".to_string())
|
Ok("ready to execute".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,16 +48,8 @@ impl Tool for Grep {
|
|||||||
///
|
///
|
||||||
/// Return: "no matches found" if empty, else a header + `path:line:text` rows.
|
/// Return: "no matches found" if empty, else a header + `path:line:text` rows.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let pattern = args
|
let pattern = crate::tool::arg_str(args, "pattern")?;
|
||||||
.get("pattern")
|
let rel = crate::tool::arg_str(args, "path")?;
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: pattern"))?
|
|
||||||
.to_string();
|
|
||||||
let rel = args
|
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?
|
|
||||||
.to_string();
|
|
||||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
anyhow::bail!("path '{rel}' does not exist");
|
anyhow::bail!("path '{rel}' does not exist");
|
||||||
@@ -137,16 +129,8 @@ impl Tool for Glob {
|
|||||||
///
|
///
|
||||||
/// Return: sorted newline-joined matches; "no files match" sentinel if empty.
|
/// Return: sorted newline-joined matches; "no files match" sentinel if empty.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let pat_str = args
|
let pat_str = crate::tool::arg_str(args, "pattern")?;
|
||||||
.get("pattern")
|
let rel = crate::tool::arg_str(args, "path")?;
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: pattern"))?
|
|
||||||
.to_string();
|
|
||||||
let rel = args
|
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?
|
|
||||||
.to_string();
|
|
||||||
let root = resolve_path(&ctx.workspaces, &rel)?;
|
let root = resolve_path(&ctx.workspaces, &rel)?;
|
||||||
if !root.exists() || !root.is_dir() {
|
if !root.exists() || !root.is_dir() {
|
||||||
anyhow::bail!("path '{rel}' is not a valid directory");
|
anyhow::bail!("path '{rel}' is not a valid directory");
|
||||||
|
|||||||
@@ -64,11 +64,7 @@ impl Tool for Bash {
|
|||||||
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
|
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
|
||||||
/// foreground runs, or the job ID for background runs.
|
/// foreground runs, or the job ID for background runs.
|
||||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let cmd = args
|
let cmd = crate::tool::arg_str(args, "command")?;
|
||||||
.get("command")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: command"))?
|
|
||||||
.to_string();
|
|
||||||
let timeout_ms = args
|
let timeout_ms = args
|
||||||
.get("timeout")
|
.get("timeout")
|
||||||
.and_then(serde_json::Value::as_u64)
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
//! module is kept for callers that DO want to block credential reads (e.g.
|
//! module is kept for callers that DO want to block credential reads (e.g.
|
||||||
//! a future sandboxed/untrusted-tool execution path) and is covered by its
|
//! a future sandboxed/untrusted-tool execution path) and is covered by its
|
||||||
//! own inline tests below.
|
//! own inline tests below.
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
/// Reject shell commands whose lowercased form contains any known credential-read pattern.
|
/// Reject shell commands whose lowercased form contains any known credential-read pattern.
|
||||||
///
|
///
|
||||||
@@ -19,59 +18,6 @@ use anyhow::Result;
|
|||||||
/// model could insert quotes between characters to bypass substring matching.
|
/// model could insert quotes between characters to bypass substring matching.
|
||||||
///
|
///
|
||||||
/// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise.
|
/// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise.
|
||||||
pub fn check_credential_read(cmd: &str) -> Result<()> {
|
|
||||||
let patterns = [
|
|
||||||
// SSH key files
|
|
||||||
"cat ~/.ssh",
|
|
||||||
"cat /home/",
|
|
||||||
".ssh/id_rsa",
|
|
||||||
".ssh/id_ed25519",
|
|
||||||
".ssh/id_ecdsa",
|
|
||||||
".ssh/id_dsa",
|
|
||||||
".ssh/authorized_keys",
|
|
||||||
".ssh/known_hosts",
|
|
||||||
// Git / generic credential files
|
|
||||||
".git-credentials",
|
|
||||||
".netrc",
|
|
||||||
// Cloud credentials
|
|
||||||
"aws/credentials",
|
|
||||||
"gcloud/credentials",
|
|
||||||
".config/gcloud",
|
|
||||||
".config/gh",
|
|
||||||
// Container/K8s credentials
|
|
||||||
".docker/config.json",
|
|
||||||
".kube/config",
|
|
||||||
".npmrc",
|
|
||||||
// Token/key patterns in command strings
|
|
||||||
"token=",
|
|
||||||
"secret=",
|
|
||||||
"api_key=",
|
|
||||||
"api-key=",
|
|
||||||
"password=",
|
|
||||||
"ghp_",
|
|
||||||
"ghs_",
|
|
||||||
"sk-",
|
|
||||||
"akia",
|
|
||||||
"bearer ",
|
|
||||||
// Environment variable dumpers
|
|
||||||
" env",
|
|
||||||
"printenv",
|
|
||||||
"/proc/self/environ",
|
|
||||||
];
|
|
||||||
let cmd_lower = cmd.to_lowercase();
|
|
||||||
let cmd_no_quotes: String = cmd_lower.chars()
|
|
||||||
.filter(|&c| c != '\'' && c != '"')
|
|
||||||
.collect();
|
|
||||||
// Also check against ANSI-C quoting normalization so that
|
|
||||||
// $'cat\u0020~/.ssh/id_rsa' does not bypass the filter.
|
|
||||||
let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes);
|
|
||||||
for pattern in &patterns {
|
|
||||||
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) {
|
|
||||||
anyhow::bail!("credential read blocked: '{}'", pattern);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
|
|||||||
"push --tags --force",
|
"push --tags --force",
|
||||||
];
|
];
|
||||||
let cmd_lower = cmd.to_lowercase();
|
let cmd_lower = cmd.to_lowercase();
|
||||||
let cmd_no_quotes: String = cmd_lower
|
let cmd_no_quotes = super::strip_quotes(&cmd_lower);
|
||||||
.chars()
|
|
||||||
.filter(|&c| c != '\'' && c != '"')
|
|
||||||
.collect();
|
|
||||||
// Normalize ANSI-C quoting ($'...') which can encode spaces and
|
// Normalize ANSI-C quoting ($'...') which can encode spaces and
|
||||||
// special characters as escape sequences (e.g. $'push\u0020--force'
|
// special characters as escape sequences (e.g. $'push\u0020--force'
|
||||||
// → "push --force"), bypassing the raw substring matching above.
|
// → "push --force"), bypassing the raw substring matching above.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
//! Pre-execution safety filters applied to shell commands before they're spawned.
|
//! Pre-execution safety filters applied to shell commands before they're spawned.
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
pub mod credentials;
|
||||||
|
|
||||||
|
/// Strip single and double quotes from a string.
|
||||||
|
pub(crate) fn strip_quotes(s: &str) -> String {
|
||||||
|
s.chars().filter(|&c| c != '\'' && c != '"').collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Decode ANSI-C quoted strings ($'...') found in `input`, replacing
|
/// Decode ANSI-C quoted strings ($'...') found in `input`, replacing
|
||||||
/// them with their unquoted, escape-decoded equivalents.
|
/// them with their unquoted, escape-decoded equivalents.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! Also provides a pipeline variant: `spawn_pipeline` runs agents
|
//! Also provides a pipeline variant: `spawn_pipeline` runs agents
|
||||||
//! sequentially so each stage sees the previous stage's findings.
|
//! sequentially so each stage sees the previous stage's findings.
|
||||||
use super::{Tool, ToolCtx};
|
use super::{Tool, ToolCtx};
|
||||||
|
use crate::app::workflow::engine::PrimitiveCtx;
|
||||||
use crate::app::workflow::script::{ScriptOptions, ScriptPrimitive, WorkflowScript};
|
use crate::app::workflow::script::{ScriptOptions, ScriptPrimitive, WorkflowScript};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -114,18 +115,18 @@ impl Tool for SpawnAgents {
|
|||||||
// spawn_agents or workflow_run invocations.
|
// spawn_agents or workflow_run invocations.
|
||||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
||||||
let results = crate::app::workflow::engine::execute_primitive(
|
let results = crate::app::workflow::engine::execute_primitive(PrimitiveCtx {
|
||||||
&wf.script,
|
primitive: &wf.script,
|
||||||
&HashMap::new(),
|
args: &HashMap::new(),
|
||||||
max_concurrency,
|
concurrency_cap: max_concurrency,
|
||||||
true,
|
continue_on_error: true,
|
||||||
&no_abort,
|
abort_flag: &no_abort,
|
||||||
live.as_ref(),
|
live: live.as_ref(),
|
||||||
&ctx.session_dir,
|
session_dir: &ctx.session_dir,
|
||||||
&ctx.workspaces,
|
workspaces: &ctx.workspaces,
|
||||||
&findings,
|
findings: &findings,
|
||||||
None, // no per-agent timeout for spawn_agents
|
timeout_ms: None,
|
||||||
)?;
|
})?;
|
||||||
Ok(format_results(&results, "parallel"))
|
Ok(format_results(&results, "parallel"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,18 +211,18 @@ impl Tool for SpawnPipeline {
|
|||||||
// other concurrent spawn_agents / spawn_pipeline / workflow_run.
|
// other concurrent spawn_agents / spawn_pipeline / workflow_run.
|
||||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
||||||
let results = crate::app::workflow::engine::execute_primitive(
|
let results = crate::app::workflow::engine::execute_primitive(PrimitiveCtx {
|
||||||
&wf.script,
|
primitive: &wf.script,
|
||||||
&HashMap::new(),
|
args: &HashMap::new(),
|
||||||
1,
|
concurrency_cap: 1,
|
||||||
false,
|
continue_on_error: false,
|
||||||
&no_abort,
|
abort_flag: &no_abort,
|
||||||
live.as_ref(),
|
live: live.as_ref(),
|
||||||
&ctx.session_dir,
|
session_dir: &ctx.session_dir,
|
||||||
&ctx.workspaces,
|
workspaces: &ctx.workspaces,
|
||||||
&findings,
|
findings: &findings,
|
||||||
None, // no per-agent timeout for spawn_pipeline
|
timeout_ms: None,
|
||||||
)?;
|
})?;
|
||||||
Ok(format_results(&results, "pipeline"))
|
Ok(format_results(&results, "pipeline"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! `cd` tool: verify and resolve a workspace-relative directory path.
|
//! `cd` tool: verify and resolve a workspace-relative directory path.
|
||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::Result;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
/// Tool that resolves a workspace-relative path and reports whether it exists and is a dir.
|
/// Tool that resolves a workspace-relative path and reports whether it exists and is a dir.
|
||||||
@@ -40,12 +40,9 @@ impl Tool for Cd {
|
|||||||
/// Return: canonical path on success; explicit "does not exist" / "not a directory"
|
/// Return: canonical path on success; explicit "does not exist" / "not a directory"
|
||||||
/// message (still `Ok`) so the model can react without treating it as an error.
|
/// message (still `Ok`) so the model can react without treating it as an error.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let rel = args
|
let rel = crate::tool::arg_str(args, "path")?;
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
|
||||||
|
|
||||||
let path = super::super::resolve_path(&ctx.workspaces, rel)?;
|
let path = super::super::resolve_path(&ctx.workspaces, &rel)?;
|
||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(format!(
|
return Ok(format!(
|
||||||
|
|||||||
@@ -51,12 +51,9 @@ impl Tool for DirCacheUpdate {
|
|||||||
/// Return: a confirmation string with the entry count, or an error if
|
/// Return: a confirmation string with the entry count, or an error if
|
||||||
/// the `path` argument is missing or the temp runtime fails to start.
|
/// the `path` argument is missing or the temp runtime fails to start.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let rel = args
|
let rel = crate::tool::arg_str(args, "path")?;
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
|
||||||
|
|
||||||
let path = super::super::resolve_path(&ctx.workspaces, rel)?;
|
let path = super::super::resolve_path(&ctx.workspaces, &rel)?;
|
||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(format!(
|
return Ok(format!(
|
||||||
|
|||||||
@@ -52,12 +52,9 @@ impl Tool for DirList {
|
|||||||
/// Return: header + newline-joined entry names, or an error if the
|
/// Return: header + newline-joined entry names, or an error if the
|
||||||
/// `path` argument is missing or `read_dir` fails outright.
|
/// `path` argument is missing or `read_dir` fails outright.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let rel = args
|
let rel = crate::tool::arg_str(args, "path")?;
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
|
||||||
|
|
||||||
let path = super::super::resolve_path(&ctx.workspaces, rel)?;
|
let path = super::super::resolve_path(&ctx.workspaces, &rel)?;
|
||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(format!(
|
return Ok(format!(
|
||||||
|
|||||||
@@ -51,10 +51,7 @@ impl Tool for Todowrite {
|
|||||||
/// Return: confirmation string echoing the added task, or an error
|
/// Return: confirmation string echoing the added task, or an error
|
||||||
/// if the `task` argument is missing or the file can't be opened/written.
|
/// if the `task` argument is missing or the file can't be opened/written.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let task = args
|
let task = crate::tool::arg_str(args, "task")?;
|
||||||
.get("task")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: task"))?;
|
|
||||||
|
|
||||||
let path: PathBuf = ctx.session_dir.join("todo.md");
|
let path: PathBuf = ctx.session_dir.join("todo.md");
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
|||||||
@@ -58,13 +58,10 @@ impl Tool for WorkflowRun {
|
|||||||
/// Return: the workflow engine's output string, or an error if the
|
/// Return: the workflow engine's output string, or an error if the
|
||||||
/// script argument is missing or fails to parse as JSON.
|
/// script argument is missing or fails to parse as JSON.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let script_str = args
|
let script_str = crate::tool::arg_str(args, "script")?;
|
||||||
.get("script")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: script"))?;
|
|
||||||
|
|
||||||
let workflow_script: crate::app::workflow::script::WorkflowScript =
|
let workflow_script: crate::app::workflow::script::WorkflowScript =
|
||||||
serde_json::from_str(script_str)
|
serde_json::from_str(&script_str)
|
||||||
.map_err(|e| anyhow!("failed to parse workflow script: {e}"))?;
|
.map_err(|e| anyhow!("failed to parse workflow script: {e}"))?;
|
||||||
|
|
||||||
let workflow_args: std::collections::HashMap<String, String> = args
|
let workflow_args: std::collections::HashMap<String, String> = args
|
||||||
@@ -125,10 +122,7 @@ impl Tool for NoteFinding {
|
|||||||
/// Return: confirmation string containing up to the first 80 chars
|
/// Return: confirmation string containing up to the first 80 chars
|
||||||
/// of the recorded text.
|
/// of the recorded text.
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let text = args
|
let text = crate::tool::arg_str(args, "text")?;
|
||||||
.get("text")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: text"))?;
|
|
||||||
|
|
||||||
if let Some(ref findings) = ctx.workflow_findings {
|
if let Some(ref findings) = ctx.workflow_findings {
|
||||||
if let Ok(mut f) = findings.lock() {
|
if let Ok(mut f) = findings.lock() {
|
||||||
@@ -211,10 +205,7 @@ impl Tool for HiveMind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let request = args
|
let request = crate::tool::arg_str(args, "request")?;
|
||||||
.get("request")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| anyhow!("missing required argument: request"))?;
|
|
||||||
|
|
||||||
let cycles_value = args
|
let cycles_value = args
|
||||||
.get("cycles")
|
.get("cycles")
|
||||||
@@ -228,7 +219,7 @@ impl Tool for HiveMind {
|
|||||||
// itself (guaranteed, even if synthesis fails) — do not write it
|
// itself (guaranteed, even if synthesis fails) — do not write it
|
||||||
// again here.
|
// again here.
|
||||||
let (consensus, _reports) = crate::app::workflow::hive_mind::run_hive_mind(
|
let (consensus, _reports) = crate::app::workflow::hive_mind::run_hive_mind(
|
||||||
request,
|
&request,
|
||||||
&plan,
|
&plan,
|
||||||
&ctx.session_dir,
|
&ctx.session_dir,
|
||||||
&ctx.workspaces,
|
&ctx.workspaces,
|
||||||
|
|||||||
@@ -15,15 +15,17 @@
|
|||||||
//! are the exception: every line gets its `" "` prefix independently
|
//! are the exception: every line gets its `" "` prefix independently
|
||||||
//! and consistently, so there's no first-line-only misalignment there.
|
//! and consistently, so there's no first-line-only misalignment there.
|
||||||
|
|
||||||
|
use super::theme::Theme;
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use super::theme::Theme;
|
|
||||||
|
|
||||||
/// Apply the "tool output" dim/italic style, or pass `style` through
|
/// Apply the "tool output" dim/italic style, or pass `style` through
|
||||||
/// unchanged, depending on `dim`.
|
/// unchanged, depending on `dim`.
|
||||||
fn apply_dim(style: Style, dim: bool) -> Style {
|
fn apply_dim(style: Style, dim: bool) -> Style {
|
||||||
if dim {
|
if dim {
|
||||||
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)
|
Style::default()
|
||||||
|
.fg(Theme::TEXT_DIM)
|
||||||
|
.add_modifier(Modifier::ITALIC)
|
||||||
} else {
|
} else {
|
||||||
style
|
style
|
||||||
}
|
}
|
||||||
@@ -59,7 +61,6 @@ fn diff_line_style(line: &str) -> Option<Style> {
|
|||||||
///
|
///
|
||||||
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
|
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
|
||||||
/// turns it back into `Line`s for the Paragraph widget.
|
/// turns it back into `Line`s for the Paragraph widget.
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
|
pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
let mut options = pulldown_cmark::Options::empty();
|
let mut options = pulldown_cmark::Options::empty();
|
||||||
@@ -86,18 +87,15 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
|
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
|
||||||
);
|
);
|
||||||
// Code block top bar
|
// Code block top bar
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled("\n", Style::default()));
|
||||||
"\n",
|
|
||||||
Style::default(),
|
|
||||||
));
|
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
" ┌─ code ",
|
" ┌─ code ",
|
||||||
apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
|
apply_dim(
|
||||||
));
|
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
|
||||||
spans.push(Span::styled(
|
dim,
|
||||||
"\n",
|
),
|
||||||
Style::default(),
|
|
||||||
));
|
));
|
||||||
|
spans.push(Span::styled("\n", Style::default()));
|
||||||
}
|
}
|
||||||
pulldown_cmark::Tag::Heading { level, .. } => {
|
pulldown_cmark::Tag::Heading { level, .. } => {
|
||||||
in_heading = true;
|
in_heading = true;
|
||||||
@@ -125,7 +123,12 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
// After the link text ends, we'll add the URL
|
// After the link text ends, we'll add the URL
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!("]({dest_url})"),
|
format!("]({dest_url})"),
|
||||||
apply_dim(Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), dim),
|
apply_dim(
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::TEXT_MUTED)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
dim,
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::Tag::BlockQuote(_) => {
|
pulldown_cmark::Tag::BlockQuote(_) => {
|
||||||
@@ -155,7 +158,10 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
// Code block bottom bar
|
// Code block bottom bar
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"\n └─\n",
|
"\n └─\n",
|
||||||
apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
|
apply_dim(
|
||||||
|
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
|
||||||
|
dim,
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::TagEnd::Heading(_) => {
|
pulldown_cmark::TagEnd::Heading(_) => {
|
||||||
@@ -186,7 +192,8 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
for row in &table_rows {
|
for row in &table_rows {
|
||||||
for (i, cell) in row.iter().enumerate() {
|
for (i, cell) in row.iter().enumerate() {
|
||||||
if i < cols_count {
|
if i < cols_count {
|
||||||
let cell_width: usize = cell.iter().map(|s| s.content.chars().count()).sum();
|
let cell_width: usize =
|
||||||
|
cell.iter().map(|s| s.content.chars().count()).sum();
|
||||||
if cell_width > col_widths[i] {
|
if cell_width > col_widths[i] {
|
||||||
col_widths[i] = cell_width;
|
col_widths[i] = cell_width;
|
||||||
}
|
}
|
||||||
@@ -194,15 +201,26 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let effective_width = if width > 0 { (width as usize).saturating_sub(2) } else { 0 };
|
let effective_width = if width > 0 {
|
||||||
|
(width as usize).saturating_sub(2)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
let border_overhead = cols_count * 3 + 4;
|
let border_overhead = cols_count * 3 + 4;
|
||||||
let available_width = effective_width.saturating_sub(border_overhead);
|
let available_width = effective_width.saturating_sub(border_overhead);
|
||||||
let mut total_width: usize = col_widths.iter().sum();
|
let mut total_width: usize = col_widths.iter().sum();
|
||||||
|
|
||||||
if width > 0 && total_width > available_width && available_width > 0 {
|
if width > 0 && total_width > available_width && available_width > 0 {
|
||||||
while total_width > available_width {
|
while total_width > available_width {
|
||||||
let max_idx = col_widths.iter().enumerate().max_by_key(|&(_, &w)| w).map(|(i, _)| i).unwrap();
|
let max_idx = col_widths
|
||||||
if col_widths[max_idx] <= 3 { break; }
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.max_by_key(|&(_, &w)| w)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap();
|
||||||
|
if col_widths[max_idx] <= 3 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
col_widths[max_idx] -= 1;
|
col_widths[max_idx] -= 1;
|
||||||
total_width -= 1;
|
total_width -= 1;
|
||||||
}
|
}
|
||||||
@@ -217,12 +235,17 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_height = cell_lines.iter().map(std::vec::Vec::len).max().unwrap_or(1);
|
let max_height =
|
||||||
|
cell_lines.iter().map(std::vec::Vec::len).max().unwrap_or(1);
|
||||||
|
|
||||||
for y in 0..max_height {
|
for y in 0..max_height {
|
||||||
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
spans.push(Span::styled(
|
||||||
|
" | ",
|
||||||
|
apply_dim(Style::default().fg(Theme::BORDER), dim),
|
||||||
|
));
|
||||||
for (i, cl) in cell_lines.iter().enumerate() {
|
for (i, cl) in cell_lines.iter().enumerate() {
|
||||||
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
|
let line_spans =
|
||||||
|
if y < cl.len() { &cl[y] } else { [].as_slice() };
|
||||||
let mut line_width = 0;
|
let mut line_width = 0;
|
||||||
for span in line_spans {
|
for span in line_spans {
|
||||||
line_width += span.content.chars().count();
|
line_width += span.content.chars().count();
|
||||||
@@ -230,15 +253,24 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
}
|
}
|
||||||
let pad = col_widths[i].saturating_sub(line_width);
|
let pad = col_widths[i].saturating_sub(line_width);
|
||||||
spans.push(Span::raw(" ".repeat(pad)));
|
spans.push(Span::raw(" ".repeat(pad)));
|
||||||
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
spans.push(Span::styled(
|
||||||
|
" | ",
|
||||||
|
apply_dim(Style::default().fg(Theme::BORDER), dim),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
spans.push(Span::raw("\n"));
|
spans.push(Span::raw("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if r == 0 {
|
if r == 0 {
|
||||||
spans.push(Span::styled(" |", apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
spans.push(Span::styled(
|
||||||
|
" |",
|
||||||
|
apply_dim(Style::default().fg(Theme::BORDER), dim),
|
||||||
|
));
|
||||||
for w in &col_widths {
|
for w in &col_widths {
|
||||||
spans.push(Span::styled(format!("{}-|", "-".repeat(*w + 2)), apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
spans.push(Span::styled(
|
||||||
|
format!("{}-|", "-".repeat(*w + 2)),
|
||||||
|
apply_dim(Style::default().fg(Theme::BORDER), dim),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
spans.push(Span::raw("\n"));
|
spans.push(Span::raw("\n"));
|
||||||
}
|
}
|
||||||
@@ -259,15 +291,19 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let style = diff_line_style(line)
|
let style = diff_line_style(line).unwrap_or_else(|| {
|
||||||
.unwrap_or_else(|| Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG));
|
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG)
|
||||||
|
});
|
||||||
spans.push(Span::styled(format!(" {line}"), style));
|
spans.push(Span::styled(format!(" {line}"), style));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let indented = format!(" {}", s.replace('\n', "\n "));
|
let indented = format!(" {}", s.replace('\n', "\n "));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
indented,
|
indented,
|
||||||
apply_dim(Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), dim),
|
apply_dim(
|
||||||
|
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
|
||||||
|
dim,
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} else if in_heading {
|
} else if in_heading {
|
||||||
@@ -327,16 +363,24 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
let mut tokens = Vec::new();
|
let mut tokens = Vec::new();
|
||||||
for c in text.chars() {
|
for c in text.chars() {
|
||||||
if c == ' ' {
|
if c == ' ' {
|
||||||
if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
|
if !current.is_empty() {
|
||||||
|
tokens.push(current.clone());
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
tokens.push(" ".to_string());
|
tokens.push(" ".to_string());
|
||||||
} else if c == '\n' {
|
} else if c == '\n' {
|
||||||
if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
|
if !current.is_empty() {
|
||||||
|
tokens.push(current.clone());
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
tokens.push("\n".to_string());
|
tokens.push("\n".to_string());
|
||||||
} else {
|
} else {
|
||||||
current.push(c);
|
current.push(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !current.is_empty() { tokens.push(current); }
|
if !current.is_empty() {
|
||||||
|
tokens.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
for token in tokens {
|
for token in tokens {
|
||||||
if token == "\n" {
|
if token == "\n" {
|
||||||
@@ -388,13 +432,18 @@ fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<
|
|||||||
|
|
||||||
for c in text.chars() {
|
for c in text.chars() {
|
||||||
if c == ' ' {
|
if c == ' ' {
|
||||||
if !current_word.is_empty() { tokens.push(current_word.clone()); current_word.clear(); }
|
if !current_word.is_empty() {
|
||||||
|
tokens.push(current_word.clone());
|
||||||
|
current_word.clear();
|
||||||
|
}
|
||||||
tokens.push(" ".to_string());
|
tokens.push(" ".to_string());
|
||||||
} else {
|
} else {
|
||||||
current_word.push(c);
|
current_word.push(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !current_word.is_empty() { tokens.push(current_word); }
|
if !current_word.is_empty() {
|
||||||
|
tokens.push(current_word);
|
||||||
|
}
|
||||||
|
|
||||||
for token in tokens {
|
for token in tokens {
|
||||||
if token == " " {
|
if token == " " {
|
||||||
@@ -447,7 +496,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dim_true_plain_text_is_dim_italic() {
|
fn dim_true_plain_text_is_dim_italic() {
|
||||||
let spans = render_markdown("hello", 0, true);
|
let spans = render_markdown("hello", 0, true);
|
||||||
let expected = Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC);
|
let expected = Style::default()
|
||||||
|
.fg(Theme::TEXT_DIM)
|
||||||
|
.add_modifier(Modifier::ITALIC);
|
||||||
assert_eq!(spans[0].style, expected);
|
assert_eq!(spans[0].style, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,11 +506,20 @@ mod tests {
|
|||||||
fn dim_true_diff_lines_keep_their_own_color() {
|
fn dim_true_diff_lines_keep_their_own_color() {
|
||||||
let md = "```diff\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context line\n```";
|
let md = "```diff\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context line\n```";
|
||||||
let spans = render_markdown(md, 0, true);
|
let spans = render_markdown(md, 0, true);
|
||||||
let plus_span = spans.iter().find(|s| s.content.contains("+new line")).expect("plus span present");
|
let plus_span = spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.content.contains("+new line"))
|
||||||
|
.expect("plus span present");
|
||||||
assert_eq!(plus_span.style.fg, Some(Theme::SUCCESS));
|
assert_eq!(plus_span.style.fg, Some(Theme::SUCCESS));
|
||||||
let minus_span = spans.iter().find(|s| s.content.contains("-old line")).expect("minus span present");
|
let minus_span = spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.content.contains("-old line"))
|
||||||
|
.expect("minus span present");
|
||||||
assert_eq!(minus_span.style.fg, Some(Theme::ERROR));
|
assert_eq!(minus_span.style.fg, Some(Theme::ERROR));
|
||||||
let hunk_span = spans.iter().find(|s| s.content.contains("@@")).expect("hunk header span present");
|
let hunk_span = spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.content.contains("@@"))
|
||||||
|
.expect("hunk header span present");
|
||||||
assert_eq!(hunk_span.style.fg, Some(Theme::INFO));
|
assert_eq!(hunk_span.style.fg, Some(Theme::INFO));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +527,15 @@ mod tests {
|
|||||||
fn dim_true_non_diff_code_block_is_dimmed() {
|
fn dim_true_non_diff_code_block_is_dimmed() {
|
||||||
let md = "```rust\nfn main() {}\n```";
|
let md = "```rust\nfn main() {}\n```";
|
||||||
let spans = render_markdown(md, 0, true);
|
let spans = render_markdown(md, 0, true);
|
||||||
let code_span = spans.iter().find(|s| s.content.contains("fn main")).expect("code span present");
|
let code_span = spans
|
||||||
assert_eq!(code_span.style, Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC));
|
.iter()
|
||||||
|
.find(|s| s.content.contains("fn main"))
|
||||||
|
.expect("code span present");
|
||||||
|
assert_eq!(
|
||||||
|
code_span.style,
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::TEXT_DIM)
|
||||||
|
.add_modifier(Modifier::ITALIC)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_sign_loss,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::cast_possible_wrap
|
||||||
|
)]
|
||||||
//! Top-level TUI render pipeline: layouts the terminal into chat / input
|
//! Top-level TUI render pipeline: layouts the terminal into chat / input
|
||||||
//! / status regions, dispatches overlay rendering with glassmorphism-style
|
//! / status regions, dispatches overlay rendering with glassmorphism-style
|
||||||
//! centered panels, and floats toast notifications over the top-right corner.
|
//! centered panels, and floats toast notifications over the top-right corner.
|
||||||
@@ -15,7 +20,7 @@ pub mod theme;
|
|||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Style, Modifier};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -40,10 +45,7 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
|||||||
let sidebar_width = if has_workflow { 48 } else { 30 };
|
let sidebar_width = if has_workflow { 48 } else { 30 };
|
||||||
let h_chunks = Layout::default()
|
let h_chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([Constraint::Min(40), Constraint::Length(sidebar_width)])
|
||||||
Constraint::Min(40),
|
|
||||||
Constraint::Length(sidebar_width),
|
|
||||||
])
|
|
||||||
.split(area);
|
.split(area);
|
||||||
(h_chunks[0], Some(h_chunks[1]))
|
(h_chunks[0], Some(h_chunks[1]))
|
||||||
} else {
|
} else {
|
||||||
@@ -91,11 +93,7 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
|||||||
// Panel helpers
|
// Panel helpers
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn render_main_panel(
|
fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
state: &crate::app::state::rest::AppStateRest,
|
|
||||||
) {
|
|
||||||
chat::draw_chat(frame, area, state);
|
chat::draw_chat(frame, area, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +107,6 @@ fn render_main_panel(
|
|||||||
/// - A top accent border strip (colored per variant)
|
/// - A top accent border strip (colored per variant)
|
||||||
/// - A title line with icon
|
/// - A title line with icon
|
||||||
/// - Content area with proper spacing
|
/// - Content area with proper spacing
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
fn render_overlay(
|
fn render_overlay(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -132,7 +129,12 @@ fn render_overlay(
|
|||||||
// ── Help ──────────────────────────────────────────────────────
|
// ── Help ──────────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Help => {
|
crate::app::state::types::Overlay::Help => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Help ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Help ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::INFO)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::INFO));
|
.border_style(Style::default().fg(Theme::INFO));
|
||||||
let content = crate::resources::HELP_TEXT;
|
let content = crate::resources::HELP_TEXT;
|
||||||
let paragraph = Paragraph::new(content)
|
let paragraph = Paragraph::new(content)
|
||||||
@@ -145,7 +147,12 @@ fn render_overlay(
|
|||||||
// ── Settings ──────────────────────────────────────────────────
|
// ── Settings ──────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Settings => {
|
crate::app::state::types::Overlay::Settings => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Settings ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Settings ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::PRIMARY)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::PRIMARY));
|
.border_style(Style::default().fg(Theme::PRIMARY));
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
@@ -157,13 +164,23 @@ fn render_overlay(
|
|||||||
Style::default().fg(Theme::TEXT),
|
Style::default().fg(Theme::TEXT),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" Max tokens: {}",
|
format!(
|
||||||
state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())),
|
" Max tokens: {}",
|
||||||
|
state
|
||||||
|
.settings
|
||||||
|
.max_tokens
|
||||||
|
.map_or_else(|| "auto".to_string(), |v| v.to_string())
|
||||||
|
),
|
||||||
Style::default().fg(Theme::TEXT),
|
Style::default().fg(Theme::TEXT),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" Temperature: {}",
|
format!(
|
||||||
state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))),
|
" Temperature: {}",
|
||||||
|
state
|
||||||
|
.settings
|
||||||
|
.temperature
|
||||||
|
.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))
|
||||||
|
),
|
||||||
Style::default().fg(Theme::TEXT),
|
Style::default().fg(Theme::TEXT),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
@@ -182,24 +199,39 @@ fn render_overlay(
|
|||||||
// ── Bash ──────────────────────────────────────────────────────
|
// ── Bash ──────────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Bash => {
|
crate::app::state::types::Overlay::Bash => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Bash Jobs ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Bash Jobs ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::ACCENT_ORANGE)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
|
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
|
||||||
let lines: Vec<Line> = state.session_runtime.as_ref().map(|r| {
|
let lines: Vec<Line> = state
|
||||||
r.bash_jobs.iter().map(|job| {
|
.session_runtime
|
||||||
Line::from(Span::styled(
|
.as_ref()
|
||||||
format!(" [{}] {} — {}",
|
.map(|r| {
|
||||||
job.id, job.command,
|
r.bash_jobs
|
||||||
if job.running { "running" } else { "done" },
|
.iter()
|
||||||
),
|
.map(|job| {
|
||||||
Style::default().fg(Theme::TEXT),
|
Line::from(Span::styled(
|
||||||
))
|
format!(
|
||||||
}).collect()
|
" [{}] {} — {}",
|
||||||
}).unwrap_or_default();
|
job.id,
|
||||||
|
job.command,
|
||||||
|
if job.running { "running" } else { "done" },
|
||||||
|
),
|
||||||
|
Style::default().fg(Theme::TEXT),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
let paragraph = if lines.is_empty() {
|
let paragraph = if lines.is_empty() {
|
||||||
Paragraph::new(Line::from(Span::styled(
|
Paragraph::new(Line::from(Span::styled(
|
||||||
" No active bash jobs.",
|
" No active bash jobs.",
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
))).block(block)
|
)))
|
||||||
|
.block(block)
|
||||||
} else {
|
} else {
|
||||||
Paragraph::new(lines).block(block)
|
Paragraph::new(lines).block(block)
|
||||||
};
|
};
|
||||||
@@ -209,12 +241,19 @@ fn render_overlay(
|
|||||||
// ── Quit Confirm ──────────────────────────────────────────────
|
// ── Quit Confirm ──────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::QuitConfirm => {
|
crate::app::state::types::Overlay::QuitConfirm => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Quit ", Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Quit ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::ERROR)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::ERROR));
|
.border_style(Style::default().fg(Theme::ERROR));
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
" Are you sure you want to quit?",
|
" Are you sure you want to quit?",
|
||||||
Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Theme::ERROR)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::raw("")),
|
Line::from(Span::raw("")),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
@@ -226,12 +265,15 @@ fn render_overlay(
|
|||||||
frame.render_widget(paragraph, overlay_area);
|
frame.render_widget(paragraph, overlay_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Key Input ─────────────────────────────────────────────────
|
// ── Key Input ─────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::KeyInput => {
|
crate::app::state::types::Overlay::KeyInput => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" API Key ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" API Key ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::WARNING)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::WARNING));
|
.border_style(Style::default().fg(Theme::WARNING));
|
||||||
let input_text = &state.input.buffer;
|
let input_text = &state.input.buffer;
|
||||||
let display = if input_text.is_empty() {
|
let display = if input_text.is_empty() {
|
||||||
@@ -258,7 +300,12 @@ fn render_overlay(
|
|||||||
Line::from(Span::raw("")),
|
Line::from(Span::raw("")),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(" Key: ", Style::default().fg(Theme::TEXT_DIM)),
|
Span::styled(" Key: ", Style::default().fg(Theme::TEXT_DIM)),
|
||||||
Span::styled(masked, Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)),
|
Span::styled(
|
||||||
|
masked,
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::TEXT)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
let paragraph = Paragraph::new(lines).block(block);
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
@@ -268,12 +315,19 @@ fn render_overlay(
|
|||||||
// ── Editor ────────────────────────────────────────────────────
|
// ── Editor ────────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Editor => {
|
crate::app::state::types::Overlay::Editor => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Editor ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Editor ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::PRIMARY)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::PRIMARY));
|
.border_style(Style::default().fg(Theme::PRIMARY));
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
" Editor Mode — Ctrl+S save, Esc dismiss",
|
" Editor Mode — Ctrl+S save, Esc dismiss",
|
||||||
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
|
Style::default()
|
||||||
|
.fg(Theme::TEXT_MUTED)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::raw("")),
|
Line::from(Span::raw("")),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
@@ -286,7 +340,11 @@ fn render_overlay(
|
|||||||
)),
|
)),
|
||||||
Line::from(Span::raw("")),
|
Line::from(Span::raw("")),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" Cursor: pos {} / {}", state.input.cursor, state.input.buffer.len()),
|
format!(
|
||||||
|
" Cursor: pos {} / {}",
|
||||||
|
state.input.cursor,
|
||||||
|
state.input.buffer.len()
|
||||||
|
),
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
@@ -297,7 +355,12 @@ fn render_overlay(
|
|||||||
// ── Effort ────────────────────────────────────────────────────
|
// ── Effort ────────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Effort => {
|
crate::app::state::types::Overlay::Effort => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Effort Level ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Effort Level ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::ACCENT_PURPLE)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
||||||
let levels = crate::app::mode::effort::EFFORT_LEVELS;
|
let levels = crate::app::mode::effort::EFFORT_LEVELS;
|
||||||
let current_idx = crate::app::mode::effort::current_effort(state);
|
let current_idx = crate::app::mode::effort::current_effort(state);
|
||||||
@@ -317,7 +380,9 @@ fn render_overlay(
|
|||||||
format!(" {l}")
|
format!(" {l}")
|
||||||
},
|
},
|
||||||
if selected {
|
if selected {
|
||||||
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Theme::HIGHLIGHT)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Theme::TEXT)
|
Style::default().fg(Theme::TEXT)
|
||||||
},
|
},
|
||||||
@@ -330,12 +395,19 @@ fn render_overlay(
|
|||||||
// ── MCP ───────────────────────────────────────────────────────
|
// ── MCP ───────────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Mcp => {
|
crate::app::state::types::Overlay::Mcp => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" MCP Servers ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" MCP Servers ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::INFO)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::INFO));
|
.border_style(Style::default().fg(Theme::INFO));
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
" MCP Server Management",
|
" MCP Server Management",
|
||||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Theme::TEXT)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::raw("")),
|
Line::from(Span::raw("")),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
@@ -359,7 +431,12 @@ fn render_overlay(
|
|||||||
// ── Todo ──────────────────────────────────────────────────────
|
// ── Todo ──────────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Todo => {
|
crate::app::state::types::Overlay::Todo => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Tasks ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::ACCENT_PURPLE)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
||||||
let content = if state.misc.todo_content.is_empty() {
|
let content = if state.misc.todo_content.is_empty() {
|
||||||
" No tasks yet."
|
" No tasks yet."
|
||||||
@@ -375,7 +452,12 @@ fn render_overlay(
|
|||||||
// ── Rewind ────────────────────────────────────────────────────
|
// ── Rewind ────────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Rewind => {
|
crate::app::state::types::Overlay::Rewind => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Rewind ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Rewind ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::ACCENT_ORANGE)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
|
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
|
||||||
let mut lines: Vec<Line> = vec![
|
let mut lines: Vec<Line> = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
@@ -391,7 +473,11 @@ fn render_overlay(
|
|||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)));
|
)));
|
||||||
} else {
|
} else {
|
||||||
let start = if messages.len() > 8 { messages.len() - 8 } else { 0 };
|
let start = if messages.len() > 8 {
|
||||||
|
messages.len() - 8
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
for msg in &messages[start..] {
|
for msg in &messages[start..] {
|
||||||
let role_str = match msg.role {
|
let role_str = match msg.role {
|
||||||
crate::dto::chat::message::Role::User => "User",
|
crate::dto::chat::message::Role::User => "User",
|
||||||
@@ -426,20 +512,27 @@ fn render_overlay(
|
|||||||
crate::app::state::types::Overlay::Learning => {
|
crate::app::state::types::Overlay::Learning => {
|
||||||
let h_chunks = Layout::default()
|
let h_chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||||
Constraint::Percentage(40),
|
|
||||||
Constraint::Percentage(60),
|
|
||||||
])
|
|
||||||
.split(overlay_area);
|
.split(overlay_area);
|
||||||
|
|
||||||
let left_block = Block::default()
|
let left_block = Block::default()
|
||||||
.title(Span::styled(" Lessons ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Lessons ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::ACCENT_PURPLE)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Theme::BORDER))
|
.border_style(Style::default().fg(Theme::BORDER))
|
||||||
.style(Style::default().bg(Theme::BG));
|
.style(Style::default().bg(Theme::BG));
|
||||||
|
|
||||||
let right_block = Block::default()
|
let right_block = Block::default()
|
||||||
.title(Span::styled(" Details ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Details ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::INFO)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Theme::BORDER))
|
.border_style(Style::default().fg(Theme::BORDER))
|
||||||
.style(Style::default().bg(Theme::BG));
|
.style(Style::default().bg(Theme::BG));
|
||||||
@@ -456,23 +549,33 @@ fn render_overlay(
|
|||||||
let is_selected = i == state.misc.selected_index;
|
let is_selected = i == state.misc.selected_index;
|
||||||
let prefix = if is_selected { " ▸ " } else { " " };
|
let prefix = if is_selected { " ▸ " } else { " " };
|
||||||
let (label, style) = match item {
|
let (label, style) = match item {
|
||||||
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
|
crate::app::mode::learning::LearningItem::Pending { name, .. } => (
|
||||||
(
|
format!("{prefix}[Pending] {name}"),
|
||||||
format!("{prefix}[Pending] {name}"),
|
if is_selected {
|
||||||
if is_selected {
|
Style::default()
|
||||||
Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT_DIM)
|
.fg(Theme::WARNING)
|
||||||
.add_modifier(Modifier::BOLD)
|
.bg(Theme::HIGHLIGHT_DIM)
|
||||||
} else {
|
.add_modifier(Modifier::BOLD)
|
||||||
Style::default().fg(Theme::WARNING)
|
} else {
|
||||||
},
|
Style::default().fg(Theme::WARNING)
|
||||||
)
|
},
|
||||||
}
|
),
|
||||||
crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => {
|
crate::app::mode::learning::LearningItem::Stored {
|
||||||
let status = if lifecycle == "stale" { "Stale" } else { "Active" };
|
name,
|
||||||
|
lifecycle,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let status = if lifecycle == "stale" {
|
||||||
|
"Stale"
|
||||||
|
} else {
|
||||||
|
"Active"
|
||||||
|
};
|
||||||
(
|
(
|
||||||
format!("{prefix}[{status}] {name}"),
|
format!("{prefix}[{status}] {name}"),
|
||||||
if is_selected {
|
if is_selected {
|
||||||
Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM)
|
Style::default()
|
||||||
|
.fg(Theme::TEXT)
|
||||||
|
.bg(Theme::HIGHLIGHT_DIM)
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Theme::TEXT)
|
Style::default().fg(Theme::TEXT)
|
||||||
@@ -507,14 +610,20 @@ fn render_overlay(
|
|||||||
if let Some(item) = items.get(selected) {
|
if let Some(item) = items.get(selected) {
|
||||||
match item {
|
match item {
|
||||||
crate::app::mode::learning::LearningItem::Pending {
|
crate::app::mode::learning::LearningItem::Pending {
|
||||||
name, content, scope, confidence,
|
name,
|
||||||
|
content,
|
||||||
|
scope,
|
||||||
|
confidence,
|
||||||
} => {
|
} => {
|
||||||
right_lines.push(Line::from(Span::styled(
|
right_lines.push(Line::from(Span::styled(
|
||||||
" Name:", Style::default().fg(Theme::TEXT_DIM),
|
" Name:",
|
||||||
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)));
|
)));
|
||||||
right_lines.push(Line::from(Span::styled(
|
right_lines.push(Line::from(Span::styled(
|
||||||
format!(" {name}"),
|
format!(" {name}"),
|
||||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Theme::TEXT)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)));
|
)));
|
||||||
right_lines.push(Line::from(Span::raw("")));
|
right_lines.push(Line::from(Span::raw("")));
|
||||||
right_lines.push(Line::from(Span::styled(
|
right_lines.push(Line::from(Span::styled(
|
||||||
@@ -531,7 +640,8 @@ fn render_overlay(
|
|||||||
)));
|
)));
|
||||||
right_lines.push(Line::from(Span::raw("")));
|
right_lines.push(Line::from(Span::raw("")));
|
||||||
right_lines.push(Line::from(Span::styled(
|
right_lines.push(Line::from(Span::styled(
|
||||||
" Content:", Style::default().fg(Theme::TEXT_DIM),
|
" Content:",
|
||||||
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)));
|
)));
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
right_lines.push(Line::from(Span::styled(
|
right_lines.push(Line::from(Span::styled(
|
||||||
@@ -546,14 +656,21 @@ fn render_overlay(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
crate::app::mode::learning::LearningItem::Stored {
|
crate::app::mode::learning::LearningItem::Stored {
|
||||||
name, content, lifecycle, scope, description,
|
name,
|
||||||
|
content,
|
||||||
|
lifecycle,
|
||||||
|
scope,
|
||||||
|
description,
|
||||||
} => {
|
} => {
|
||||||
right_lines.push(Line::from(Span::styled(
|
right_lines.push(Line::from(Span::styled(
|
||||||
" Name:", Style::default().fg(Theme::TEXT_DIM),
|
" Name:",
|
||||||
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)));
|
)));
|
||||||
right_lines.push(Line::from(Span::styled(
|
right_lines.push(Line::from(Span::styled(
|
||||||
format!(" {name}"),
|
format!(" {name}"),
|
||||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Theme::TEXT)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)));
|
)));
|
||||||
right_lines.push(Line::from(Span::raw("")));
|
right_lines.push(Line::from(Span::raw("")));
|
||||||
let status_color = if lifecycle == "stale" {
|
let status_color = if lifecycle == "stale" {
|
||||||
@@ -575,7 +692,8 @@ fn render_overlay(
|
|||||||
)));
|
)));
|
||||||
right_lines.push(Line::from(Span::raw("")));
|
right_lines.push(Line::from(Span::raw("")));
|
||||||
right_lines.push(Line::from(Span::styled(
|
right_lines.push(Line::from(Span::styled(
|
||||||
" Content:", Style::default().fg(Theme::TEXT_DIM),
|
" Content:",
|
||||||
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)));
|
)));
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
right_lines.push(Line::from(Span::styled(
|
right_lines.push(Line::from(Span::styled(
|
||||||
@@ -605,19 +723,32 @@ fn render_overlay(
|
|||||||
// ── Usage ────────────────────────────────────────────────────
|
// ── Usage ────────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Usage => {
|
crate::app::state::types::Overlay::Usage => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Usage ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Usage ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::INFO)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::INFO));
|
.border_style(Style::default().fg(Theme::INFO));
|
||||||
let runtime = state.session_runtime.as_ref();
|
let runtime = state.session_runtime.as_ref();
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let summary = runtime.map(|r| sidebar::compute_usage_summary(&r.usage, r.session_start, now_ms));
|
let summary =
|
||||||
let (edit_count, lesson_count, review_count, consec_empty) = runtime
|
runtime.map(|r| sidebar::compute_usage_summary(&r.usage, r.session_start, now_ms));
|
||||||
.map_or((0, 0, 0, 0), |r| {
|
let (edit_count, lesson_count, review_count, consec_empty) =
|
||||||
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
|
runtime.map_or((0, 0, 0, 0), |r| {
|
||||||
|
(
|
||||||
|
r.edit_count,
|
||||||
|
r.lesson_count,
|
||||||
|
r.review_count,
|
||||||
|
r.consecutive_empty_reviews,
|
||||||
|
)
|
||||||
});
|
});
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
" Token Usage",
|
" Token Usage",
|
||||||
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Theme::INFO)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::raw("")),
|
Line::from(Span::raw("")),
|
||||||
];
|
];
|
||||||
@@ -632,7 +763,9 @@ fn render_overlay(
|
|||||||
)));
|
)));
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
format!(" Total: {} tokens", s.total_tokens),
|
format!(" Total: {} tokens", s.total_tokens),
|
||||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Theme::TEXT)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)));
|
)));
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
format!(" API calls: {}", s.api_calls),
|
format!(" API calls: {}", s.api_calls),
|
||||||
@@ -647,7 +780,9 @@ fn render_overlay(
|
|||||||
lines.push(Line::from(Span::raw("")));
|
lines.push(Line::from(Span::raw("")));
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
" Activity",
|
" Activity",
|
||||||
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Theme::INFO)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)));
|
)));
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
format!(" Edits: {edit_count}"),
|
format!(" Edits: {edit_count}"),
|
||||||
@@ -662,15 +797,27 @@ fn render_overlay(
|
|||||||
Style::default().fg(Theme::TEXT_MUTED),
|
Style::default().fg(Theme::TEXT_MUTED),
|
||||||
)));
|
)));
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
format!(" Empty reviews: {}",
|
format!(
|
||||||
if consec_empty > 3 { format!("{consec_empty} ⚠") } else { consec_empty.to_string() },
|
" Empty reviews: {}",
|
||||||
|
if consec_empty > 3 {
|
||||||
|
format!("{consec_empty} ⚠")
|
||||||
|
} else {
|
||||||
|
consec_empty.to_string()
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }),
|
Style::default().fg(if consec_empty > 3 {
|
||||||
|
Theme::WARNING
|
||||||
|
} else {
|
||||||
|
Theme::TEXT_DIM
|
||||||
|
}),
|
||||||
)));
|
)));
|
||||||
if let Some(s) = &summary {
|
if let Some(s) = &summary {
|
||||||
lines.push(Line::from(Span::raw("")));
|
lines.push(Line::from(Span::raw("")));
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
format!(" Session: {}h {}m {}s", s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds),
|
format!(
|
||||||
|
" Session: {}h {}m {}s",
|
||||||
|
s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds
|
||||||
|
),
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -681,25 +828,39 @@ fn render_overlay(
|
|||||||
// ── Loading ──────────────────────────────────────────────────
|
// ── Loading ──────────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::Loading => {
|
crate::app::state::types::Overlay::Loading => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Loading ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Loading ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::WARNING)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::WARNING));
|
.border_style(Style::default().fg(Theme::WARNING));
|
||||||
let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
let frame_idx = (state.misc.tick_count as usize) % spinner.len();
|
let frame_idx = (state.misc.tick_count as usize) % spinner.len();
|
||||||
let content = format!(" {} Processing, please wait...", spinner[frame_idx]);
|
let content = format!(" {} Processing, please wait...", spinner[frame_idx]);
|
||||||
let paragraph = Paragraph::new(content)
|
let paragraph = Paragraph::new(content).block(block);
|
||||||
.block(block);
|
|
||||||
frame.render_widget(paragraph, overlay_area);
|
frame.render_widget(paragraph, overlay_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Model Selector ───────────────────────────────────────────
|
// ── Model Selector ───────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::ModelSelector => {
|
crate::app::state::types::Overlay::ModelSelector => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Model Selector ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Model Selector ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::ACCENT_PURPLE)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
||||||
let mut lines: Vec<Line> = vec![
|
let mut lines: Vec<Line> = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" Current: {} / {}", state.settings.provider, state.settings.model),
|
format!(
|
||||||
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
|
" Current: {} / {}",
|
||||||
|
state.settings.provider, state.settings.model
|
||||||
|
),
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::INFO)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::raw("")),
|
Line::from(Span::raw("")),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
@@ -716,7 +877,9 @@ fn render_overlay(
|
|||||||
let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
|
let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
|
||||||
let label = format!("{prefix}{name} ({model_str})");
|
let label = format!("{prefix}{name} ({model_str})");
|
||||||
let style = if is_current {
|
let style = if is_current {
|
||||||
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Theme::HIGHLIGHT)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else if is_selected {
|
} else if is_selected {
|
||||||
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
|
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
|
||||||
} else {
|
} else {
|
||||||
@@ -736,7 +899,12 @@ fn render_overlay(
|
|||||||
// ── Clear Confirm ────────────────────────────────────────────
|
// ── Clear Confirm ────────────────────────────────────────────
|
||||||
crate::app::state::types::Overlay::ClearConfirm => {
|
crate::app::state::types::Overlay::ClearConfirm => {
|
||||||
let block = block
|
let block = block
|
||||||
.title(Span::styled(" Clear Transcript ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
|
.title(Span::styled(
|
||||||
|
" Clear Transcript ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::WARNING)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
.border_style(Style::default().fg(Theme::WARNING));
|
.border_style(Style::default().fg(Theme::WARNING));
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
@@ -763,11 +931,7 @@ fn render_overlay(
|
|||||||
///
|
///
|
||||||
/// The bar has a subtle top border, a `❯` prompt, the user's buffer with
|
/// The bar has a subtle top border, a `❯` prompt, the user's buffer with
|
||||||
/// a highlighted cursor position, and placeholder text when empty.
|
/// a highlighted cursor position, and placeholder text when empty.
|
||||||
fn render_input_bar(
|
fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
state: &crate::app::state::rest::AppStateRest,
|
|
||||||
) {
|
|
||||||
// ── Autocomplete dropdown ────────────────────────────────────────────
|
// ── Autocomplete dropdown ────────────────────────────────────────────
|
||||||
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
|
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
|
||||||
let n = state.input.autocomplete_candidates.len().min(10) as u16;
|
let n = state.input.autocomplete_candidates.len().min(10) as u16;
|
||||||
@@ -793,7 +957,13 @@ fn render_input_bar(
|
|||||||
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
let selected = state.input.autocomplete_idx;
|
let selected = state.input.autocomplete_idx;
|
||||||
for (i, candidate) in state.input.autocomplete_candidates.iter().enumerate().take(10) {
|
for (i, candidate) in state
|
||||||
|
.input
|
||||||
|
.autocomplete_candidates
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.take(10)
|
||||||
|
{
|
||||||
let prefix = if i == selected { " ▸ " } else { " " };
|
let prefix = if i == selected { " ▸ " } else { " " };
|
||||||
let style = if i == selected {
|
let style = if i == selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -821,7 +991,9 @@ fn render_input_bar(
|
|||||||
|
|
||||||
let prompt = Span::styled(
|
let prompt = Span::styled(
|
||||||
" ❯ ",
|
" ❯ ",
|
||||||
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Theme::PRIMARY)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut spans = vec![prompt];
|
let mut spans = vec![prompt];
|
||||||
@@ -829,16 +1001,14 @@ fn render_input_bar(
|
|||||||
if input_text.is_empty() {
|
if input_text.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"Type a message or /command...",
|
"Type a message or /command...",
|
||||||
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC),
|
Style::default()
|
||||||
|
.fg(Theme::TEXT_DIM)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
let (before, after) = input_text.split_at(cursor_pos);
|
let (before, after) = input_text.split_at(cursor_pos);
|
||||||
spans.push(Span::raw(before.to_string()));
|
spans.push(Span::raw(before.to_string()));
|
||||||
let cursor_char = if after.is_empty() {
|
let cursor_char = if after.is_empty() { " " } else { &after[..1] };
|
||||||
" "
|
|
||||||
} else {
|
|
||||||
&after[..1]
|
|
||||||
};
|
|
||||||
// Cursor highlight
|
// Cursor highlight
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
cursor_char,
|
cursor_char,
|
||||||
@@ -868,7 +1038,10 @@ fn render_input_bar(
|
|||||||
/// left border and a subtle background.
|
/// left border and a subtle background.
|
||||||
fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let active: Vec<&crate::app::state::types::Toast> = state.misc.toasts.iter()
|
let active: Vec<&crate::app::state::types::Toast> = state
|
||||||
|
.misc
|
||||||
|
.toasts
|
||||||
|
.iter()
|
||||||
.filter(|t| !t.expired(now_ms))
|
.filter(|t| !t.expired(now_ms))
|
||||||
.collect();
|
.collect();
|
||||||
if active.is_empty() {
|
if active.is_empty() {
|
||||||
@@ -957,7 +1130,9 @@ pub(crate) fn split_for_display<T>(items: &[T], max_visible: usize) -> (&[T], us
|
|||||||
pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> {
|
pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> {
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" +{hidden} more — {command}"),
|
format!(" +{hidden} more — {command}"),
|
||||||
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC),
|
Style::default()
|
||||||
|
.fg(Theme::TEXT_DIM)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,22 @@ impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<
|
|||||||
|
|
||||||
fn save_conversation(&self, conv: &Conversation) -> Result<()> {
|
fn save_conversation(&self, conv: &Conversation) -> Result<()> {
|
||||||
let dir = self.session_dir(&conv.session_id);
|
let dir = self.session_dir(&conv.session_id);
|
||||||
self.repo
|
self.repo.save(&dir, conv).with_context(|| {
|
||||||
.save(&dir, conv)
|
format!(
|
||||||
.with_context(|| format!("failed to save conversation for session '{}'", conv.session_id))
|
"failed to save conversation for session '{}'",
|
||||||
|
conv.session_id
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()> {
|
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()> {
|
||||||
conv.push(msg);
|
conv.push(msg);
|
||||||
let dir = self.session_dir(&conv.session_id);
|
let dir = self.session_dir(&conv.session_id);
|
||||||
self.repo
|
self.repo.save(&dir, conv).with_context(|| {
|
||||||
.save(&dir, conv)
|
format!(
|
||||||
.with_context(|| format!("failed to persist conversation after adding message for session '{}'", conv.session_id))
|
"failed to persist conversation after adding message for session '{}'",
|
||||||
|
conv.session_id
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ pub struct SettingsServiceImpl<S, C> {
|
|||||||
|
|
||||||
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
||||||
/// Create a new service with the given repositories and base directory.
|
/// Create a new service with the given repositories and base directory.
|
||||||
pub fn new(settings_repo: S, app_config_repo: C, base_dir: impl Into<std::path::PathBuf>) -> Self {
|
pub fn new(
|
||||||
|
settings_repo: S,
|
||||||
|
app_config_repo: C,
|
||||||
|
base_dir: impl Into<std::path::PathBuf>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
settings_repo,
|
settings_repo,
|
||||||
app_config_repo,
|
app_config_repo,
|
||||||
@@ -46,7 +50,9 @@ impl<S: SettingsRepository, C: AppConfigRepository> SettingsService for Settings
|
|||||||
|
|
||||||
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> {
|
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> {
|
||||||
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
|
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
|
||||||
app_config.providers.insert(name.to_string(), config.clone());
|
app_config
|
||||||
|
.providers
|
||||||
|
.insert(name.to_string(), config.clone());
|
||||||
self.app_config_repo.save(&self.base_dir, &app_config)
|
self.app_config_repo.save(&self.base_dir, &app_config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,6 @@
|
|||||||
//! Pure Conversation entity — in-memory message history plus system prompt
|
//! Pure Conversation entity
|
||||||
//! and LLM generation parameters.
|
|
||||||
//!
|
//!
|
||||||
//! # Architecture
|
//! Re-exported from zesdex_entities for consistency.
|
||||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
|
||||||
//! responsibilities live in [`ConversationRepository`](super::repository::ConversationRepository).
|
|
||||||
|
|
||||||
#![allow(
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
pub use zesdex_entities::seaorm::common::message::{ChatMessage, Role};
|
pub use zesdex_entities::seaorm::common::message::{ChatMessage, Role};
|
||||||
|
pub use zesdex_entities::seaorm::common::conversation::Conversation;
|
||||||
/// A single conversation's message history and generation settings.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Conversation {
|
|
||||||
pub messages: Vec<ChatMessage>,
|
|
||||||
pub system_prompt: String,
|
|
||||||
pub session_id: String,
|
|
||||||
pub model: String,
|
|
||||||
pub max_tokens: Option<u32>,
|
|
||||||
pub temperature: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
Self {
|
|
||||||
messages: Vec::new(),
|
|
||||||
system_prompt,
|
|
||||||
session_id,
|
|
||||||
model: "anthropic/claude-opus-4-8".to_string(),
|
|
||||||
max_tokens: None,
|
|
||||||
temperature: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append a message to the conversation history.
|
|
||||||
pub fn push(&mut self, msg: ChatMessage) {
|
|
||||||
self.messages.push(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the system prompt and strip any prior `System`-role messages
|
|
||||||
/// from history.
|
|
||||||
pub fn rebuild_system(&mut self, new_prompt: String) {
|
|
||||||
self.system_prompt = new_prompt;
|
|
||||||
self.messages.retain(|m| !matches!(m.role, Role::System));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the message list to send to the LLM API, with the system
|
|
||||||
/// prompt prepended as the first message.
|
|
||||||
pub fn to_api_messages(&self) -> Vec<ChatMessage> {
|
|
||||||
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
|
|
||||||
msgs.push(ChatMessage::system(&self.system_prompt));
|
|
||||||
msgs.extend(self.messages.iter().cloned());
|
|
||||||
msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of messages in the conversation history (excluding the
|
|
||||||
/// synthesized system message).
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.messages.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if the conversation has no messages.
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.messages.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_message_is_the_canonical_entities_type() {
|
|
||||||
let canonical = zesdex_entities::seaorm::common::message::ChatMessage::user("hi");
|
|
||||||
let via_cms: ChatMessage = canonical;
|
|
||||||
assert_eq!(via_cms.content.as_deref(), Some("hi"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ pub trait MemoryRepository {
|
|||||||
/// caller-supplied key (e.g. a tool-call id) within a session.
|
/// caller-supplied key (e.g. a tool-call id) within a session.
|
||||||
pub trait RewindBlobRepository {
|
pub trait RewindBlobRepository {
|
||||||
/// Store (or overwrite) a blob under `blob_key` for this session.
|
/// Store (or overwrite) a blob under `blob_key` for this session.
|
||||||
fn store_blob(&self, session_dir: &Path, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> anyhow::Result<()>;
|
fn store_blob(
|
||||||
|
&self,
|
||||||
|
session_dir: &Path,
|
||||||
|
blob_key: &str,
|
||||||
|
data: &[u8],
|
||||||
|
mime_type: Option<&str>,
|
||||||
|
) -> anyhow::Result<()>;
|
||||||
|
|
||||||
/// Retrieve a blob's bytes by key, or `None` if not found.
|
/// Retrieve a blob's bytes by key, or `None` if not found.
|
||||||
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> anyhow::Result<Option<Vec<u8>>>;
|
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> anyhow::Result<Option<Vec<u8>>>;
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ pub trait SettingsService {
|
|||||||
fn save_settings(&self, settings: &Settings) -> Result<()>;
|
fn save_settings(&self, settings: &Settings) -> Result<()>;
|
||||||
|
|
||||||
/// Update the provider configuration (name and details).
|
/// Update the provider configuration (name and details).
|
||||||
fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig) -> Result<()>;
|
fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig)
|
||||||
|
-> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conversation management use cases.
|
/// Conversation management use cases.
|
||||||
|
|||||||
@@ -18,17 +18,13 @@ use crate::domain::memory::Memory;
|
|||||||
use crate::domain::service::{MemoryService, SettingsService};
|
use crate::domain::service::{MemoryService, SettingsService};
|
||||||
use crate::domain::settings::Settings;
|
use crate::domain::settings::Settings;
|
||||||
|
|
||||||
use super::dto::{
|
use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, SettingsUpdateRequest};
|
||||||
MemoryCreateRequest, MemoryResponse, SettingsResponse, SettingsUpdateRequest,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Handle `GET /settings`
|
/// Handle `GET /settings`
|
||||||
///
|
///
|
||||||
/// Returns the current settings as a `SettingsResponse`.
|
/// Returns the current settings as a `SettingsResponse`.
|
||||||
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
|
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
|
||||||
let settings = service
|
let settings = service.load_settings().context("failed to load settings")?;
|
||||||
.load_settings()
|
|
||||||
.context("failed to load settings")?;
|
|
||||||
Ok(SettingsResponse::from(settings))
|
Ok(SettingsResponse::from(settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +111,7 @@ pub fn handle_update_settings<S: SettingsService>(
|
|||||||
///
|
///
|
||||||
/// Lists all memory slugs, then loads each memory to return full responses.
|
/// Lists all memory slugs, then loads each memory to return full responses.
|
||||||
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
|
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
|
||||||
let slugs = service
|
let slugs = service.list_memories().context("failed to list memories")?;
|
||||||
.list_memories()
|
|
||||||
.context("failed to list memories")?;
|
|
||||||
|
|
||||||
// We can't load individual memories without a load_memory method on the
|
// We can't load individual memories without a load_memory method on the
|
||||||
// service. For now, list returns summary info; callers who need full
|
// service. For now, list returns summary info; callers who need full
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ impl AppConfigRepository for JsonAppConfigRepository {
|
|||||||
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
|
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
|
||||||
let path = base_dir.join("app_config.json");
|
let path = base_dir.join("app_config.json");
|
||||||
let tmp = base_dir.join("app_config.json.tmp");
|
let tmp = base_dir.join("app_config.json.tmp");
|
||||||
let json = serde_json::to_string_pretty(config)
|
let json =
|
||||||
.context("failed to serialize app config")?;
|
serde_json::to_string_pretty(config).context("failed to serialize app config")?;
|
||||||
{
|
{
|
||||||
let mut f = std::fs::OpenOptions::new()
|
let mut f = std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
@@ -147,8 +147,13 @@ impl AppConfigRepository for JsonAppConfigRepository {
|
|||||||
f.write_all(json.as_bytes())?;
|
f.write_all(json.as_bytes())?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
}
|
}
|
||||||
std::fs::rename(&tmp, &path)
|
std::fs::rename(&tmp, &path).with_context(|| {
|
||||||
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
|
format!(
|
||||||
|
"failed to rename '{}' -> '{}'",
|
||||||
|
tmp.display(),
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
if let Ok(d) = std::fs::File::open(parent) {
|
if let Ok(d) = std::fs::File::open(parent) {
|
||||||
let _ = d.sync_all();
|
let _ = d.sync_all();
|
||||||
|
|||||||
@@ -57,8 +57,13 @@ impl ConversationRepository for JsonConversationRepository {
|
|||||||
f.write_all(json.as_bytes())?;
|
f.write_all(json.as_bytes())?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
}
|
}
|
||||||
std::fs::rename(&tmp, &path)
|
std::fs::rename(&tmp, &path).with_context(|| {
|
||||||
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
|
format!(
|
||||||
|
"failed to rename '{}' -> '{}'",
|
||||||
|
tmp.display(),
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
if let Ok(d) = std::fs::File::open(parent) {
|
if let Ok(d) = std::fs::File::open(parent) {
|
||||||
let _ = d.sync_all();
|
let _ = d.sync_all();
|
||||||
|
|||||||
@@ -74,9 +74,8 @@ impl EditLogRepository for JsonlEditLogRepository {
|
|||||||
|
|
||||||
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()> {
|
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()> {
|
||||||
let path = session_dir.join("edits.jsonl");
|
let path = session_dir.join("edits.jsonl");
|
||||||
let line = serde_json::to_string(&entry)
|
let line =
|
||||||
.context("failed to serialize edit log entry")?
|
serde_json::to_string(&entry).context("failed to serialize edit log entry")? + "\n";
|
||||||
+ "\n";
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.with_context(|| format!("failed to create session dir '{}'", parent.display()))?;
|
.with_context(|| format!("failed to create session dir '{}'", parent.display()))?;
|
||||||
@@ -89,8 +88,7 @@ impl EditLogRepository for JsonlEditLogRepository {
|
|||||||
.with_context(|| format!("failed to open edits.jsonl at '{}'", path.display()))?;
|
.with_context(|| format!("failed to open edits.jsonl at '{}'", path.display()))?;
|
||||||
file.write_all(line.as_bytes())
|
file.write_all(line.as_bytes())
|
||||||
.context("failed to write edit log entry")?;
|
.context("failed to write edit log entry")?;
|
||||||
file.sync_all()
|
file.sync_all().context("failed to fsync edit log")?;
|
||||||
.context("failed to fsync edit log")?;
|
|
||||||
}
|
}
|
||||||
log.entries.push(entry);
|
log.entries.push(entry);
|
||||||
// Enforce in-memory cap
|
// Enforce in-memory cap
|
||||||
|
|||||||
@@ -84,10 +84,7 @@ impl MarkdownMemoryRepository {
|
|||||||
.lines()
|
.lines()
|
||||||
.filter_map(|l| {
|
.filter_map(|l| {
|
||||||
let mut it = l.splitn(2, ':');
|
let mut it = l.splitn(2, ':');
|
||||||
Some((
|
Some((it.next()?.trim().to_string(), it.next()?.trim().to_string()))
|
||||||
it.next()?.trim().to_string(),
|
|
||||||
it.next()?.trim().to_string(),
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -155,7 +152,8 @@ impl MemoryRepository for MarkdownMemoryRepository {
|
|||||||
if name == "MEMORY.md" {
|
if name == "MEMORY.md" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
name.strip_suffix(".md").map(std::string::ToString::to_string)
|
name.strip_suffix(".md")
|
||||||
|
.map(std::string::ToString::to_string)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(slugs)
|
Ok(slugs)
|
||||||
@@ -190,8 +188,13 @@ impl MemoryRepository for MarkdownMemoryRepository {
|
|||||||
f.write_all(content.as_bytes())?;
|
f.write_all(content.as_bytes())?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
}
|
}
|
||||||
std::fs::rename(&tmp, &path)
|
std::fs::rename(&tmp, &path).with_context(|| {
|
||||||
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
|
format!(
|
||||||
|
"failed to rename '{}' -> '{}'",
|
||||||
|
tmp.display(),
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
if let Some(p) = path.parent() {
|
if let Some(p) = path.parent() {
|
||||||
if let Ok(d) = std::fs::File::open(p) {
|
if let Ok(d) = std::fs::File::open(p) {
|
||||||
let _ = d.sync_all();
|
let _ = d.sync_all();
|
||||||
@@ -204,11 +207,15 @@ impl MemoryRepository for MarkdownMemoryRepository {
|
|||||||
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()> {
|
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()> {
|
||||||
let path = Memory::path(memory_dir, name);
|
let path = Memory::path(memory_dir, name);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
std::fs::remove_file(&path)
|
std::fs::remove_file(&path).with_context(|| {
|
||||||
.with_context(|| format!("failed to delete memory '{name}' at '{}'", path.display()))?;
|
format!("failed to delete memory '{name}' at '{}'", path.display())
|
||||||
|
})?;
|
||||||
tracing::debug!("memory deleted: '{}'", path.display());
|
tracing::debug!("memory deleted: '{}'", path.display());
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("memory '{name}' not found at '{}', skipping delete", path.display());
|
tracing::warn!(
|
||||||
|
"memory '{name}' not found at '{}', skipping delete",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,10 +128,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn tmp_dir() -> std::path::PathBuf {
|
fn tmp_dir() -> std::path::PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir =
|
||||||
"zesdex-cms-blob-test-{}",
|
std::env::temp_dir().join(format!("zesdex-cms-blob-test-{}", uuid::Uuid::new_v4()));
|
||||||
uuid::Uuid::new_v4()
|
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
@@ -179,10 +177,7 @@ mod tests {
|
|||||||
vec!["k".to_string()],
|
vec!["k".to_string()],
|
||||||
"key must appear exactly once even after being overwritten"
|
"key must appear exactly once even after being overwritten"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(repo.retrieve_blob(&dir, "k").unwrap(), Some(b"v2".to_vec()));
|
||||||
repo.retrieve_blob(&dir, "k").unwrap(),
|
|
||||||
Some(b"v2".to_vec())
|
|
||||||
);
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ impl SettingsRepository for JsonSettingsRepository {
|
|||||||
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
|
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
|
||||||
let path = base_dir.join("settings.json");
|
let path = base_dir.join("settings.json");
|
||||||
let tmp = base_dir.join("settings.json.tmp");
|
let tmp = base_dir.join("settings.json.tmp");
|
||||||
let json = serde_json::to_string_pretty(settings)
|
let json =
|
||||||
.context("failed to serialize settings")?;
|
serde_json::to_string_pretty(settings).context("failed to serialize settings")?;
|
||||||
{
|
{
|
||||||
let mut f = std::fs::OpenOptions::new()
|
let mut f = std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
@@ -69,8 +69,13 @@ impl SettingsRepository for JsonSettingsRepository {
|
|||||||
f.write_all(json.as_bytes())?;
|
f.write_all(json.as_bytes())?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
}
|
}
|
||||||
std::fs::rename(&tmp, &path)
|
std::fs::rename(&tmp, &path).with_context(|| {
|
||||||
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
|
format!(
|
||||||
|
"failed to rename '{}' -> '{}'",
|
||||||
|
tmp.display(),
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
if let Ok(d) = std::fs::File::open(parent) {
|
if let Ok(d) = std::fs::File::open(parent) {
|
||||||
let _ = d.sync_all();
|
let _ = d.sync_all();
|
||||||
@@ -87,7 +92,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_defaults_hive_mind_timeout_when_field_missing_from_old_settings_json() {
|
fn load_defaults_hive_mind_timeout_when_field_missing_from_old_settings_json() {
|
||||||
let dir = std::env::temp_dir().join(format!("zesdex-cms-settings-test-{}", uuid::Uuid::new_v4()));
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("zesdex-cms-settings-test-{}", uuid::Uuid::new_v4()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
// Simulate a settings.json written before `hive_mind_node_timeout_ms` existed.
|
// Simulate a settings.json written before `hive_mind_node_timeout_ms` existed.
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -96,7 +102,9 @@ mod tests {
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
let repo = JsonSettingsRepository::new();
|
let repo = JsonSettingsRepository::new();
|
||||||
let settings = repo.load(&dir).expect("load must not fail on a pre-existing settings.json missing the new field");
|
let settings = repo
|
||||||
|
.load(&dir)
|
||||||
|
.expect("load must not fail on a pre-existing settings.json missing the new field");
|
||||||
assert_eq!(settings.hive_mind_node_timeout_ms, 600_000);
|
assert_eq!(settings.hive_mind_node_timeout_ms, 600_000);
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
clippy::cast_possible_wrap
|
clippy::cast_possible_wrap
|
||||||
)]
|
)]
|
||||||
|
|
||||||
pub mod domain;
|
|
||||||
pub mod application;
|
pub mod application;
|
||||||
|
pub mod domain;
|
||||||
pub mod infrastructure;
|
pub mod infrastructure;
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
//! needed; optional fields use `skip_serializing_if` so unset knobs are
|
//! needed; optional fields use `skip_serializing_if` so unset knobs are
|
||||||
//! omitted rather than sent as `null`, matching provider expectations.
|
//! 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
|
/// Outbound chat completion request body sent to an
|
||||||
/// OpenAI/Anthropic-compatible provider.
|
/// OpenAI/Anthropic-compatible provider.
|
||||||
@@ -25,57 +24,6 @@ use serde_json::Value;
|
|||||||
/// Flow: constructed from the current message history plus optional
|
/// Flow: constructed from the current message history plus optional
|
||||||
/// generation knobs (temperature, `max_tokens`, tools, etc.) and serialized
|
/// generation knobs (temperature, `max_tokens`, tools, etc.) and serialized
|
||||||
/// directly into the HTTP request body.
|
/// directly into the HTTP request body.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
pub use zesdex_entities::seaorm::common::provider::ChatRequest as ChatCompletionRequest;
|
||||||
pub struct ChatCompletionRequest {
|
|
||||||
pub model: String,
|
|
||||||
pub messages: Vec<super::super::chat::message::ChatMessage>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub max_tokens: Option<u32>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub temperature: Option<f32>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub stream: Option<bool>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub stream_options: Option<StreamOptions>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub tools: Option<Vec<ToolDef>>,
|
|
||||||
/// Controls which (if any) function is called by the model.
|
|
||||||
/// Can be `"none"`, `"auto"`, or `{"type": "function", "function": {"name": "..."}}`.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub tool_choice: Option<Value>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub stop: Option<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Streaming options for the request; `include_usage` asks the provider to
|
pub use zesdex_entities::seaorm::common::provider::{StreamOptions, ToolDef, ToolFunctionDef};
|
||||||
/// emit a final usage chunk in the SSE stream.
|
|
||||||
///
|
|
||||||
/// Why: usage tokens are otherwise unavailable in a streamed response since
|
|
||||||
/// they are 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` implementations
|
|
||||||
/// and attached to [`ChatCompletionRequest::tools`] so the model knows which
|
|
||||||
/// functions it may call.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ToolDef {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub type_: String,
|
|
||||||
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,
|
|
||||||
pub description: String,
|
|
||||||
pub parameters: Value,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,58 +1,5 @@
|
|||||||
#![allow(
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
|
|
||||||
//! Inbound response DTOs for the non-streaming chat completions API.
|
//! Inbound response DTOs for the non-streaming chat completions API.
|
||||||
//!
|
//!
|
||||||
//! Flow: provider HTTP response body → `serde_json` deserializes into
|
//! Re-exported from `zesdex_entities` for consistency.
|
||||||
//! [`ChatCompletionResponse`] → caller reads `choices[0]` for the assistant
|
|
||||||
//! reply and `usage` for token accounting.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
pub use zesdex_entities::seaorm::common::provider::{ChatResponse as ChatCompletionResponse, Choice, Delta};
|
||||||
|
|
||||||
/// Non-streaming chat completion response returned by the provider.
|
|
||||||
///
|
|
||||||
/// Flow: deserialized directly from the HTTP response body of a
|
|
||||||
/// non-streaming completion call.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChatCompletionResponse {
|
|
||||||
pub id: String,
|
|
||||||
pub object: String,
|
|
||||||
pub created: i64,
|
|
||||||
pub model: String,
|
|
||||||
pub choices: Vec<Choice>,
|
|
||||||
pub usage: Option<super::usage::TokenUsage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One completion candidate within a [`ChatCompletionResponse::choices`] list.
|
|
||||||
///
|
|
||||||
/// For non-streaming responses the `message` field is populated; for streaming
|
|
||||||
/// responses the `delta` field carries the incremental token.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Choice {
|
|
||||||
pub index: u32,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub message: Option<super::super::chat::message::ChatMessage>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub delta: Option<Delta>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub finish_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Incremental delta emitted in a streaming SSE chunk.
|
|
||||||
///
|
|
||||||
/// Only populated when the response is streamed; `role` typically appears
|
|
||||||
/// only on the first chunk and `content` / `tool_calls` are appended
|
|
||||||
/// incrementally.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Delta {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub role: Option<super::super::chat::message::Role>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub content: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub tool_calls: Option<Vec<super::super::chat::tool::ToolCall>>,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,28 +1,5 @@
|
|||||||
#![allow(
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
|
|
||||||
//! Token usage accounting DTO shared by streaming and non-streaming responses.
|
//! Token usage accounting DTO shared by streaming and non-streaming responses.
|
||||||
//!
|
//!
|
||||||
//! Flow: populated from the provider's `usage` object (either the final SSE
|
//! Re-exported from `zesdex_entities` for consistency.
|
||||||
//! chunk when `stream_options.include_usage` is set, or the `usage` field of
|
|
||||||
//! a non-streaming [`ChatCompletionResponse`](super::response::ChatCompletionResponse))
|
|
||||||
//! → surfaced to the TUI for cost/token display.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
pub use zesdex_entities::seaorm::common::provider::TokenUsage;
|
||||||
|
|
||||||
/// Token counts for a single completion request.
|
|
||||||
///
|
|
||||||
/// Why: these are the standard fields reported by the OpenAI-compatible chat
|
|
||||||
/// completions API. All fields are required when present — use `Option` at
|
|
||||||
/// the [`ChatCompletionResponse`](super::response::ChatCompletionResponse) level
|
|
||||||
/// if usage is absent.
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
|
||||||
pub struct TokenUsage {
|
|
||||||
pub prompt_tokens: u32,
|
|
||||||
pub completion_tokens: u32,
|
|
||||||
pub total_tokens: u32,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ pub mod usage;
|
|||||||
pub use conversation::Conversation;
|
pub use conversation::Conversation;
|
||||||
pub use message::{ChatMessage, Role};
|
pub use message::{ChatMessage, Role};
|
||||||
pub use provider::{
|
pub use provider::{
|
||||||
ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef, ToolFunctionDef,
|
ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef,
|
||||||
|
ToolFunctionDef,
|
||||||
};
|
};
|
||||||
pub use store::Store;
|
pub use store::Store;
|
||||||
pub use tool_call::{ToolCall, ToolFunction};
|
pub use tool_call::{ToolCall, ToolFunction};
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ pub struct ChatRequest {
|
|||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tools: Option<Vec<ToolDef>>,
|
pub tools: Option<Vec<ToolDef>>,
|
||||||
|
/// Controls which (if any) function is called by the model.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_choice: Option<Value>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub stream: Option<bool>,
|
pub stream: Option<bool>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -62,9 +65,10 @@ pub struct ToolFunctionDef {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ChatResponse {
|
pub struct ChatResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub object: Option<String>,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub choices: Vec<Choice>,
|
pub choices: Vec<Choice>,
|
||||||
pub usage: Option<Usage>,
|
pub usage: Option<TokenUsage>,
|
||||||
pub created: Option<i64>,
|
pub created: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,16 +76,31 @@ pub struct ChatResponse {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Choice {
|
pub struct Choice {
|
||||||
pub index: u32,
|
pub index: u32,
|
||||||
pub message: super::message::ChatMessage,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub message: Option<super::message::ChatMessage>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub delta: Option<Delta>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub finish_reason: Option<String>,
|
pub finish_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Incremental delta emitted in a streaming SSE chunk.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Delta {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub role: Option<super::message::Role>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Token counts and optional cost breakdown for a single completion request.
|
/// Token counts and optional cost breakdown for a single completion request.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Usage {
|
pub struct TokenUsage {
|
||||||
pub prompt_tokens: Option<u32>,
|
pub prompt_tokens: u32,
|
||||||
pub completion_tokens: Option<u32>,
|
pub completion_tokens: u32,
|
||||||
pub total_tokens: Option<u32>,
|
pub total_tokens: u32,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub prompt_tokens_cost: Option<f64>,
|
pub prompt_tokens_cost: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -229,9 +248,7 @@ impl SseParser {
|
|||||||
if let Some(choice) = choices.first() {
|
if let Some(choice) = choices.first() {
|
||||||
if let Some(d) = choice.get("delta") {
|
if let Some(d) = choice.get("delta") {
|
||||||
// Content token
|
// Content token
|
||||||
if let Some(content) =
|
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
||||||
d.get("content").and_then(|c| c.as_str())
|
|
||||||
{
|
|
||||||
d_events.push(StreamEvent::Token(content.to_string()));
|
d_events.push(StreamEvent::Token(content.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,9 +256,7 @@ impl SseParser {
|
|||||||
if let Some(reasoning) =
|
if let Some(reasoning) =
|
||||||
d.get("reasoning_content").and_then(|r| r.as_str())
|
d.get("reasoning_content").and_then(|r| r.as_str())
|
||||||
{
|
{
|
||||||
d_events.push(StreamEvent::Reasoning(
|
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
||||||
reasoning.to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool calls — iterate ALL entries, not just first()
|
// Tool calls — iterate ALL entries, not just first()
|
||||||
@@ -249,16 +264,16 @@ impl SseParser {
|
|||||||
d.get("tool_calls").and_then(|tc| tc.as_array())
|
d.get("tool_calls").and_then(|tc| tc.as_array())
|
||||||
{
|
{
|
||||||
for tc in tool_calls {
|
for tc in tool_calls {
|
||||||
let index = tc
|
let index =
|
||||||
.get("index")
|
tc.get("index").and_then(Value::as_u64).unwrap_or_else(
|
||||||
.and_then(Value::as_u64)
|
|| {
|
||||||
.unwrap_or_else(|| {
|
tracing::warn!(
|
||||||
tracing::warn!(
|
"[stream] tool call delta missing index, \
|
||||||
"[stream] tool call delta missing index, \
|
|
||||||
defaulting to 0"
|
defaulting to 0"
|
||||||
);
|
);
|
||||||
0
|
0
|
||||||
}) as usize;
|
},
|
||||||
|
) as usize;
|
||||||
let id = tc
|
let id = tc
|
||||||
.get("id")
|
.get("id")
|
||||||
.and_then(|i| i.as_str())
|
.and_then(|i| i.as_str())
|
||||||
@@ -293,9 +308,7 @@ impl SseParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(content) =
|
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||||
delta.get("content").and_then(|c| c.as_str())
|
|
||||||
{
|
|
||||||
d_events.push(StreamEvent::Token(content.to_string()));
|
d_events.push(StreamEvent::Token(content.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,9 +71,7 @@ pub fn sanitize_tool_arguments(args: &Value) -> Value {
|
|||||||
let repaired = repair_json(input);
|
let repaired = repair_json(input);
|
||||||
match serde_json::from_str::<Value>(&repaired) {
|
match serde_json::from_str::<Value>(&repaired) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
tracing::warn!(
|
tracing::warn!("tool argument string was truncated — repaired successfully",);
|
||||||
"tool argument string was truncated — repaired successfully",
|
|
||||||
);
|
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Cumulative token/latency counters for a session, persisted alongside it.
|
/// Cumulative token/latency counters for a session, persisted alongside it.
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||||
pub struct UsageStats {
|
pub struct UsageStats {
|
||||||
pub tokens_in: u64,
|
pub tokens_in: u64,
|
||||||
pub tokens_out: u64,
|
pub tokens_out: u64,
|
||||||
@@ -21,20 +21,6 @@ pub struct UsageStats {
|
|||||||
pub total_ms: u64,
|
pub total_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UsageStats {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
tokens_in: 0,
|
|
||||||
tokens_out: 0,
|
|
||||||
last_tokens_in: 0,
|
|
||||||
last_tokens_out: 0,
|
|
||||||
api_calls: 0,
|
|
||||||
review_tokens: 0,
|
|
||||||
total_ms: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UsageStats {
|
impl UsageStats {
|
||||||
/// Create a new `UsageStats` with all counters zeroed.
|
/// Create a new `UsageStats` with all counters zeroed.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
|||||||
@@ -183,10 +183,7 @@ impl<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
|
|||||||
access_token,
|
access_token,
|
||||||
refresh_token: body["refresh_token"].as_str().map(String::from),
|
refresh_token: body["refresh_token"].as_str().map(String::from),
|
||||||
expires_at: now + expires_in,
|
expires_at: now + expires_in,
|
||||||
token_type: body["token_type"]
|
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
||||||
.as_str()
|
|
||||||
.unwrap_or("Bearer")
|
|
||||||
.to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
self.token_repo.save_token(&self.token_path, &token)?;
|
self.token_repo.save_token(&self.token_path, &token)?;
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ pub mod oauth;
|
|||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod session_lock;
|
|
||||||
|
|||||||
@@ -1,59 +1,5 @@
|
|||||||
#![allow(
|
//! Pure Session entity
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
//! Pure Session entity — no persistence logic.
|
|
||||||
//!
|
//!
|
||||||
//! This type represents the metadata of one conversation session.
|
//! Re-exported from zesdex_entities for consistency.
|
||||||
//! All save / load / list operations belong to the repository traits.
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// Metadata for one conversation session.
|
pub use zesdex_entities::seaorm::auth::session::Session;
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Session {
|
|
||||||
pub id: String,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
pub title: String,
|
|
||||||
pub model: String,
|
|
||||||
pub workspace_roots: Vec<PathBuf>,
|
|
||||||
pub message_count: u32,
|
|
||||||
pub token_count: u32,
|
|
||||||
pub archived: bool,
|
|
||||||
pub summary: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Session {
|
|
||||||
/// Create a new session with default field values.
|
|
||||||
pub fn new(id: String, title: String) -> Self {
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis() as i64;
|
|
||||||
Session {
|
|
||||||
id,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
title,
|
|
||||||
model: "anthropic/claude-opus-4-8".to_string(),
|
|
||||||
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
|
|
||||||
message_count: 0,
|
|
||||||
token_count: 0,
|
|
||||||
archived: false,
|
|
||||||
summary: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
#![allow(
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
//! Pure SessionLock entity — no lock / unlock logic.
|
|
||||||
//!
|
|
||||||
//! Lock acquisition and release are handled by the repository.
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// A PID-file based session lock handle.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct SessionLock {
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub pid: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionLock {
|
|
||||||
/// Construct a lock handle for a session directory (does not acquire
|
|
||||||
/// the lock yet — use the repository's `try_lock`).
|
|
||||||
pub fn new(session_dir: &Path) -> Self {
|
|
||||||
SessionLock {
|
|
||||||
path: session_dir.join(".lock"),
|
|
||||||
pid: std::process::id(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,19 +27,14 @@ pub fn handle_create_session<S: SessionService>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a list-sessions request.
|
/// Handle a list-sessions request.
|
||||||
pub fn handle_list_sessions<S: SessionService>(
|
pub fn handle_list_sessions<S: SessionService>(service: &S) -> anyhow::Result<SessionListResponse> {
|
||||||
service: &S,
|
|
||||||
) -> anyhow::Result<SessionListResponse> {
|
|
||||||
let sessions = service.list_all()?;
|
let sessions = service.list_all()?;
|
||||||
let total = sessions.len();
|
let total = sessions.len();
|
||||||
Ok(SessionListResponse { sessions, total })
|
Ok(SessionListResponse { sessions, total })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle an archive-session request.
|
/// Handle an archive-session request.
|
||||||
pub fn handle_archive_session<S: SessionService>(
|
pub fn handle_archive_session<S: SessionService>(service: &S, id: &str) -> anyhow::Result<()> {
|
||||||
service: &S,
|
|
||||||
id: &str,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
service.archive_session(id)?;
|
service.archive_session(id)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -63,9 +58,7 @@ pub fn handle_complete_oauth<O: OAuthService>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a get-token request.
|
/// Handle a get-token request.
|
||||||
pub fn handle_get_token<O: OAuthService>(
|
pub fn handle_get_token<O: OAuthService>(service: &O) -> anyhow::Result<OAuthTokenResponse> {
|
||||||
service: &O,
|
|
||||||
) -> anyhow::Result<OAuthTokenResponse> {
|
|
||||||
let token = service
|
let token = service
|
||||||
.get_token()?
|
.get_token()?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?;
|
.ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?;
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ mod tests {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn save_token_sets_owner_only_permissions() {
|
fn save_token_sets_owner_only_permissions() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let dir = std::env::temp_dir().join(format!("zesdex-iam-perm-test-{}", uuid::Uuid::new_v4()));
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("zesdex-iam-perm-test-{}", uuid::Uuid::new_v4()));
|
||||||
let path = dir.join("oauth_test.json");
|
let path = dir.join("oauth_test.json");
|
||||||
let repo = FileSystemOAuthRepository::new();
|
let repo = FileSystemOAuthRepository::new();
|
||||||
let token = OAuthToken {
|
let token = OAuthToken {
|
||||||
@@ -74,10 +75,14 @@ mod tests {
|
|||||||
expires_at: 0,
|
expires_at: 0,
|
||||||
token_type: "Bearer".to_string(),
|
token_type: "Bearer".to_string(),
|
||||||
};
|
};
|
||||||
repo.save_token(&path, &token).expect("save_token should succeed");
|
repo.save_token(&path, &token)
|
||||||
|
.expect("save_token should succeed");
|
||||||
|
|
||||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
assert_eq!(mode, 0o600, "token file must be readable/writable by owner only, got {mode:o}");
|
assert_eq!(
|
||||||
|
mode, 0o600,
|
||||||
|
"token file must be readable/writable by owner only, got {mode:o}"
|
||||||
|
);
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
|
|||||||
let path = session_dir.join(".lock");
|
let path = session_dir.join(".lock");
|
||||||
let pid = std::process::id();
|
let pid = std::process::id();
|
||||||
|
|
||||||
match fs::OpenOptions::new().create_new(true).write(true).open(&path) {
|
match fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
Ok(mut file) => {
|
Ok(mut file) => {
|
||||||
write!(file, "{pid}")?;
|
write!(file, "{pid}")?;
|
||||||
file.sync_all()?;
|
file.sync_all()?;
|
||||||
@@ -49,7 +53,11 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
|
|||||||
|
|
||||||
let tmp = path.with_extension("lock.tmp");
|
let tmp = path.with_extension("lock.tmp");
|
||||||
{
|
{
|
||||||
let mut tmp_file = fs::OpenOptions::new().create(true).truncate(true).write(true).open(&tmp)?;
|
let mut tmp_file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&tmp)?;
|
||||||
write!(tmp_file, "{pid}")?;
|
write!(tmp_file, "{pid}")?;
|
||||||
tmp_file.sync_all()?;
|
tmp_file.sync_all()?;
|
||||||
}
|
}
|
||||||
@@ -89,7 +97,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn tmp_dir() -> std::path::PathBuf {
|
fn tmp_dir() -> std::path::PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!("zesdex-iam-lock-test-{}", uuid::Uuid::new_v4()));
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("zesdex-iam-lock-test-{}", uuid::Uuid::new_v4()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
@@ -119,7 +128,10 @@ mod tests {
|
|||||||
let repo = FileSystemSessionLockRepository::new();
|
let repo = FileSystemSessionLockRepository::new();
|
||||||
// Write a lock file with a PID that cannot possibly be alive.
|
// Write a lock file with a PID that cannot possibly be alive.
|
||||||
std::fs::write(dir.join(".lock"), "999999999").unwrap();
|
std::fs::write(dir.join(".lock"), "999999999").unwrap();
|
||||||
assert!(repo.try_lock(&dir).unwrap(), "a stale lock (dead PID) must be recoverable");
|
assert!(
|
||||||
|
repo.try_lock(&dir).unwrap(),
|
||||||
|
"a stale lock (dead PID) must be recoverable"
|
||||||
|
);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ impl SessionRepository for FileSystemSessionRepository {
|
|||||||
fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<Session> {
|
fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<Session> {
|
||||||
// Directory-traversal prevention.
|
// Directory-traversal prevention.
|
||||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||||
anyhow::bail!(
|
anyhow::bail!("invalid session id '{id}': must not contain path separators");
|
||||||
"invalid session id '{id}': must not contain path separators"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let path = base_dir.join("sessions").join(id).join("session.json");
|
let path = base_dir.join("sessions").join(id).join("session.json");
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -79,9 +77,7 @@ impl SessionRepository for FileSystemSessionRepository {
|
|||||||
|
|
||||||
fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()> {
|
fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()> {
|
||||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||||
anyhow::bail!(
|
anyhow::bail!("invalid session id '{id}': must not contain path separators");
|
||||||
"invalid session id '{id}': must not contain path separators"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let dir = base_dir.join("sessions").join(id);
|
let dir = base_dir.join("sessions").join(id);
|
||||||
if dir.exists() {
|
if dir.exists() {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ mod tests {
|
|||||||
fn secure_token_hex_is_not_constant() {
|
fn secure_token_hex_is_not_constant() {
|
||||||
let a = secure_token_hex(16);
|
let a = secure_token_hex(16);
|
||||||
let b = secure_token_hex(16);
|
let b = secure_token_hex(16);
|
||||||
assert_ne!(a, b, "two consecutive calls must not produce the same token");
|
assert_ne!(
|
||||||
|
a, b,
|
||||||
|
"two consecutive calls must not produce the same token"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
//! - **application**— Use-case implementations of the service traits
|
//! - **application**— Use-case implementations of the service traits
|
||||||
//! - **infrastructure** — Concrete persistence (filesystem) and HTTP adapter layers
|
//! - **infrastructure** — Concrete persistence (filesystem) and HTTP adapter layers
|
||||||
|
|
||||||
pub mod domain;
|
|
||||||
pub mod application;
|
pub mod application;
|
||||||
|
pub mod domain;
|
||||||
pub mod infrastructure;
|
pub mod infrastructure;
|
||||||
|
|||||||
@@ -4,13 +4,6 @@
|
|||||||
//! [`IpcClient`] wraps a [`Connection`] behind a [`Mutex`] so it can be
|
//! [`IpcClient`] wraps a [`Connection`] behind a [`Mutex`] so it can be
|
||||||
//! shared across threads (e.g. the TUI event loop and the render task).
|
//! shared across threads (e.g. the TUI event loop and the render task).
|
||||||
|
|
||||||
#![allow(
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
|
|
||||||
use crate::conn::Connection;
|
use crate::conn::Connection;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
@@ -43,6 +36,11 @@ impl IpcClient {
|
|||||||
|
|
||||||
/// Serialise `msg` to JSON and send it as a length-prefixed frame.
|
/// Serialise `msg` to JSON and send it as a length-prefixed frame.
|
||||||
///
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal mutex is poisoned (a previous operation
|
||||||
|
/// panicked while holding the lock).
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Delegates to the underlying [`Connection::send`].
|
/// Delegates to the underlying [`Connection::send`].
|
||||||
@@ -58,6 +56,11 @@ impl IpcClient {
|
|||||||
///
|
///
|
||||||
/// Returns `Ok(None)` on clean EOF (daemon closed the connection).
|
/// Returns `Ok(None)` on clean EOF (daemon closed the connection).
|
||||||
///
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the internal mutex is poisoned (a previous operation
|
||||||
|
/// panicked while holding the lock).
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Delegates to the underlying [`Connection::receive`].
|
/// Delegates to the underlying [`Connection::receive`].
|
||||||
@@ -73,17 +76,13 @@ impl IpcClient {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::os::unix::net::UnixListener;
|
use std::os::unix::net::UnixListener;
|
||||||
|
use crate::test_utils::Ping;
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
struct Ping {
|
|
||||||
seq: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn connect_and_round_trip() {
|
fn connect_and_round_trip() {
|
||||||
let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}", std::process::id()));
|
let id = crate::test_utils::TEST_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}-{}", std::process::id(), id));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let sock_path = dir.join("test.sock");
|
let sock_path = dir.join("test.sock");
|
||||||
|
|||||||
@@ -3,13 +3,6 @@
|
|||||||
//! [`Connection`] pairs a buffered reader with a raw writer and exposes
|
//! [`Connection`] pairs a buffered reader with a raw writer and exposes
|
||||||
//! `send` / `receive` for framed JSON messages.
|
//! `send` / `receive` for framed JSON messages.
|
||||||
|
|
||||||
#![allow(
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
|
|
||||||
use crate::frame::{read_frame, write_frame};
|
use crate::frame::{read_frame, write_frame};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
@@ -30,6 +23,12 @@ pub struct Connection {
|
|||||||
|
|
||||||
impl Connection {
|
impl Connection {
|
||||||
/// Create a new `Connection` from an already-connected [`UnixStream`].
|
/// Create a new `Connection` from an already-connected [`UnixStream`].
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `UnixStream::try_clone` fails — this should never happen on
|
||||||
|
/// Linux (it calls `dup(2)`).
|
||||||
|
#[must_use]
|
||||||
pub fn new(stream: UnixStream) -> Self {
|
pub fn new(stream: UnixStream) -> Self {
|
||||||
// Clone the stream so that reader and writer can reference separate
|
// Clone the stream so that reader and writer can reference separate
|
||||||
// file-descriptor handles. `UnixStream::try_clone` is infallible on
|
// file-descriptor handles. `UnixStream::try_clone` is infallible on
|
||||||
@@ -50,10 +49,8 @@ impl Connection {
|
|||||||
/// Delegates to [`serde_json::to_vec`] for serialisation and
|
/// Delegates to [`serde_json::to_vec`] for serialisation and
|
||||||
/// [`write_frame`] for writing.
|
/// [`write_frame`] for writing.
|
||||||
pub fn send<T: Serialize>(&mut self, msg: &T) -> Result<()> {
|
pub fn send<T: Serialize>(&mut self, msg: &T) -> Result<()> {
|
||||||
let json =
|
let json = serde_json::to_vec(msg).context("failed to serialise message to JSON")?;
|
||||||
serde_json::to_vec(msg).context("failed to serialise message to JSON")?;
|
write_frame(&mut self.writer, &json).context("failed to write frame to connection")
|
||||||
write_frame(&mut self.writer, &json)
|
|
||||||
.context("failed to write frame to connection")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read one framed JSON message and deserialise it.
|
/// Read one framed JSON message and deserialise it.
|
||||||
@@ -66,19 +63,14 @@ impl Connection {
|
|||||||
/// Delegates to [`read_frame`] for reading and
|
/// Delegates to [`read_frame`] for reading and
|
||||||
/// [`serde_json::from_slice`] for deserialisation.
|
/// [`serde_json::from_slice`] for deserialisation.
|
||||||
pub fn receive<T: DeserializeOwned>(&mut self) -> Result<Option<T>> {
|
pub fn receive<T: DeserializeOwned>(&mut self) -> Result<Option<T>> {
|
||||||
let raw = read_frame(&mut self.reader)
|
let raw = read_frame(&mut self.reader).context("failed to read frame from connection")?;
|
||||||
.context("failed to read frame from connection")?;
|
|
||||||
|
|
||||||
match raw {
|
match raw {
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
Some(bytes) => {
|
Some(bytes) => {
|
||||||
let msg: T = serde_json::from_slice(&bytes)
|
let msg: T = serde_json::from_slice(&bytes).with_context(|| {
|
||||||
.with_context(|| {
|
format!("failed to deserialise frame ({} byte(s))", bytes.len())
|
||||||
format!(
|
})?;
|
||||||
"failed to deserialise frame ({} byte(s))",
|
|
||||||
bytes.len()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(Some(msg))
|
Ok(Some(msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,12 +91,7 @@ where
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use crate::test_utils::Ping;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
struct Ping {
|
|
||||||
seq: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: create a pair of connected `Connection` values via a
|
/// Helper: create a pair of connected `Connection` values via a
|
||||||
/// Unix socket pair.
|
/// Unix socket pair.
|
||||||
@@ -132,8 +119,7 @@ mod tests {
|
|||||||
// Since we dropped right, left's reads should eventually get EOF.
|
// Since we dropped right, left's reads should eventually get EOF.
|
||||||
// But with a socket pair, dropping one end signals EOF on the other.
|
// But with a socket pair, dropping one end signals EOF on the other.
|
||||||
drop(left); // drop left too — we'll test EOF on a fresh pair
|
drop(left); // drop left too — we'll test EOF on a fresh pair
|
||||||
let (mut a, _b) = pair();
|
let (mut a, _) = pair();
|
||||||
drop(_b);
|
|
||||||
let result: Option<Ping> = a.receive().unwrap();
|
let result: Option<Ping> = a.receive().unwrap();
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,7 @@ pub fn read_frame(reader: &mut impl Read) -> Result<Option<Vec<u8>>> {
|
|||||||
let payload_len = u32::from_be_bytes(len_buf) as usize;
|
let payload_len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
|
||||||
if payload_len > MAX_PAYLOAD as usize {
|
if payload_len > MAX_PAYLOAD as usize {
|
||||||
anyhow::bail!(
|
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
|
||||||
"frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Read the payload ---------------------------------------------------
|
// --- Read the payload ---------------------------------------------------
|
||||||
@@ -79,9 +77,7 @@ pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> Result<()> {
|
|||||||
.context("payload length exceeds u32 range")?;
|
.context("payload length exceeds u32 range")?;
|
||||||
|
|
||||||
if payload_len > MAX_PAYLOAD {
|
if payload_len > MAX_PAYLOAD {
|
||||||
anyhow::bail!(
|
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
|
||||||
"frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let len_bytes = payload_len.to_be_bytes();
|
let len_bytes = payload_len.to_be_bytes();
|
||||||
@@ -91,9 +87,7 @@ pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> Result<()> {
|
|||||||
writer
|
writer
|
||||||
.write_all(data)
|
.write_all(data)
|
||||||
.context("failed to write frame payload")?;
|
.context("failed to write frame payload")?;
|
||||||
writer
|
writer.flush().context("failed to flush frame writer")?;
|
||||||
.flush()
|
|
||||||
.context("failed to flush frame writer")?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,21 @@
|
|||||||
clippy::cast_possible_wrap
|
clippy::cast_possible_wrap
|
||||||
)]
|
)]
|
||||||
|
|
||||||
pub mod protocol;
|
|
||||||
pub mod frame;
|
|
||||||
pub mod conn;
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod conn;
|
||||||
|
pub mod frame;
|
||||||
|
pub mod protocol;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_utils {
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
|
||||||
|
pub static TEST_ID: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Ping {
|
||||||
|
pub seq: u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,16 +79,12 @@ where
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use crate::test_utils::Ping;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
struct Ping {
|
|
||||||
seq: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bind_and_accept_one() {
|
fn bind_and_accept_one() {
|
||||||
let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}", std::process::id()));
|
let id = crate::test_utils::TEST_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}-{}", std::process::id(), id));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let sock_path = dir.join("server_test.sock");
|
let sock_path = dir.join("server_test.sock");
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ impl DbConn {
|
|||||||
where
|
where
|
||||||
F: FnOnce(&rusqlite::Connection) -> Result<T>,
|
F: FnOnce(&rusqlite::Connection) -> Result<T>,
|
||||||
{
|
{
|
||||||
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("db lock poisoned: {e}"))?;
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| anyhow::anyhow!("db lock poisoned: {e}"))?;
|
||||||
f(&conn)
|
f(&conn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ impl JwtClaims {
|
|||||||
pub fn create_token(secret: &str, claims: JwtClaims) -> Result<String> {
|
pub fn create_token(secret: &str, claims: JwtClaims) -> Result<String> {
|
||||||
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
||||||
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
|
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
|
||||||
jsonwebtoken::encode(&header, &claims, &key)
|
jsonwebtoken::encode(&header, &claims, &key).context("failed to encode JWT")
|
||||||
.context("failed to encode JWT")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a JWT string and return its claims.
|
/// Verify a JWT string and return its claims.
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ pub fn hash_password(password: &str) -> Result<String> {
|
|||||||
/// Returns an error if the hash string is not a valid PHC string or if
|
/// Returns an error if the hash string is not a valid PHC string or if
|
||||||
/// the argon2 library encounters an internal failure.
|
/// the argon2 library encounters an internal failure.
|
||||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||||
let parsed_hash =
|
let parsed_hash = PasswordHash::new(hash)
|
||||||
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
|
||||||
let argon2 = Argon2::default();
|
let argon2 = Argon2::default();
|
||||||
Ok(argon2
|
Ok(argon2
|
||||||
.verify_password(password.as_bytes(), &parsed_hash)
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ use uuid::Uuid;
|
|||||||
use zesdex_cms::domain::app_config::ProviderConfig;
|
use zesdex_cms::domain::app_config::ProviderConfig;
|
||||||
use zesdex_cms::domain::conversation::Conversation;
|
use zesdex_cms::domain::conversation::Conversation;
|
||||||
use zesdex_cms::domain::memory::Memory;
|
use zesdex_cms::domain::memory::Memory;
|
||||||
|
use zesdex_cms::domain::repository::{
|
||||||
|
AppConfigRepository, ConversationRepository, MemoryRepository, SettingsRepository,
|
||||||
|
};
|
||||||
use zesdex_cms::domain::settings::Settings;
|
use zesdex_cms::domain::settings::Settings;
|
||||||
use zesdex_cms::infrastructure::persistence::{
|
use zesdex_cms::infrastructure::persistence::{
|
||||||
JsonAppConfigRepository, JsonConversationRepository, JsonSettingsRepository,
|
JsonAppConfigRepository, JsonConversationRepository, JsonSettingsRepository,
|
||||||
MarkdownMemoryRepository,
|
MarkdownMemoryRepository,
|
||||||
};
|
};
|
||||||
use zesdex_entities::seaorm::common::store::Store;
|
use zesdex_entities::seaorm::common::store::Store;
|
||||||
use zesdex_cms::domain::repository::{
|
|
||||||
AppConfigRepository, ConversationRepository, MemoryRepository, SettingsRepository,
|
|
||||||
};
|
|
||||||
use zesdex_iam::domain::repository::SessionRepository;
|
use zesdex_iam::domain::repository::SessionRepository;
|
||||||
use zesdex_iam::domain::session::Session;
|
use zesdex_iam::domain::session::Session;
|
||||||
use zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository;
|
use zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository;
|
||||||
@@ -213,14 +213,12 @@ impl CmsServiceProvider for DefaultCmsServiceProvider {
|
|||||||
|
|
||||||
fn save_conversation(&self, conv: &Conversation) -> Result<()> {
|
fn save_conversation(&self, conv: &Conversation) -> Result<()> {
|
||||||
let dir = self.session_dir(&conv.session_id);
|
let dir = self.session_dir(&conv.session_id);
|
||||||
self.conversation_repo
|
self.conversation_repo.save(&dir, conv).with_context(|| {
|
||||||
.save(&dir, conv)
|
format!(
|
||||||
.with_context(|| {
|
"failed to save conversation for session '{}'",
|
||||||
format!(
|
conv.session_id
|
||||||
"failed to save conversation for session '{}'",
|
)
|
||||||
conv.session_id
|
})
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Memories --
|
// -- Memories --
|
||||||
@@ -299,17 +297,14 @@ pub fn initialize_app_context() -> Result<AppContext> {
|
|||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("invalid db path: {}", db_path.display()))?;
|
.ok_or_else(|| anyhow::anyhow!("invalid db path: {}", db_path.display()))?;
|
||||||
|
|
||||||
let db = database::init_db(db_path_str)
|
let db = database::init_db(db_path_str).context("failed to initialise database")?;
|
||||||
.context("failed to initialise database")?;
|
|
||||||
|
|
||||||
database::run_migrations(&db)
|
database::run_migrations(&db).context("failed to run database migrations")?;
|
||||||
.context("failed to run database migrations")?;
|
|
||||||
|
|
||||||
// -- Services --
|
// -- Services --
|
||||||
let iam_service: Box<dyn IamServiceProvider> =
|
let iam_service: Box<dyn IamServiceProvider> =
|
||||||
Box::new(DefaultIamServiceProvider::new(store.base_dir.clone()));
|
Box::new(DefaultIamServiceProvider::new(store.base_dir.clone()));
|
||||||
let cms_service: Box<dyn CmsServiceProvider> =
|
let cms_service: Box<dyn CmsServiceProvider> = Box::new(DefaultCmsServiceProvider::new(&store));
|
||||||
Box::new(DefaultCmsServiceProvider::new(&store));
|
|
||||||
|
|
||||||
// -- JWT secret --
|
// -- JWT secret --
|
||||||
let jwt_secret = std::env::var("ZESDEX_JWT_SECRET")
|
let jwt_secret = std::env::var("ZESDEX_JWT_SECRET")
|
||||||
|
|||||||
@@ -91,9 +91,10 @@ impl SessionAuthLayer {
|
|||||||
store: Arc::new(store),
|
store: Arc::new(store),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience constructor using `Store::new()`.
|
impl Default for SessionAuthLayer {
|
||||||
pub fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(Store::new())
|
Self::new(Store::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,8 +149,8 @@ where
|
|||||||
let session_id = match session_id {
|
let session_id = match session_id {
|
||||||
Some(id) if !id.is_empty() => id,
|
Some(id) if !id.is_empty() => id,
|
||||||
_ => {
|
_ => {
|
||||||
let resp = (StatusCode::UNAUTHORIZED, "missing X-Session-Id header")
|
let resp =
|
||||||
.into_response();
|
(StatusCode::UNAUTHORIZED, "missing X-Session-Id header").into_response();
|
||||||
return Box::pin(async move { Ok(resp) });
|
return Box::pin(async move { Ok(resp) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -173,7 +174,7 @@ where
|
|||||||
req.extensions_mut().insert(identity);
|
req.extensions_mut().insert(identity);
|
||||||
|
|
||||||
let fut = self.inner.call(req);
|
let fut = self.inner.call(req);
|
||||||
Box::pin(async move { fut.await })
|
Box::pin(fut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +206,11 @@ pub async fn require_session(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = validate_session(&session_id, &store) {
|
if let Err(e) = validate_session(&session_id, &store) {
|
||||||
return (StatusCode::UNAUTHORIZED, format!("session validation failed: {e}")).into_response();
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
format!("session validation failed: {e}"),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
let user_agent = req
|
let user_agent = req
|
||||||
.headers()
|
.headers()
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
#![allow(
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
//! Simple in-memory rate limiter for Axum.
|
//! Simple in-memory rate limiter for Axum.
|
||||||
//!
|
//!
|
||||||
//! Uses a sliding-window approach: each client has a rolling list of
|
//! Uses a sliding-window approach: each client has a rolling list of
|
||||||
@@ -80,7 +74,9 @@ impl RateLimiter {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| anyhow::anyhow!("rate limiter lock poisoned: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("rate limiter lock poisoned: {e}"))?;
|
||||||
|
|
||||||
let timestamps = windows.entry(client_id.to_string()).or_insert_with(Vec::new);
|
let timestamps = windows
|
||||||
|
.entry(client_id.to_string())
|
||||||
|
.or_insert_with(Vec::new);
|
||||||
|
|
||||||
// Discard entries older than the window.
|
// Discard entries older than the window.
|
||||||
timestamps.retain(|&ts| ts >= cutoff);
|
timestamps.retain(|&ts| ts >= cutoff);
|
||||||
@@ -100,15 +96,19 @@ impl RateLimiter {
|
|||||||
client_id: &str,
|
client_id: &str,
|
||||||
max_requests: u32,
|
max_requests: u32,
|
||||||
window_secs: u64,
|
window_secs: u64,
|
||||||
) -> Result<(), Response> {
|
) -> Result<(), Box<Response>> {
|
||||||
match self.check_rate_limit(client_id, max_requests, window_secs) {
|
match self.check_rate_limit(client_id, max_requests, window_secs) {
|
||||||
Ok(true) => Ok(()),
|
Ok(true) => Ok(()),
|
||||||
Ok(false) => Err((
|
Ok(false) => Err(Box::new(
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
(
|
||||||
"rate limit exceeded, try again later",
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
)
|
"rate limit exceeded, try again later",
|
||||||
.into_response()),
|
)
|
||||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()),
|
.into_response(),
|
||||||
|
)),
|
||||||
|
Err(e) => Err(Box::new(
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +164,7 @@ impl RateLimitLayer {
|
|||||||
trust_proxy_headers: bool,
|
trust_proxy_headers: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
limiter: std::sync::Arc::new(RateLimiter::with_proxy_trust(
|
limiter: std::sync::Arc::new(RateLimiter::with_proxy_trust(trust_proxy_headers)),
|
||||||
trust_proxy_headers,
|
|
||||||
)),
|
|
||||||
max_requests,
|
max_requests,
|
||||||
window_secs,
|
window_secs,
|
||||||
}
|
}
|
||||||
@@ -273,7 +271,7 @@ where
|
|||||||
match limiter.check_or_429(&client_id, max_requests, window_secs) {
|
match limiter.check_or_429(&client_id, max_requests, window_secs) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(resp) => {
|
Err(resp) => {
|
||||||
return Box::pin(async move { Ok(resp) });
|
return Box::pin(async move { Ok(*resp) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
#![allow(
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
|
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
/// Write `text` to the terminal's clipboard using the OSC-52 escape sequence.
|
/// Write `text` to the terminal's clipboard using the OSC-52 escape sequence.
|
||||||
@@ -15,6 +8,10 @@ use std::io::{self, Write};
|
|||||||
///
|
///
|
||||||
/// The `output` parameter should be a writable handle to the terminal (e.g.
|
/// The `output` parameter should be a writable handle to the terminal (e.g.
|
||||||
/// `io::stdout()` or `io::stderr()`).
|
/// `io::stdout()` or `io::stderr()`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `io::Error` if writing to `output` or flushing fails.
|
||||||
pub fn write_osc52(output: &mut impl Write, text: &str) -> io::Result<()> {
|
pub fn write_osc52(output: &mut impl Write, text: &str) -> io::Result<()> {
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
|
|
||||||
@@ -37,13 +34,22 @@ mod tests {
|
|||||||
let output = String::from_utf8(buf).unwrap();
|
let output = String::from_utf8(buf).unwrap();
|
||||||
|
|
||||||
// Should start with OSC sequence
|
// Should start with OSC sequence
|
||||||
assert!(output.starts_with("\x1b]52;c;"), "should start with OSC52 prefix");
|
assert!(
|
||||||
|
output.starts_with("\x1b]52;c;"),
|
||||||
|
"should start with OSC52 prefix"
|
||||||
|
);
|
||||||
|
|
||||||
// Should have base64 payload
|
// Should have base64 payload
|
||||||
assert!(output.contains("aGVsbG8="), "should contain base64 of 'hello'");
|
assert!(
|
||||||
|
output.contains("aGVsbG8="),
|
||||||
|
"should contain base64 of 'hello'"
|
||||||
|
);
|
||||||
|
|
||||||
// Should end with ST
|
// Should end with ST
|
||||||
assert!(output.ends_with("\x1b\\"), "should end with string terminator");
|
assert!(
|
||||||
|
output.ends_with("\x1b\\"),
|
||||||
|
"should end with string terminator"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
#![allow(
|
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_possible_wrap
|
|
||||||
)]
|
|
||||||
|
|
||||||
use std::fs::{self, OpenOptions};
|
use std::fs::{self, OpenOptions};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -19,17 +12,16 @@ struct LogFileWriter {
|
|||||||
|
|
||||||
impl std::io::Write for LogFileWriter {
|
impl std::io::Write for LogFileWriter {
|
||||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
match OpenOptions::new()
|
if let Ok(mut file) = OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
.open(&self.path)
|
.open(&self.path)
|
||||||
{
|
{
|
||||||
Ok(mut file) => file.write(buf),
|
file.write(buf)
|
||||||
Err(_) => {
|
} else {
|
||||||
// fallback: write to /dev/null
|
// fallback: write to /dev/null
|
||||||
let mut null = fs::OpenOptions::new().write(true).open("/dev/null")?;
|
let mut null = fs::OpenOptions::new().write(true).open("/dev/null")?;
|
||||||
null.write(buf)
|
null.write(buf)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +53,15 @@ impl<'a> MakeWriter<'a> for LogFileWriter {
|
|||||||
/// never panics at startup.
|
/// never panics at startup.
|
||||||
///
|
///
|
||||||
/// The subscriber uses `RUST_LOG` / `ZESDEX_LOG` env-filtering.
|
/// The subscriber uses `RUST_LOG` / `ZESDEX_LOG` env-filtering.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if creating the log directory or initializing the tracing
|
||||||
|
/// subscriber fails unexpectedly.
|
||||||
pub fn init_logging() -> Result<(), anyhow::Error> {
|
pub fn init_logging() -> Result<(), anyhow::Error> {
|
||||||
// ── determine log directory ──────────────────────────────────────
|
// ── determine log directory ──────────────────────────────────────
|
||||||
let data_dir = dirs::data_dir()
|
let data_dir =
|
||||||
.map(|p| p.join("zesdex"))
|
dirs::data_dir().map_or_else(|| PathBuf::from("/tmp/zesdex"), |p| p.join("zesdex"));
|
||||||
.unwrap_or_else(|| PathBuf::from("/tmp/zesdex"));
|
|
||||||
|
|
||||||
let log_dir = data_dir.join("logs");
|
let log_dir = data_dir.join("logs");
|
||||||
|
|
||||||
@@ -73,7 +69,10 @@ pub fn init_logging() -> Result<(), anyhow::Error> {
|
|||||||
if let Err(e) = fs::create_dir_all(&log_dir) {
|
if let Err(e) = fs::create_dir_all(&log_dir) {
|
||||||
// If we can't create the directory, log via eprintln and continue
|
// If we can't create the directory, log via eprintln and continue
|
||||||
// with a /dev/null fallback.
|
// with a /dev/null fallback.
|
||||||
eprintln!("[zesdex-utils::logger] failed to create log dir {log_dir:?}: {e}");
|
eprintln!(
|
||||||
|
"[zesdex-utils::logger] failed to create log dir {}: {e}",
|
||||||
|
log_dir.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── build log file path ──────────────────────────────────────────
|
// ── build log file path ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub struct Paginated<T> {
|
|||||||
|
|
||||||
impl<T> Paginated<T> {
|
impl<T> Paginated<T> {
|
||||||
/// The total number of pages.
|
/// The total number of pages.
|
||||||
|
#[must_use]
|
||||||
pub fn total_pages(&self) -> usize {
|
pub fn total_pages(&self) -> usize {
|
||||||
if self.total == 0 {
|
if self.total == 0 {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -30,11 +31,13 @@ impl<T> Paginated<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether there is a next page.
|
/// Whether there is a next page.
|
||||||
|
#[must_use]
|
||||||
pub fn has_next(&self) -> bool {
|
pub fn has_next(&self) -> bool {
|
||||||
self.page < self.total_pages()
|
self.page < self.total_pages()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether there is a previous page.
|
/// Whether there is a previous page.
|
||||||
|
#[must_use]
|
||||||
pub fn has_prev(&self) -> bool {
|
pub fn has_prev(&self) -> bool {
|
||||||
self.page > 1
|
self.page > 1
|
||||||
}
|
}
|
||||||
@@ -46,6 +49,7 @@ impl<T> Paginated<T> {
|
|||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Panics if `page == 0` or `page_size == 0`.
|
/// Panics if `page == 0` or `page_size == 0`.
|
||||||
|
#[must_use]
|
||||||
pub fn paginate<T>(items: Vec<T>, page: usize, page_size: usize) -> Paginated<T> {
|
pub fn paginate<T>(items: Vec<T>, page: usize, page_size: usize) -> Paginated<T> {
|
||||||
assert!(page > 0, "page must be 1-based");
|
assert!(page > 0, "page must be 1-based");
|
||||||
assert!(page_size > 0, "page_size must be > 0");
|
assert!(page_size > 0, "page_size must be > 0");
|
||||||
@@ -70,6 +74,7 @@ pub fn paginate<T>(items: Vec<T>, page: usize, page_size: usize) -> Paginated<T>
|
|||||||
/// Compute the SQL offset/limit from 1-based page params.
|
/// Compute the SQL offset/limit from 1-based page params.
|
||||||
///
|
///
|
||||||
/// Returns `(offset, limit)`.
|
/// Returns `(offset, limit)`.
|
||||||
|
#[must_use]
|
||||||
pub fn page_params(page: usize, page_size: usize) -> (usize, usize) {
|
pub fn page_params(page: usize, page_size: usize) -> (usize, usize) {
|
||||||
let offset = page.saturating_sub(1) * page_size;
|
let offset = page.saturating_sub(1) * page_size;
|
||||||
(offset, page_size)
|
(offset, page_size)
|
||||||
|
|||||||
@@ -8,15 +8,16 @@
|
|||||||
/// Characters that are invalid in filenames on most operating systems.
|
/// Characters that are invalid in filenames on most operating systems.
|
||||||
const INVALID_FILENAME_CHARS: &[char] = &[
|
const INVALID_FILENAME_CHARS: &[char] = &[
|
||||||
'/', '\0', '<', '>', ':', '"', '\\', '|', '?', '*', '\x01', '\x02', '\x03', '\x04', '\x05',
|
'/', '\0', '<', '>', ':', '"', '\\', '|', '?', '*', '\x01', '\x02', '\x03', '\x04', '\x05',
|
||||||
'\x06', '\x07', '\x08', '\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10',
|
'\x06', '\x07', '\x08', '\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', '\x11',
|
||||||
'\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b',
|
'\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b', '\x1c', '\x1d',
|
||||||
'\x1c', '\x1d', '\x1e', '\x1f', '\x7f',
|
'\x1e', '\x1f', '\x7f',
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Replace characters that are invalid in filenames with `_`.
|
/// Replace characters that are invalid in filenames with `_`.
|
||||||
///
|
///
|
||||||
/// Also strips leading/trailing whitespace and dots, because those can be
|
/// Also strips leading/trailing whitespace and dots, because those can be
|
||||||
/// problematic on some filesystems.
|
/// problematic on some filesystems.
|
||||||
|
#[must_use]
|
||||||
pub fn sanitize_filename(s: &str) -> String {
|
pub fn sanitize_filename(s: &str) -> String {
|
||||||
let sanitized: String = s
|
let sanitized: String = s
|
||||||
.chars()
|
.chars()
|
||||||
@@ -43,6 +44,7 @@ pub fn sanitize_filename(s: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// Replaces `..` path components with `_`, collapses repeated separators,
|
/// Replaces `..` path components with `_`, collapses repeated separators,
|
||||||
/// and strips any leading `/` to keep the result relative.
|
/// and strips any leading `/` to keep the result relative.
|
||||||
|
#[must_use]
|
||||||
pub fn sanitize_path(path: &str) -> String {
|
pub fn sanitize_path(path: &str) -> String {
|
||||||
let mut cleaned = String::new();
|
let mut cleaned = String::new();
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ pub fn sanitize_path(path: &str) -> String {
|
|||||||
|
|
||||||
/// Escape HTML special characters so the string can be safely embedded in
|
/// Escape HTML special characters so the string can be safely embedded in
|
||||||
/// HTML or XML content.
|
/// HTML or XML content.
|
||||||
|
#[must_use]
|
||||||
pub fn sanitize_html(s: &str) -> String {
|
pub fn sanitize_html(s: &str) -> String {
|
||||||
let mut escaped = String::with_capacity(s.len());
|
let mut escaped = String::with_capacity(s.len());
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ pub fn sanitize_html(s: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// If `max_chars` is 0, returns an empty string. If the string is already
|
/// If `max_chars` is 0, returns an empty string. If the string is already
|
||||||
/// short enough, returns it unchanged.
|
/// short enough, returns it unchanged.
|
||||||
|
#[must_use]
|
||||||
pub fn truncate(s: &str, max_chars: usize) -> String {
|
pub fn truncate(s: &str, max_chars: usize) -> String {
|
||||||
if max_chars == 0 {
|
if max_chars == 0 {
|
||||||
return String::new();
|
return String::new();
|
||||||
@@ -110,6 +114,7 @@ pub fn truncate(s: &str, max_chars: usize) -> String {
|
|||||||
|
|
||||||
/// Validate that a session ID contains only alphanumeric characters, dashes,
|
/// Validate that a session ID contains only alphanumeric characters, dashes,
|
||||||
/// and underscores, and is non-empty.
|
/// and underscores, and is non-empty.
|
||||||
|
#[must_use]
|
||||||
pub fn is_valid_session_id(id: &str) -> bool {
|
pub fn is_valid_session_id(id: &str) -> bool {
|
||||||
if id.is_empty() {
|
if id.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
@@ -140,10 +145,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sanitize_path_removes_dotdot() {
|
fn test_sanitize_path_removes_dotdot() {
|
||||||
assert_eq!(
|
assert_eq!(sanitize_path("foo/../../bar"), "foo/_/_/bar");
|
||||||
sanitize_path("foo/../../bar"),
|
|
||||||
"foo/_/_/bar"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const MAX_SLUG_LENGTH: usize = 80;
|
|||||||
/// 5. Truncate to 80 characters, breaking at the last full word if possible.
|
/// 5. Truncate to 80 characters, breaking at the last full word if possible.
|
||||||
///
|
///
|
||||||
/// Returns `None` if the slug would be completely empty.
|
/// Returns `None` if the slug would be completely empty.
|
||||||
|
#[must_use]
|
||||||
pub fn slugify(s: &str) -> Option<String> {
|
pub fn slugify(s: &str) -> Option<String> {
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -74,6 +75,7 @@ pub fn slugify(s: &str) -> Option<String> {
|
|||||||
/// Join `base` with a slugified version of `name`.
|
/// Join `base` with a slugified version of `name`.
|
||||||
///
|
///
|
||||||
/// If `slugify(name)` returns `None`, the name is used as-is (lowercased).
|
/// If `slugify(name)` returns `None`, the name is used as-is (lowercased).
|
||||||
|
#[must_use]
|
||||||
pub fn slug_path(base: &Path, name: &str) -> PathBuf {
|
pub fn slug_path(base: &Path, name: &str) -> PathBuf {
|
||||||
match slugify(name) {
|
match slugify(name) {
|
||||||
Some(slug) => base.join(slug),
|
Some(slug) => base.join(slug),
|
||||||
@@ -131,6 +133,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_slug_path() {
|
fn test_slug_path() {
|
||||||
let base = Path::new("/tmp");
|
let base = Path::new("/tmp");
|
||||||
assert_eq!(slug_path(base, "Hello World"), Path::new("/tmp/hello-world"));
|
assert_eq!(
|
||||||
|
slug_path(base, "Hello World"),
|
||||||
|
Path::new("/tmp/hello-world")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user