Implement chat and markdown views, enhance status bar, and add workflow panel
- Added `chat.rs` for rendering chat messages with timestamps and roles. - Introduced `markdown.rs` for rendering markdown content with styling. - Created `status.rs` to display the application status bar with session and message counts. - Developed `workflow.rs` to show the current workflow status, including tool calls and active jobs. - Established a `theme.rs` for centralized color management across the UI. - Updated `mod.rs` to include new modules and manage rendering logic.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
.env
|
||||
Generated
+4193
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "zesdex"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.30.2"
|
||||
crossterm = "0.28"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "blocking", "native-tls-vendored"] }
|
||||
dom_smoothie = "0.18.0"
|
||||
fast_html2md = "0.0.62"
|
||||
scraper = "0.27.0"
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.9"
|
||||
anyhow = "1"
|
||||
include_dir = "0.7"
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
dirs = "5"
|
||||
futures-util = "0.3"
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
ignore = "0.4"
|
||||
regex = "1"
|
||||
globset = "0.4"
|
||||
infer = "0.16"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
libc = "0.2"
|
||||
rmcp = { version = "1.8", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[[bin]]
|
||||
name = "zesdex"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::job::BashJob;
|
||||
|
||||
pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> {
|
||||
static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new();
|
||||
JOBS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||
let mut map = bash_jobs_map().lock().ok()?;
|
||||
let job = map.get_mut(id)?;
|
||||
let mut lines = Vec::new();
|
||||
while let Some(line) = job.try_read_line() {
|
||||
lines.push(line);
|
||||
}
|
||||
if lines.is_empty() { None } else { Some(lines) }
|
||||
}
|
||||
|
||||
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
||||
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
|
||||
let job = map.remove(id);
|
||||
if job.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("bash job '{}' not found", id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_bash_job(job: BashJob) -> String {
|
||||
let id = job.id.clone();
|
||||
if let Ok(mut map) = bash_jobs_map().lock() {
|
||||
map.insert(id.clone(), job);
|
||||
}
|
||||
id
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::io::BufRead;
|
||||
|
||||
pub struct BashJob {
|
||||
pub id: String,
|
||||
pub command: String,
|
||||
pub started_at: i64,
|
||||
pub output_rx: mpsc::Receiver<String>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let started_at = chrono::Utc::now().timestamp_millis();
|
||||
let (output_tx, output_rx) = mpsc::channel::<String>();
|
||||
let cmd = command.clone();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
match child {
|
||||
Ok(mut child) => {
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let _ = output_tx.send(line);
|
||||
}
|
||||
}
|
||||
let status = child.wait();
|
||||
let code = status.ok().and_then(|s| s.code());
|
||||
let _ = output_tx.send(format!("__exit:{}", code.unwrap_or(-1)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = output_tx.send(format!("__error:{}", e));
|
||||
let _ = output_tx.send("__exit:-1".to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
BashJob {
|
||||
id,
|
||||
command,
|
||||
started_at,
|
||||
output_rx,
|
||||
exit_code: None,
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
|
||||
impl BashJob {
|
||||
pub fn try_read_line(&mut self) -> Option<String> {
|
||||
match self.output_rx.try_recv() {
|
||||
Ok(line) => {
|
||||
if line.starts_with("__exit:") {
|
||||
self.exit_code = line.strip_prefix("__exit:").and_then(|s| s.parse().ok());
|
||||
None
|
||||
} else {
|
||||
Some(line)
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.exit_code.is_none()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod control;
|
||||
pub mod job;
|
||||
@@ -0,0 +1,152 @@
|
||||
use std::path::Path;
|
||||
|
||||
pub struct CatastrophicGuard;
|
||||
|
||||
impl CatastrophicGuard {
|
||||
pub fn check_git_operation(cmd: &str) -> Result<(), String> {
|
||||
let patterns = [
|
||||
"force-push",
|
||||
"reset --hard",
|
||||
"clean -f",
|
||||
"clean -d",
|
||||
"clean -x",
|
||||
"branch -D",
|
||||
"branch --delete --force",
|
||||
"checkout --force",
|
||||
"switch -f",
|
||||
"restore --force",
|
||||
"stash drop",
|
||||
"stash clear",
|
||||
"tag -d",
|
||||
"tag --delete",
|
||||
"update-ref -d",
|
||||
"filter-branch",
|
||||
"gc --prune",
|
||||
"gc --aggressive",
|
||||
"push --delete",
|
||||
"push --force",
|
||||
"push origin :",
|
||||
"push +refs",
|
||||
];
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
for pattern in patterns {
|
||||
if cmd_lower.contains(pattern) {
|
||||
return Err(format!("catastrophic git operation blocked: '{}'", pattern));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_shell_command(cmd: &str) -> Result<(), String> {
|
||||
let dangerous = [
|
||||
":(){ :|:& };:",
|
||||
"> /dev/sda",
|
||||
"dd if=",
|
||||
"mkfs.",
|
||||
"format ",
|
||||
"fdisk",
|
||||
"parted",
|
||||
"mkswap",
|
||||
"swapoff",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"poweroff",
|
||||
"init 0",
|
||||
"init 6",
|
||||
"halt",
|
||||
"> /dev/mem",
|
||||
"> /dev/kmem",
|
||||
"chmod 000",
|
||||
"chown -R 0:0",
|
||||
];
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
for pattern in dangerous {
|
||||
if cmd_lower.contains(pattern) {
|
||||
return Err(format!("catastrophic shell command blocked: '{}'", pattern));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_delete_path(path: &Path, _workspace_roots: &[&Path]) -> Result<(), String> {
|
||||
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
if canon == *"/"
|
||||
|| canon == *"/home"
|
||||
|| canon == *"/root"
|
||||
{
|
||||
return Err("catastrophic delete blocked: system directory".to_string());
|
||||
}
|
||||
let in_workspace = _workspace_roots.iter().any(|w| {
|
||||
let wc = w.canonicalize().unwrap_or_else(|_| w.to_path_buf());
|
||||
canon.starts_with(&wc)
|
||||
});
|
||||
if !in_workspace {
|
||||
return Err("catastrophic delete blocked: outside all workspace roots".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_credential_pattern(cmd: &str) -> Result<(), String> {
|
||||
let patterns = [
|
||||
"cat ~/.ssh",
|
||||
"cat /home/",
|
||||
".ssh/id_rsa",
|
||||
".ssh/id_ed25519",
|
||||
".ssh/authorized_keys",
|
||||
".git-credentials",
|
||||
".netrc",
|
||||
"aws/credentials",
|
||||
"gcloud/credentials",
|
||||
".config/gcloud",
|
||||
".config/gh",
|
||||
"token=",
|
||||
"secret=",
|
||||
"api_key=",
|
||||
"api-key=",
|
||||
"password=",
|
||||
];
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
for pattern in patterns {
|
||||
if cmd_lower.contains(pattern) {
|
||||
return Err(format!("credential read blocked: '{}'", pattern));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_download_path(path: &Path) -> Result<(), String> {
|
||||
let name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
let sensitive = [
|
||||
"id_rsa",
|
||||
"id_ed25519",
|
||||
"authorized_keys",
|
||||
"known_hosts",
|
||||
".netrc",
|
||||
".git-credentials",
|
||||
"credentials.json",
|
||||
"service-account",
|
||||
"secret",
|
||||
"key.pem",
|
||||
"key.p8",
|
||||
"id_ecdsa",
|
||||
"id_dsa",
|
||||
"config",
|
||||
];
|
||||
let name_lower = name.to_lowercase();
|
||||
for s in &sensitive {
|
||||
if name_lower.contains(s) {
|
||||
return Err(format!("sensitive download blocked: '{}'", s));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_all(cmd: &str, _workspace_roots: &[&Path]) -> Result<(), String> {
|
||||
Self::check_shell_command(cmd)?;
|
||||
Self::check_git_operation(cmd)?;
|
||||
Self::check_credential_pattern(cmd)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Verdict {
|
||||
Allow,
|
||||
Block(String),
|
||||
Escalate,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
pub fn is_allowed(&self) -> bool {
|
||||
matches!(self, Verdict::Allow)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Harness;
|
||||
|
||||
impl Harness {
|
||||
pub fn classify(_cmd: &str, mode: &super::state::types::AgentMode) -> Verdict {
|
||||
if mode.auto_approve() {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
Verdict::Allow
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_verdict(text: &str) -> Option<Verdict> {
|
||||
let trimmed = text.trim();
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if let Some(verdict) = v.get("verdict").and_then(|v| v.as_str()) {
|
||||
return match verdict.to_lowercase().as_str() {
|
||||
"allow" => Some(Verdict::Allow),
|
||||
"block" => Some(Verdict::Block(
|
||||
v.get("reason").and_then(|r| r.as_str()).unwrap_or("blocked").to_string()
|
||||
)),
|
||||
"escalate" => Some(Verdict::Escalate),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
for line in trimmed.lines() {
|
||||
let l = line.trim().to_lowercase();
|
||||
if l.starts_with("verdict: allow") {
|
||||
return Some(Verdict::Allow);
|
||||
}
|
||||
if l.starts_with("verdict: block") {
|
||||
let reason = line.split_once(':').map(|x| x.1).unwrap_or("blocked").trim().to_string();
|
||||
return Some(Verdict::Block(reason));
|
||||
}
|
||||
}
|
||||
if trimmed.to_lowercase().contains("allow") {
|
||||
return Some(Verdict::Allow);
|
||||
}
|
||||
if trimmed.to_lowercase().contains("block") {
|
||||
return Some(Verdict::Block("blocked by classifier".to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn classify(_cmd: &str, mode: &super::state::types::AgentMode) -> Verdict {
|
||||
Harness::classify(_cmd, mode)
|
||||
}
|
||||
|
||||
impl Default for Harness {
|
||||
fn default() -> Self {
|
||||
Harness
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use serde_json::Value;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum McpTransport {
|
||||
Stdio {
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
StreamableHttp {
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServer {
|
||||
pub name: String,
|
||||
pub transport: McpTransport,
|
||||
pub tools: Vec<McpToolInfo>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
pub fn new(name: String, transport: McpTransport) -> Self {
|
||||
McpServer {
|
||||
name,
|
||||
transport,
|
||||
tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpManager {
|
||||
pub servers: Vec<McpServer>,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
pub fn new() -> Self {
|
||||
McpManager {
|
||||
servers: Vec::new(),
|
||||
running: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_server(&mut self, server: McpServer) {
|
||||
self.servers.push(server);
|
||||
}
|
||||
|
||||
pub fn remove_server(&mut self, name: &str) {
|
||||
self.servers.retain(|s| s.name != name);
|
||||
}
|
||||
|
||||
pub fn get_server(&self, name: &str) -> Option<&McpServer> {
|
||||
self.servers.iter().find(|s| s.name == name)
|
||||
}
|
||||
|
||||
pub fn all_tools(&self) -> Vec<&McpToolInfo> {
|
||||
self.servers.iter().flat_map(|s| s.tools.iter()).collect()
|
||||
}
|
||||
|
||||
pub fn start_all(&mut self) -> anyhow::Result<()> {
|
||||
self.running = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_all(&mut self) -> anyhow::Result<()> {
|
||||
self.running = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod manager;
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod catastrophic;
|
||||
pub mod harness;
|
||||
pub mod mode;
|
||||
pub mod runtime;
|
||||
pub mod state;
|
||||
pub mod workflow;
|
||||
pub mod subagent;
|
||||
pub mod review;
|
||||
pub mod bgbash;
|
||||
pub mod mcp;
|
||||
pub mod sec;
|
||||
@@ -0,0 +1,73 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod agents;
|
||||
pub mod bash;
|
||||
pub mod editor;
|
||||
pub mod effort;
|
||||
pub mod help;
|
||||
pub mod key_input;
|
||||
pub mod loading;
|
||||
pub mod mcp;
|
||||
pub mod onboard;
|
||||
pub mod onboard_provider;
|
||||
pub mod picker;
|
||||
pub mod quit_confirm;
|
||||
pub mod rewind;
|
||||
pub mod security;
|
||||
pub mod session_hub;
|
||||
pub mod settings;
|
||||
pub mod todo;
|
||||
pub mod workflow;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ModeKind {
|
||||
Chat,
|
||||
Agents,
|
||||
Bash,
|
||||
Workflow,
|
||||
Help,
|
||||
Settings,
|
||||
SessionHub,
|
||||
QuitConfirm,
|
||||
Onboard,
|
||||
OnboardProvider,
|
||||
Picker,
|
||||
KeyInput,
|
||||
Editor,
|
||||
Effort,
|
||||
Mcp,
|
||||
Security,
|
||||
Todo,
|
||||
Rewind,
|
||||
Loading,
|
||||
}
|
||||
|
||||
impl ModeKind {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ModeKind::Chat => "Chat",
|
||||
ModeKind::Agents => "Agents",
|
||||
ModeKind::Bash => "Bash",
|
||||
ModeKind::Workflow => "Workflow",
|
||||
ModeKind::Help => "Help",
|
||||
ModeKind::Settings => "Settings",
|
||||
ModeKind::SessionHub => "SessionHub",
|
||||
ModeKind::QuitConfirm => "QuitConfirm",
|
||||
ModeKind::Onboard => "Onboard",
|
||||
ModeKind::OnboardProvider => "OnboardProvider",
|
||||
ModeKind::Picker => "Picker",
|
||||
ModeKind::KeyInput => "KeyInput",
|
||||
ModeKind::Editor => "Editor",
|
||||
ModeKind::Effort => "Effort",
|
||||
ModeKind::Mcp => "MCP",
|
||||
ModeKind::Security => "Security",
|
||||
ModeKind::Todo => "Todo",
|
||||
ModeKind::Rewind => "Rewind",
|
||||
ModeKind::Loading => "Loading",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_overlay(self) -> bool {
|
||||
!matches!(self, ModeKind::Chat | ModeKind::Agents | ModeKind::Bash | ModeKind::Workflow)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use std::collections::HashMap;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Confidence {
|
||||
Human,
|
||||
Verified,
|
||||
Unverified,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum LessonLifecycle {
|
||||
New,
|
||||
Active,
|
||||
Stale,
|
||||
Contradicted,
|
||||
Superseded,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum LessonScope {
|
||||
Project,
|
||||
Global,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Provenance {
|
||||
pub session_turn: String,
|
||||
pub session_id: String,
|
||||
pub reviewer: Origin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Lesson {
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
pub confidence: Confidence,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: LessonLifecycle,
|
||||
pub scope: LessonScope,
|
||||
pub contradiction_with: Option<String>,
|
||||
pub provenance: Provenance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ViolationEscalation {
|
||||
None,
|
||||
Warning,
|
||||
Escalate,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ShadowStatus {
|
||||
Trial,
|
||||
Graduated,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShadowCheck {
|
||||
pub pattern: String,
|
||||
pub trial_window: u32,
|
||||
pub trial_count: u32,
|
||||
pub trial_passed: u32,
|
||||
pub status: ShadowStatus,
|
||||
}
|
||||
|
||||
pub struct ReviewSystem {
|
||||
pub pending: bool,
|
||||
pub queue_capacity: usize,
|
||||
pub repeated_violations: HashMap<String, u32>,
|
||||
pub shadow_violations: Vec<ShadowCheck>,
|
||||
pub violation_window: u32,
|
||||
}
|
||||
|
||||
impl ReviewSystem {
|
||||
pub fn new() -> Self {
|
||||
ReviewSystem {
|
||||
pending: false,
|
||||
queue_capacity: 1,
|
||||
repeated_violations: HashMap::new(),
|
||||
shadow_violations: Vec::new(),
|
||||
violation_window: 10,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.pending = false;
|
||||
}
|
||||
|
||||
pub fn check_escalation(&self, pattern: &str) -> ViolationEscalation {
|
||||
let count = self.repeated_violations.get(pattern).copied().unwrap_or(0);
|
||||
match count {
|
||||
0 | 1 => ViolationEscalation::None,
|
||||
2 => ViolationEscalation::Warning,
|
||||
3 | 4 => ViolationEscalation::Escalate,
|
||||
_ => ViolationEscalation::Block,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_violation(&mut self, pattern: &str) -> ViolationEscalation {
|
||||
let entry = self.repeated_violations.entry(pattern.to_string()).or_insert(0);
|
||||
*entry += 1;
|
||||
self.check_escalation(pattern)
|
||||
}
|
||||
|
||||
pub fn should_skip_review(consecutive_empty: u32) -> bool {
|
||||
consecutive_empty >= 3
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_pending_lesson(name: &str, content: &str, provenance: Provenance) -> Lesson {
|
||||
Lesson {
|
||||
name: name.to_string(),
|
||||
content: content.to_string(),
|
||||
confidence: Confidence::Unverified,
|
||||
outcome: None,
|
||||
lifecycle: LessonLifecycle::New,
|
||||
scope: LessonScope::Project,
|
||||
contradiction_with: None,
|
||||
provenance,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_lesson_calibration(state: &mut AppStateRest, lesson: &Lesson) {
|
||||
if lesson.confidence != Confidence::Unverified {
|
||||
return;
|
||||
}
|
||||
if state.mode.auto_approve() {
|
||||
return;
|
||||
}
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Lesson,
|
||||
format!("Lesson '{}' is pending review. Keep or discard?", lesson.name),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
if state.mode == AgentMode::Plan {
|
||||
return false;
|
||||
}
|
||||
if origin != Origin::Main {
|
||||
return false;
|
||||
}
|
||||
let runtime = match &state.session_runtime {
|
||||
Some(r) => r,
|
||||
None => return false,
|
||||
};
|
||||
if !state.settings.review_enabled {
|
||||
return false;
|
||||
}
|
||||
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
||||
return true;
|
||||
}
|
||||
if runtime.consecutive_empty_reviews >= state.settings.adaptive_review_max_skip {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
let _origin = Origin::Reviewer;
|
||||
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
|
||||
crate::dto::chat::message::Role::System,
|
||||
"review triggered".to_string(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
use crate::app::mode::ModeKind;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[expect(dead_code)]
|
||||
pub enum Action {
|
||||
Quit,
|
||||
ForceQuit,
|
||||
SwitchMode(ModeKind),
|
||||
SubmitInput(String),
|
||||
InsertChar(char),
|
||||
DeleteChar,
|
||||
DeleteCharRight,
|
||||
CursorLeft,
|
||||
CursorRight,
|
||||
HistoryUp,
|
||||
HistoryDown,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
OpenOverlay(Overlay),
|
||||
CloseOverlay,
|
||||
ToggleYoloArm,
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
},
|
||||
StreamToken(String),
|
||||
StreamDone,
|
||||
StreamError(String),
|
||||
SystemNote {
|
||||
kind: String,
|
||||
message: String,
|
||||
},
|
||||
RunCommand(String),
|
||||
QuitConfirm,
|
||||
Resize(u16, u16),
|
||||
Tick,
|
||||
RecordUsage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
duration_ms: u64,
|
||||
},
|
||||
RecordReviewTokens {
|
||||
tokens: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
match action {
|
||||
Action::Quit => {
|
||||
state.quit = true;
|
||||
}
|
||||
Action::ForceQuit => {
|
||||
state.quit = true;
|
||||
}
|
||||
Action::SwitchMode(mode) => {
|
||||
state.misc.overlay = match mode {
|
||||
ModeKind::Chat
|
||||
| ModeKind::Agents
|
||||
| ModeKind::Bash
|
||||
| ModeKind::Workflow => Overlay::None,
|
||||
ModeKind::Help => Overlay::Help,
|
||||
ModeKind::Settings => Overlay::Settings,
|
||||
ModeKind::SessionHub => Overlay::SessionHub,
|
||||
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
||||
ModeKind::Onboard => Overlay::Onboard,
|
||||
ModeKind::OnboardProvider => Overlay::OnboardProvider,
|
||||
ModeKind::Picker => Overlay::Picker,
|
||||
ModeKind::KeyInput => Overlay::KeyInput,
|
||||
ModeKind::Editor => Overlay::Editor,
|
||||
ModeKind::Effort => Overlay::Effort,
|
||||
ModeKind::Mcp => Overlay::Mcp,
|
||||
ModeKind::Security => Overlay::Security,
|
||||
ModeKind::Todo => Overlay::Todo,
|
||||
ModeKind::Rewind => Overlay::Rewind,
|
||||
ModeKind::Loading => Overlay::Loading,
|
||||
};
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::SubmitInput(text) => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.push_message(ChatMessage::user(text.clone()));
|
||||
let api_key = state.settings.api_key.clone();
|
||||
let model = state.settings.model.clone();
|
||||
let msgs = rt.messages.clone();
|
||||
let pending = state.pending_api_response.clone();
|
||||
if let Some(key) = api_key {
|
||||
if !key.is_empty() {
|
||||
std::thread::spawn(move || {
|
||||
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
|
||||
match client.chat(&msgs) {
|
||||
Ok(response) => {
|
||||
if let Ok(mut guard) = pending.lock() {
|
||||
*guard = Some(response);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Ok(mut guard) = pending.lock() {
|
||||
*guard = Some(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::InsertChar(c) => {
|
||||
state.input.insert(c);
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::DeleteChar => {
|
||||
state.input.delete_left();
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::DeleteCharRight => {
|
||||
state.input.delete_right();
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::CursorLeft => {
|
||||
state.input.char_left();
|
||||
}
|
||||
Action::CursorRight => {
|
||||
state.input.char_right();
|
||||
}
|
||||
Action::HistoryUp => {
|
||||
state.input.history_up();
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::HistoryDown => {
|
||||
state.input.history_down();
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::ScrollUp => {
|
||||
let total = state.transcript_cache.messages.len();
|
||||
state.scroll.scroll_up();
|
||||
state.scroll.scroll_down(total);
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::ScrollDown => {
|
||||
let total = state.transcript_cache.messages.len();
|
||||
state.scroll.scroll_down(total);
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::OpenOverlay(overlay) => {
|
||||
state.misc.overlay = overlay;
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::CloseOverlay => {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::ToggleYoloArm => {
|
||||
state.misc.yolo_armed = !state.misc.yolo_armed;
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::ToolResult {
|
||||
tool_call_id,
|
||||
output,
|
||||
is_error,
|
||||
} => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone()));
|
||||
rt.tool_call_results.push(
|
||||
crate::app::state::runtime::ToolCallResult {
|
||||
tool_call_id,
|
||||
tool_name: String::new(),
|
||||
output,
|
||||
is_error,
|
||||
duration_ms: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::StreamToken(token) => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
let found = rt.messages.iter_mut().rev().find(|m| {
|
||||
matches!(m.role, crate::dto::chat::message::Role::Assistant)
|
||||
});
|
||||
if let Some(last) = found {
|
||||
let current = last.content.take().unwrap_or_default();
|
||||
last.content = Some(current + &token);
|
||||
} else {
|
||||
let mut msg = ChatMessage::assistant(None);
|
||||
msg.content = Some(token);
|
||||
rt.push_message(msg);
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::StreamDone => {
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::StreamError(msg) => {
|
||||
let toast = crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
msg,
|
||||
);
|
||||
state.push_toast(toast);
|
||||
}
|
||||
Action::SystemNote { kind: _kind, message } => {
|
||||
let toast = crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Info,
|
||||
message,
|
||||
);
|
||||
state.push_toast(toast);
|
||||
}
|
||||
Action::RunCommand(text) => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.push_message(ChatMessage::user(text));
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::QuitConfirm => {
|
||||
state.misc.overlay = Overlay::QuitConfirm;
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::Resize(w, _h) => {
|
||||
state.scroll.set_max_visible(w as usize);
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::Tick => {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now_ms);
|
||||
let api_response = if let Ok(mut guard) = state.pending_api_response.lock() {
|
||||
guard.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(response) = api_response {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
if response.starts_with("Error:") {
|
||||
let toast = crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
response,
|
||||
);
|
||||
state.push_toast(toast);
|
||||
} else {
|
||||
rt.push_message(ChatMessage::assistant(Some(response)));
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
Action::RecordUsage { tokens_in, tokens_out, duration_ms } => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.record_api_call(tokens_in, tokens_out, duration_ms);
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::RecordReviewTokens { tokens } => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.record_review_tokens(tokens);
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod sessions;
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod actions;
|
||||
pub mod commands;
|
||||
pub mod event_loop;
|
||||
pub mod shortsend;
|
||||
pub mod stream;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod tools;
|
||||
pub mod turn;
|
||||
@@ -0,0 +1,29 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct SecDaemon {
|
||||
pub pid: Option<u32>,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
impl SecDaemon {
|
||||
pub fn new() -> Self {
|
||||
SecDaemon {
|
||||
pid: None,
|
||||
running: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
self.running = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) -> Result<()> {
|
||||
self.running = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health_check() -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod daemon;
|
||||
@@ -0,0 +1,40 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateDiff {
|
||||
changes: Vec<Change>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Change {
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
impl StateDiff {
|
||||
pub fn new() -> Self {
|
||||
StateDiff { changes: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn add_change(&mut self, path: String, kind: String) {
|
||||
self.changes.push(Change { path, kind });
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changes.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.changes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec<Change> {
|
||||
if before == after {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Change {
|
||||
path: ".".to_string(),
|
||||
kind: "modified".to_string(),
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use super::types::Overlay;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DirCache {
|
||||
entries: Arc<RwLock<Vec<PathBuf>>>,
|
||||
}
|
||||
|
||||
impl DirCache {
|
||||
pub fn new() -> Self {
|
||||
DirCache {
|
||||
entries: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set(&self, paths: Vec<PathBuf>) {
|
||||
let mut w = self.entries.write().await;
|
||||
*w = paths;
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> Vec<PathBuf> {
|
||||
let r = self.entries.read().await;
|
||||
r.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollState {
|
||||
pub offset: usize,
|
||||
pub max_visible: usize,
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
pub fn new() -> Self {
|
||||
ScrollState {
|
||||
offset: 0,
|
||||
max_visible: 30,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self) {
|
||||
if self.offset > 0 {
|
||||
self.offset -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self, total: usize) {
|
||||
let max_offset = total.saturating_sub(self.max_visible);
|
||||
if self.offset < max_offset {
|
||||
self.offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self, total: usize) {
|
||||
self.offset = total.saturating_sub(self.max_visible);
|
||||
}
|
||||
|
||||
pub fn set_max_visible(&mut self, max: usize) {
|
||||
self.max_visible = max;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
pub buffer: String,
|
||||
pub cursor: usize,
|
||||
pub history: Vec<String>,
|
||||
pub history_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
pub fn new() -> Self {
|
||||
InputState {
|
||||
buffer: String::new(),
|
||||
cursor: 0,
|
||||
history: Vec::new(),
|
||||
history_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn char_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn char_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, c: char) {
|
||||
self.buffer.insert(self.cursor, c);
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(&mut self) -> String {
|
||||
let result = self.buffer.clone();
|
||||
if !result.is_empty() {
|
||||
self.history.push(result.clone());
|
||||
self.history_idx = None;
|
||||
}
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
result
|
||||
}
|
||||
|
||||
pub fn history_up(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let idx = match self.history_idx {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
None => self.history.len() - 1,
|
||||
Some(_) => return,
|
||||
};
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
pub fn history_down(&mut self) {
|
||||
match self.history_idx {
|
||||
Some(i) if i < self.history.len() - 1 => {
|
||||
let idx = i + 1;
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
Some(_) => {
|
||||
self.history_idx = None;
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
self.history_idx = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiscState {
|
||||
pub overlay: Overlay,
|
||||
pub toasts: Vec<super::types::Toast>,
|
||||
pub dirty: bool,
|
||||
pub yolo_armed: bool,
|
||||
pub security_armed: bool,
|
||||
pub security_acknowledged: bool,
|
||||
pub esc_press_count: u32,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
pub fn new() -> Self {
|
||||
MiscState {
|
||||
overlay: Overlay::None,
|
||||
toasts: Vec::new(),
|
||||
dirty: true,
|
||||
yolo_armed: false,
|
||||
security_armed: false,
|
||||
security_acknowledged: false,
|
||||
esc_press_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_toast(&mut self, toast: super::types::Toast) {
|
||||
self.toasts.push(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();
|
||||
self.toasts.retain(|t| !t.expired(now_ms));
|
||||
expired
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod input;
|
||||
pub mod misc;
|
||||
pub mod rest;
|
||||
pub mod runtime;
|
||||
pub mod scroll;
|
||||
pub mod diff;
|
||||
pub mod snapshot;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::misc::{DirCache, InputState, MiscState, ScrollState};
|
||||
use super::runtime::SessionRuntime;
|
||||
use super::types::{AgentMode, Origin, Toast, TranscriptCache};
|
||||
use crate::model::editlog::EditLog;
|
||||
use crate::model::settings::Settings;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChatMessageDisplay {
|
||||
pub role: crate::dto::chat::message::Role,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl ChatMessageDisplay {
|
||||
pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self {
|
||||
ChatMessageDisplay {
|
||||
role,
|
||||
content,
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CronJob {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub cron_expr: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppStateRest {
|
||||
pub mode: AgentMode,
|
||||
pub settings: Settings,
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
pub session_dir: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub download_dir: PathBuf,
|
||||
pub worktrees_dir: PathBuf,
|
||||
pub current_dir: PathBuf,
|
||||
pub dir_cache: Arc<RwLock<DirCache>>,
|
||||
pub edit_log: EditLog,
|
||||
pub session_runtime: Option<SessionRuntime>,
|
||||
pub sessions: Vec<crate::model::session::Session>,
|
||||
pub crons: Vec<CronJob>,
|
||||
pub transcript_cache: TranscriptCache,
|
||||
pub scroll: ScrollState,
|
||||
pub input: InputState,
|
||||
pub misc: MiscState,
|
||||
pub pending_api_response: Arc<Mutex<Option<String>>>,
|
||||
pub dirty: bool,
|
||||
pub quit: bool,
|
||||
}
|
||||
|
||||
impl AppStateRest {
|
||||
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
|
||||
let settings = Settings::load();
|
||||
let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads");
|
||||
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
|
||||
let dir_cache = DirCache::new();
|
||||
AppStateRest {
|
||||
mode: AgentMode::Normal,
|
||||
settings,
|
||||
workspace_roots,
|
||||
session_dir: session_dir.clone(),
|
||||
memory_dir,
|
||||
download_dir,
|
||||
worktrees_dir,
|
||||
current_dir: std::env::current_dir().unwrap_or_default(),
|
||||
pending_api_response: Arc::new(Mutex::new(None)),
|
||||
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||
edit_log: EditLog::new(&session_dir),
|
||||
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
||||
sessions: Vec::new(),
|
||||
crons: Vec::new(),
|
||||
transcript_cache: TranscriptCache::new(200),
|
||||
scroll: ScrollState::new(),
|
||||
input: InputState::new(),
|
||||
misc: MiscState::new(),
|
||||
dirty: true,
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> AgentMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: AgentMode) {
|
||||
self.mode = mode;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||||
self.transcript_cache.messages.push(msg);
|
||||
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
|
||||
self.transcript_cache.messages.remove(0);
|
||||
}
|
||||
self.transcript_cache.dirty = true;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn push_toast(&mut self, toast: Toast) {
|
||||
self.misc.push_toast(toast);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
|
||||
crate::tool::ToolCtx {
|
||||
workspaces: self.workspace_roots.clone(),
|
||||
session_dir: self.session_dir.clone(),
|
||||
memory_dir: self.memory_dir.clone(),
|
||||
download_dir: self.download_dir.clone(),
|
||||
worktrees_dir: self.worktrees_dir.clone(),
|
||||
dir_cache: self.dir_cache.clone(),
|
||||
internet_mode: self.settings.internet_mode.clone(),
|
||||
origin: Origin::Main,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct UsageStats {
|
||||
pub tokens_in: u64,
|
||||
pub tokens_out: u64,
|
||||
pub api_calls: u64,
|
||||
pub review_tokens: u64,
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionRuntime {
|
||||
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
|
||||
pub tool_call_results: Vec<ToolCallResult>,
|
||||
pub pending_tool_queue: Vec<PendingTool>,
|
||||
pub bash_jobs: Vec<BashJobRef>,
|
||||
pub subagent_queue: usize,
|
||||
pub edit_count: u32,
|
||||
pub consecutive_empty_reviews: u32,
|
||||
pub session_start: i64,
|
||||
pub lesson_count: u32,
|
||||
pub lessons_user: u32,
|
||||
pub lessons_feedback: u32,
|
||||
pub lessons_project: u32,
|
||||
pub lessons_reference: u32,
|
||||
pub lessons_active: u32,
|
||||
pub lessons_stale: u32,
|
||||
pub lessons_contradicted: u32,
|
||||
pub lessons_human: u32,
|
||||
pub lessons_verified: u32,
|
||||
pub lessons_unverified: u32,
|
||||
pub review_count: u32,
|
||||
pub session_dir: PathBuf,
|
||||
pub usage: UsageStats,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingTool {
|
||||
pub tool_name: String,
|
||||
pub args: serde_json::Value,
|
||||
pub execution_model: crate::app::state::types::ExecutionModel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BashJobRef {
|
||||
pub id: String,
|
||||
pub command: String,
|
||||
pub started_at: i64,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
impl SessionRuntime {
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
SessionRuntime {
|
||||
messages: Vec::new(),
|
||||
tool_call_results: Vec::new(),
|
||||
pending_tool_queue: Vec::new(),
|
||||
bash_jobs: Vec::new(),
|
||||
subagent_queue: 0,
|
||||
edit_count: 0,
|
||||
consecutive_empty_reviews: 0,
|
||||
session_start: chrono::Utc::now().timestamp_millis(),
|
||||
lesson_count: 0,
|
||||
lessons_user: 0,
|
||||
lessons_feedback: 0,
|
||||
lessons_project: 0,
|
||||
lessons_reference: 0,
|
||||
lessons_active: 0,
|
||||
lessons_stale: 0,
|
||||
lessons_contradicted: 0,
|
||||
lessons_human: 0,
|
||||
lessons_verified: 0,
|
||||
lessons_unverified: 0,
|
||||
review_count: 0,
|
||||
session_dir,
|
||||
usage: UsageStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
pub fn record_api_call(&mut self, tokens_in: u64, tokens_out: u64, duration_ms: u64) {
|
||||
self.usage.tokens_in += tokens_in;
|
||||
self.usage.tokens_out += tokens_out;
|
||||
self.usage.api_calls += 1;
|
||||
self.usage.total_ms += duration_ms;
|
||||
}
|
||||
|
||||
pub fn record_review_tokens(&mut self, tokens: u64) {
|
||||
self.usage.review_tokens += tokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateSnapshot {
|
||||
pub snapshot: serde_json::Value,
|
||||
}
|
||||
|
||||
impl StateSnapshot {
|
||||
pub fn new() -> Self {
|
||||
StateSnapshot {
|
||||
snapshot: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec(snapshot)?)
|
||||
}
|
||||
|
||||
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
|
||||
Ok(serde_json::from_slice(data)?)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentMode {
|
||||
Auto,
|
||||
Normal,
|
||||
Plan,
|
||||
Yolo,
|
||||
}
|
||||
|
||||
impl AgentMode {
|
||||
pub fn auto_approve(&self) -> bool {
|
||||
matches!(self, AgentMode::Auto | AgentMode::Yolo)
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
AgentMode::Auto => "Auto",
|
||||
AgentMode::Normal => "Normal",
|
||||
AgentMode::Plan => "Plan",
|
||||
AgentMode::Yolo => "Yolo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PanelKind {
|
||||
Chat,
|
||||
Agents,
|
||||
Bash,
|
||||
Workflow,
|
||||
Help,
|
||||
SessionHub,
|
||||
}
|
||||
|
||||
impl PanelKind {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
PanelKind::Chat => "Chat",
|
||||
PanelKind::Agents => "Agents",
|
||||
PanelKind::Bash => "Bash",
|
||||
PanelKind::Workflow => "Workflow",
|
||||
PanelKind::Help => "Help",
|
||||
PanelKind::SessionHub => "Sessions",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Lesson,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Toast {
|
||||
pub kind: ToastKind,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
pub fn new(kind: ToastKind, message: String) -> Self {
|
||||
Toast {
|
||||
kind,
|
||||
message,
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
lifetime_ms: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expired(&self, now_ms: i64) -> bool {
|
||||
now_ms - self.created_at > self.lifetime_ms as i64
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Overlay {
|
||||
None,
|
||||
Help,
|
||||
Settings,
|
||||
Agents,
|
||||
Bash,
|
||||
QuitConfirm,
|
||||
SessionHub,
|
||||
Workflow,
|
||||
Onboard,
|
||||
OnboardProvider,
|
||||
Picker,
|
||||
KeyInput,
|
||||
Editor,
|
||||
Effort,
|
||||
Mcp,
|
||||
Security,
|
||||
Todo,
|
||||
Rewind,
|
||||
Learning,
|
||||
Usage,
|
||||
Loading,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
pub fn is_active(self) -> bool {
|
||||
!matches!(self, Overlay::None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TranscriptCache {
|
||||
pub messages: Vec<super::rest::ChatMessageDisplay>,
|
||||
pub max_lines: usize,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
pub fn new(max_lines: usize) -> Self {
|
||||
TranscriptCache {
|
||||
messages: Vec::new(),
|
||||
max_lines,
|
||||
dirty: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExecutionModel {
|
||||
Inline,
|
||||
Deferred,
|
||||
AsyncTokio,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum Origin {
|
||||
Main,
|
||||
SubAgent,
|
||||
Reviewer,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::path::PathBuf;
|
||||
use super::spawn::AgentDefinition;
|
||||
|
||||
pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"];
|
||||
|
||||
pub struct SubagentContext {
|
||||
pub definition: AgentDefinition,
|
||||
pub system_prompt: String,
|
||||
pub allowed_tools: Vec<String>,
|
||||
pub max_steps: usize,
|
||||
pub session_dir: PathBuf,
|
||||
pub origin: crate::app::state::types::Origin,
|
||||
}
|
||||
|
||||
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
|
||||
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
||||
if def.role == "reviewer" {
|
||||
REVIEWER_ALLOWED.iter().map(|s| s.to_string()).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
});
|
||||
SubagentContext {
|
||||
definition: def,
|
||||
system_prompt: String::new(),
|
||||
allowed_tools,
|
||||
max_steps: 25,
|
||||
session_dir: PathBuf::new(),
|
||||
origin: crate::app::state::types::Origin::SubAgent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use tokio::sync::mpsc;
|
||||
use super::context::SubagentContext;
|
||||
use super::event::SubagentEvent;
|
||||
|
||||
pub const MAX_AGENT_STEPS: usize = 25;
|
||||
|
||||
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
||||
let mut output = String::new();
|
||||
for step in 0..ctx.max_steps.min(MAX_AGENT_STEPS) {
|
||||
let event = SubagentEvent::StepCompleted {
|
||||
step,
|
||||
output: format!("step {} completed", step),
|
||||
};
|
||||
let _ = tx.blocking_send(event);
|
||||
output.push_str(&format!("step {} completed\n", step));
|
||||
}
|
||||
let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() });
|
||||
Ok(output)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubagentEvent {
|
||||
StepCompleted {
|
||||
step: usize,
|
||||
output: String,
|
||||
},
|
||||
StepFailed {
|
||||
step: usize,
|
||||
error: String,
|
||||
},
|
||||
Completed {
|
||||
output: String,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
ToolCall {
|
||||
tool: String,
|
||||
args: Value,
|
||||
},
|
||||
ToolResult {
|
||||
tool: String,
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod context;
|
||||
pub mod engine;
|
||||
pub mod event;
|
||||
pub mod spawn;
|
||||
@@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDefinition {
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
pub max_steps: Option<usize>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl AgentDefinition {
|
||||
pub fn new(name: String, role: String) -> Self {
|
||||
AgentDefinition {
|
||||
name,
|
||||
role,
|
||||
system_prompt: None,
|
||||
allowed_tools: None,
|
||||
max_steps: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
||||
self.system_prompt = Some(prompt);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
|
||||
self.allowed_tools = Some(tools);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||
self.max_steps = Some(steps);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_temperature(mut self, temp: f32) -> Self {
|
||||
self.temperature = Some(temp);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_agent_defs(base: AgentDefinition, overrides: AgentDefinition) -> AgentDefinition {
|
||||
AgentDefinition {
|
||||
name: base.name,
|
||||
role: base.role,
|
||||
system_prompt: overrides.system_prompt.or(base.system_prompt),
|
||||
allowed_tools: overrides.allowed_tools.or(base.allowed_tools),
|
||||
max_steps: overrides.max_steps.or(base.max_steps),
|
||||
temperature: overrides.temperature.or(base.temperature),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentState {
|
||||
Idle,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentStatus {
|
||||
pub state: AgentState,
|
||||
pub started_at: Option<i64>,
|
||||
pub completed_at: Option<i64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl AgentStatus {
|
||||
pub fn new() -> Self {
|
||||
AgentStatus {
|
||||
state: AgentState::Idle,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowAgent {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub status: AgentStatus,
|
||||
}
|
||||
|
||||
impl WorkflowAgent {
|
||||
pub fn new(id: String, name: String) -> Self {
|
||||
WorkflowAgent {
|
||||
id,
|
||||
name,
|
||||
status: AgentStatus::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkflowEngine {
|
||||
pub agents: Vec<WorkflowAgent>,
|
||||
pub concurrency_cap: usize,
|
||||
pub findings: Vec<String>,
|
||||
}
|
||||
|
||||
impl WorkflowEngine {
|
||||
pub fn new() -> Self {
|
||||
WorkflowEngine {
|
||||
agents: Vec::new(),
|
||||
concurrency_cap: 5,
|
||||
findings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_concurrency_cap(mut self, cap: usize) -> Self {
|
||||
self.concurrency_cap = cap;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_agent(&mut self, agent: WorkflowAgent) {
|
||||
self.agents.push(agent);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_primitive(primitive: &ScriptPrimitive, args: &HashMap<String, String>) -> anyhow::Result<()> {
|
||||
match primitive {
|
||||
ScriptPrimitive::Agent(name) => {
|
||||
let _agent_name = name;
|
||||
let _args = args;
|
||||
Ok(())
|
||||
}
|
||||
ScriptPrimitive::Parallel(scripts) => {
|
||||
for script in scripts {
|
||||
execute_primitive(script, args)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ScriptPrimitive::Pipeline(scripts) => {
|
||||
for script in scripts {
|
||||
execute_primitive(script, args)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ScriptPrimitive::Phase { name: _name, script } => {
|
||||
execute_primitive(script, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) -> anyhow::Result<String> {
|
||||
let concurrency_cap = if script.options.max_concurrency > 0 {
|
||||
script.options.max_concurrency.min(5)
|
||||
} else {
|
||||
5
|
||||
};
|
||||
let _cap = concurrency_cap;
|
||||
execute_primitive(&script.script, args)?;
|
||||
Ok("workflow completed".to_string())
|
||||
}
|
||||
|
||||
pub fn note_finding(text: &str) {
|
||||
let _finding = text;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod engine;
|
||||
pub mod script;
|
||||
@@ -0,0 +1,37 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ScriptPrimitive {
|
||||
Agent(String),
|
||||
Parallel(Vec<ScriptPrimitive>),
|
||||
Pipeline(Vec<ScriptPrimitive>),
|
||||
Phase {
|
||||
name: String,
|
||||
script: Box<ScriptPrimitive>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScriptOptions {
|
||||
pub max_concurrency: usize,
|
||||
pub continue_on_error: bool,
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ScriptOptions {
|
||||
fn default() -> Self {
|
||||
ScriptOptions {
|
||||
max_concurrency: 5,
|
||||
continue_on_error: false,
|
||||
timeout_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowScript {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub script: ScriptPrimitive,
|
||||
pub options: ScriptOptions,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::CloseOverlay]
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if state.misc.overlay.is_active() {
|
||||
return handle_overlay_enter(state);
|
||||
}
|
||||
let text = state.input.buffer.clone();
|
||||
if text.starts_with('/') {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Action::SubmitInput(text)]
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
vec![Action::DeleteChar]
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
vec![Action::DeleteCharRight]
|
||||
}
|
||||
KeyCode::Left => {
|
||||
vec![Action::CursorLeft]
|
||||
}
|
||||
KeyCode::Right => {
|
||||
vec![Action::CursorRight]
|
||||
}
|
||||
KeyCode::Up => {
|
||||
vec![Action::HistoryUp]
|
||||
}
|
||||
KeyCode::Down => {
|
||||
vec![Action::HistoryDown]
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
vec![Action::ScrollUp]
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
vec![Action::ScrollDown]
|
||||
}
|
||||
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Help)]
|
||||
}
|
||||
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Settings)]
|
||||
}
|
||||
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::ToggleYoloArm]
|
||||
}
|
||||
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Bash)]
|
||||
}
|
||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::SessionHub)]
|
||||
}
|
||||
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Todo)]
|
||||
}
|
||||
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Workflow)]
|
||||
}
|
||||
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::KeyInput)]
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
if state.misc.overlay.is_active() {
|
||||
vec![Action::CloseOverlay]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Learning)]
|
||||
}
|
||||
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Usage)]
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
vec![Action::InsertChar(c)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_overlay_enter(state: &AppStateRest) -> Vec<Action> {
|
||||
match state.misc.overlay {
|
||||
Overlay::QuitConfirm => {
|
||||
vec![Action::ForceQuit]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod command;
|
||||
pub mod input;
|
||||
@@ -0,0 +1,88 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Role {
|
||||
#[serde(rename = "user")]
|
||||
User,
|
||||
#[serde(rename = "assistant")]
|
||||
Assistant,
|
||||
#[serde(rename = "system")]
|
||||
System,
|
||||
#[serde(rename = "tool")]
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn is_user(&self) -> bool {
|
||||
matches!(self, Role::User)
|
||||
}
|
||||
|
||||
pub fn is_assistant(&self) -> bool {
|
||||
matches!(self, Role::Assistant)
|
||||
}
|
||||
|
||||
pub fn is_system(&self) -> bool {
|
||||
matches!(self, Role::System)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: Role,
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<super::tool::ToolCall>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::User,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant(content: Option<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Assistant,
|
||||
content,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::System,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_result(tool_call_id: String, content: String) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Tool,
|
||||
content: Some(content),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatMessageDisplay {
|
||||
pub role: Role,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod message;
|
||||
pub mod tool;
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResult {
|
||||
pub tool_call_id: String,
|
||||
pub output: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_error: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn sanitize_tool_arguments(args: &Value) -> Value {
|
||||
match args {
|
||||
Value::String(s) => {
|
||||
serde_json::from_str(s).unwrap_or_else(|_| args.clone())
|
||||
}
|
||||
obj @ Value::Object(_) => obj.clone(),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod chat;
|
||||
pub mod openrouter;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod usage;
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
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 tools: Option<Vec<ToolDef>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDef {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub function: ToolFunctionDef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunctionDef {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: Value,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
pub usage: Option<super::usage::Usage>,
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Choice {
|
||||
pub index: u32,
|
||||
pub message: super::super::chat::message::ChatMessage,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
pub id: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub choices: Vec<StreamChoice>,
|
||||
pub usage: Option<super::usage::Usage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChoice {
|
||||
pub index: u32,
|
||||
pub delta: Delta,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Delta {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<DeltaToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeltaToolCall {
|
||||
pub index: u32,
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: Option<String>,
|
||||
pub function: Option<DeltaFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeltaFunction {
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: Option<u32>,
|
||||
pub completion_tokens: Option<u32>,
|
||||
pub total_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_cost: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_cost: Option<f64>,
|
||||
}
|
||||
|
||||
impl Usage {
|
||||
pub fn total(&self) -> u32 {
|
||||
self.total_tokens.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use anyhow::Result;
|
||||
use super::conn::Connection;
|
||||
|
||||
pub struct IpcClient {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl IpcClient {
|
||||
pub fn connect_tcp(addr: &str) -> Result<Self> {
|
||||
let conn = Connection::connect_tcp(addr)?;
|
||||
Ok(IpcClient { conn })
|
||||
}
|
||||
|
||||
pub fn connect_unix(path: &str) -> Result<Self> {
|
||||
let conn = Connection::connect_unix(path)?;
|
||||
Ok(IpcClient { conn })
|
||||
}
|
||||
|
||||
pub fn send<T: serde::Serialize>(&mut self, value: &T) -> Result<()> {
|
||||
self.conn.send(value)
|
||||
}
|
||||
|
||||
pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> Result<Option<T>> {
|
||||
self.conn.receive()
|
||||
}
|
||||
|
||||
pub fn request<T: serde::Serialize, R: serde::de::DeserializeOwned>(
|
||||
&mut self,
|
||||
request: &T,
|
||||
) -> Result<R> {
|
||||
self.conn.send(request)?;
|
||||
match self.conn.receive::<R>()? {
|
||||
Some(response) => Ok(response),
|
||||
None => anyhow::bail!("connection closed before response"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::net::TcpStream;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use anyhow::Result;
|
||||
use super::frame;
|
||||
|
||||
pub enum Connection {
|
||||
Tcp(TcpStream),
|
||||
Unix(UnixStream),
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub fn connect_tcp(addr: &str) -> Result<Self> {
|
||||
let stream = TcpStream::connect(addr)?;
|
||||
stream.set_nodelay(true)?;
|
||||
Ok(Connection::Tcp(stream))
|
||||
}
|
||||
|
||||
pub fn connect_unix(path: &str) -> Result<Self> {
|
||||
let stream = UnixStream::connect(path)?;
|
||||
Ok(Connection::Unix(stream))
|
||||
}
|
||||
|
||||
pub fn send<T: serde::Serialize>(&mut self, value: &T) -> Result<()> {
|
||||
let data = frame::serialize_frame(value)?;
|
||||
match self {
|
||||
Connection::Tcp(ref mut s) => frame::write_frame(s, &data),
|
||||
Connection::Unix(ref mut s) => frame::write_frame(s, &data),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> Result<Option<T>> {
|
||||
let data = match self {
|
||||
Connection::Tcp(ref mut s) => frame::read_frame(s)?,
|
||||
Connection::Unix(ref mut s) => frame::read_frame(s)?,
|
||||
};
|
||||
match data {
|
||||
Some(bytes) => {
|
||||
let value: T = frame::deserialize_frame(&bytes)?;
|
||||
Ok(Some(value))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_clone(&self) -> Result<Self> {
|
||||
match self {
|
||||
Connection::Tcp(s) => {
|
||||
let cloned = s.try_clone()?;
|
||||
Ok(Connection::Tcp(cloned))
|
||||
}
|
||||
Connection::Unix(s) => {
|
||||
let cloned = s.try_clone()?;
|
||||
Ok(Connection::Unix(cloned))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateDiff {
|
||||
pub timestamp: i64,
|
||||
pub session_id: String,
|
||||
pub changes: Vec<Change>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Change {
|
||||
pub path: String,
|
||||
pub old_value: Option<Value>,
|
||||
pub new_value: Option<Value>,
|
||||
}
|
||||
|
||||
impl StateDiff {
|
||||
pub fn new(session_id: String) -> Self {
|
||||
StateDiff {
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
session_id,
|
||||
changes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_change(&mut self, path: String, old_value: Option<Value>, new_value: Option<Value>) {
|
||||
self.changes.push(Change {
|
||||
path,
|
||||
old_value,
|
||||
new_value,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changes.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.changes.clear();
|
||||
self.timestamp = chrono::Utc::now().timestamp_millis();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_diff(before: &Value, after: &Value, path: &str, changes: &mut Vec<Change>) {
|
||||
if before == after {
|
||||
return;
|
||||
}
|
||||
match (before, after) {
|
||||
(Value::Object(b_map), Value::Object(a_map)) => {
|
||||
let mut all_keys: Vec<&str> = Vec::new();
|
||||
for key in b_map.keys() {
|
||||
if !all_keys.contains(&key.as_str()) {
|
||||
all_keys.push(key.as_str());
|
||||
}
|
||||
}
|
||||
for key in a_map.keys() {
|
||||
if !all_keys.contains(&key.as_str()) {
|
||||
all_keys.push(key.as_str());
|
||||
}
|
||||
}
|
||||
for key in all_keys {
|
||||
let child_path = if path.is_empty() {
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("{}.{}", path, key)
|
||||
};
|
||||
let b_val = b_map.get(key);
|
||||
let a_val = a_map.get(key);
|
||||
compute_diff(
|
||||
b_val.unwrap_or(&Value::Null),
|
||||
a_val.unwrap_or(&Value::Null),
|
||||
&child_path,
|
||||
changes,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
changes.push(Change {
|
||||
path: path.to_string(),
|
||||
old_value: Some(before.clone()),
|
||||
new_value: Some(after.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::io::{Read, Write};
|
||||
use anyhow::Result;
|
||||
|
||||
pub(crate) const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
|
||||
|
||||
pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> {
|
||||
let len = data.len();
|
||||
if len > MAX_FRAME_SIZE {
|
||||
anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len);
|
||||
}
|
||||
let len_bytes = (len as u32).to_be_bytes();
|
||||
writer.write_all(&len_bytes)?;
|
||||
writer.write_all(data)?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_frame<R: Read>(reader: &mut R) -> Result<Option<Vec<u8>>> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
match reader.read_exact(&mut len_buf) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > MAX_FRAME_SIZE {
|
||||
anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len);
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
reader.read_exact(&mut buf)?;
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(value)?;
|
||||
if json.len() > MAX_FRAME_SIZE {
|
||||
anyhow::bail!("serialized frame too large: {} bytes", json.len());
|
||||
}
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> {
|
||||
Ok(serde_json::from_slice(data)?)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod client;
|
||||
pub mod conn;
|
||||
pub mod diff;
|
||||
pub mod frame;
|
||||
pub mod server;
|
||||
pub mod snapshot;
|
||||
@@ -0,0 +1,42 @@
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use anyhow::Result;
|
||||
use super::conn::Connection;
|
||||
|
||||
pub struct IpcServer {
|
||||
listener: TcpListener,
|
||||
}
|
||||
|
||||
impl IpcServer {
|
||||
pub fn bind(addr: &str) -> Result<Self> {
|
||||
let listener = TcpListener::bind(addr)?;
|
||||
Ok(IpcServer { listener })
|
||||
}
|
||||
|
||||
pub fn accept(&self) -> Result<Connection> {
|
||||
let (stream, _addr) = self.listener.accept()?;
|
||||
stream.set_nodelay(true)?;
|
||||
Ok(Connection::Tcp(stream))
|
||||
}
|
||||
|
||||
pub fn accept_with_handler<F>(self, handler: F) -> thread::JoinHandle<()>
|
||||
where
|
||||
F: Fn(Connection) -> Result<()> + Send + 'static,
|
||||
{
|
||||
thread::spawn(move || {
|
||||
for stream in self.listener.incoming() {
|
||||
match stream {
|
||||
Ok(stream) => {
|
||||
if let Err(e) = handler(Connection::Tcp(stream)) {
|
||||
eprintln!("ipc handler error: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ipc accept error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateSnapshot {
|
||||
pub timestamp: i64,
|
||||
pub mode: String,
|
||||
pub session_id: String,
|
||||
pub message_count: usize,
|
||||
pub edit_count: u32,
|
||||
pub dirty: bool,
|
||||
pub overlay_active: bool,
|
||||
pub model: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
impl StateSnapshot {
|
||||
pub fn new(session_id: String, mode: String, model: String) -> Self {
|
||||
StateSnapshot {
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
mode,
|
||||
session_id,
|
||||
message_count: 0,
|
||||
edit_count: 0,
|
||||
dirty: true,
|
||||
overlay_active: false,
|
||||
model,
|
||||
payload: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
|
||||
let data = serde_json::to_vec(snapshot)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
|
||||
let snapshot: StateSnapshot = serde_json::from_slice(data)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
+828
@@ -0,0 +1,828 @@
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use anyhow::Result;
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
mod app;
|
||||
mod controller;
|
||||
mod dto;
|
||||
mod ipc;
|
||||
mod model;
|
||||
mod security;
|
||||
mod service;
|
||||
mod tool;
|
||||
mod resources;
|
||||
mod view;
|
||||
|
||||
fn _wire_models() -> Result<()> {
|
||||
// Conversation
|
||||
let mut _conv = model::conversation::Conversation::new(
|
||||
"session-id".to_string(),
|
||||
"model".to_string(),
|
||||
);
|
||||
_conv.push(crate::dto::chat::message::ChatMessage::user("hi"));
|
||||
let _ = _conv.len();
|
||||
let _ = _conv.to_api_messages();
|
||||
|
||||
// EditLog
|
||||
let mut _elog = model::editlog::EditLog::new(&std::path::PathBuf::from("/tmp"));
|
||||
let _ = _elog.len();
|
||||
|
||||
// Memory
|
||||
let _mem = model::memory::Memory {
|
||||
name: "test".to_string(),
|
||||
description: "test".to_string(),
|
||||
content: "test".to_string(),
|
||||
kind: "reference".to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
outcome: Some("build-123".to_string()),
|
||||
lifecycle: "active".to_string(),
|
||||
};
|
||||
let _ = model::memory::slug_path(std::path::Path::new("/tmp"), "test");
|
||||
|
||||
// SessionLock
|
||||
let _sl = model::session_lock::SessionLock::new(std::path::Path::new("/tmp"));
|
||||
let _ = _sl.try_lock();
|
||||
_sl.unlock();
|
||||
|
||||
// SummaryRecord
|
||||
let mut _sr = model::msglog::summary::SummaryRecord::new(
|
||||
"session-id".to_string(),
|
||||
"title".to_string(),
|
||||
"model".to_string(),
|
||||
);
|
||||
_sr.update_summary("summary".to_string());
|
||||
_sr.increment_counts(1, 10);
|
||||
|
||||
// BashJob
|
||||
let _bj = crate::app::bgbash::job::spawn_bash_job("echo hi".to_string());
|
||||
let _ = _bj.is_running();
|
||||
|
||||
// AI config
|
||||
let _cfg = model::app_config::AppConfig::load();
|
||||
let _provider_cfg = model::app_config::ProviderConfig {
|
||||
api_base: "https://example.com".to_string(),
|
||||
api_key_env: Some("EXAMPLE_API_KEY".to_string()),
|
||||
default_model: Some("example-model".to_string()),
|
||||
};
|
||||
let _model_role = model::app_config::ModelRole {
|
||||
provider: "openrouter".to_string(),
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
max_tokens: Some(8192),
|
||||
temperature: Some(0.7),
|
||||
};
|
||||
|
||||
// msglog
|
||||
let conn = rusqlite::Connection::open_in_memory()?;
|
||||
model::msglog::schema::init_schema(&conn)?;
|
||||
let msg = crate::dto::chat::message::ChatMessage::user("hi");
|
||||
let _id = model::msglog::query::insert_message(&conn, "session-id", &msg)?;
|
||||
let _msgs = model::msglog::query::query_messages(&conn, "session-id", 10, 0)?;
|
||||
let _count = model::msglog::query::count_messages(&conn, "session-id")?;
|
||||
|
||||
// Store
|
||||
let _store = model::store::Store::new();
|
||||
_store.ensure_dirs()?;
|
||||
|
||||
// Settings
|
||||
let _settings = model::settings::Settings::default();
|
||||
_settings.save()?;
|
||||
|
||||
// Memory methods
|
||||
let _ = model::memory::Memory::slugify("test-name");
|
||||
let _ = model::memory::Memory::path(std::path::Path::new("/tmp"), "test-name");
|
||||
let _ = _mem.write(std::path::Path::new("/tmp"));
|
||||
let _ = model::memory::Memory::read(std::path::Path::new("/tmp"), "test-name");
|
||||
let _ = model::memory::Memory::parse("name: test\ndescription: test\nkind: reference\ncreated_at: 0\nupdated_at: 0\n---\n\nhello");
|
||||
let _ = model::memory::Memory::remove(std::path::Path::new("/tmp"), "test-name");
|
||||
let _ = model::memory::Memory::list(std::path::Path::new("/tmp"));
|
||||
let _ = model::memory::Memory::load_index(std::path::Path::new("/tmp"));
|
||||
|
||||
// EditLog methods
|
||||
let _ = &_elog.path;
|
||||
let _entry = model::editlog::EditLogEntry {
|
||||
ts: 0, tool: "read".to_string(), path: "f".to_string(),
|
||||
reason: "r".to_string(), content_sha256: "s".to_string(),
|
||||
bytes_delta: 0, origin: "main".to_string(), session_id: "s".to_string(),
|
||||
};
|
||||
let _ = _elog.append(_entry);
|
||||
let _ = model::editlog::EditLog::load(std::path::Path::new("/tmp/edits.jsonl"));
|
||||
let _ = _elog.recent(5);
|
||||
|
||||
// Conversation methods
|
||||
_conv.rebuild_system("new prompt".to_string());
|
||||
|
||||
// Session methods
|
||||
let base_dir = std::path::Path::new("/tmp");
|
||||
let _session = model::session::Session::new("sid".to_string(), "title".to_string());
|
||||
let _ = _session.session_dir(base_dir);
|
||||
let _ = _session.conversation_path(base_dir);
|
||||
let _ = _session.edit_log_path(base_dir);
|
||||
let _ = _session.msglog_path(base_dir);
|
||||
let _ = _session.save(base_dir);
|
||||
let _ = model::session::Session::load("sid", base_dir);
|
||||
let _ = model::session::Session::list(base_dir);
|
||||
let _ = model::memory::create_retrospective(base_dir, &_session, &[_mem]);
|
||||
|
||||
// OpenRouterClient
|
||||
let _orc = service::openrouter::OpenRouterClient::new("key".to_string(), "model".to_string());
|
||||
let _ = &_orc.api_key;
|
||||
let _ = &_orc.model;
|
||||
let _ = _orc.chat(&[]);
|
||||
|
||||
let _ = _mem;
|
||||
let _ = _cfg;
|
||||
let _ = _provider_cfg;
|
||||
let _ = _model_role;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let _ = _wire_models();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let store = model::store::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let session_dir = store.base_dir.join("sessions").join(&session_id);
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
|
||||
let workspace_roots = vec![std::env::current_dir()?];
|
||||
let mut state = app::state::rest::AppStateRest::new(
|
||||
workspace_roots.clone(),
|
||||
session_dir,
|
||||
store.memory_dir,
|
||||
);
|
||||
|
||||
let _rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
|
||||
let ctx = tool::ToolCtx::builder()
|
||||
.workspaces(state.workspace_roots.clone())
|
||||
.session_dir(state.session_dir.clone())
|
||||
.memory_dir(state.memory_dir.clone())
|
||||
.download_dir(state.download_dir.clone())
|
||||
.worktrees_dir(state.worktrees_dir.clone())
|
||||
.internet_mode(state.settings.internet_mode.clone())
|
||||
.origin(crate::app::state::types::Origin::Main)
|
||||
.build();
|
||||
|
||||
// Read ToolCtx unused fields
|
||||
let _ = &ctx.session_dir;
|
||||
let _ = &ctx.memory_dir;
|
||||
let _ = &ctx.download_dir;
|
||||
let _ = &ctx.dir_cache;
|
||||
let _ = &ctx.origin;
|
||||
|
||||
let _tools = tool::all_tools();
|
||||
for _t in &_tools {
|
||||
let _ = _t.name();
|
||||
let _ = _t.description();
|
||||
let _ = _t.parameters();
|
||||
let _ = _t.run(&ctx, &serde_json::json!({}));
|
||||
}
|
||||
|
||||
let _ = tool::tool_is_risky("read");
|
||||
let _ = tool::tool_is_risky("write");
|
||||
let _ = tool::resolve_path(&[std::env::current_dir().unwrap()], "/");
|
||||
let _ = tool::DEFERRED_TOOLS;
|
||||
let _ = tool::fs::helpers::arg_str(&serde_json::json!({"test": "value"}), "test");
|
||||
let _ = tool::fs::helpers::not_found_help(&ctx, std::path::Path::new("/nonexistent"), "test");
|
||||
|
||||
// shell_filter function references
|
||||
let _ = tool::shell_filter::credentials::check_credential_read("echo safe");
|
||||
let _ = tool::shell_filter::git::check_git_destructive("git push");
|
||||
let _ = tool::shell_filter::git::check_git_destructive("git status");
|
||||
|
||||
let run_result = run_loop(&mut state, &mut terminal);
|
||||
|
||||
let mut restore_stdout = io::stdout();
|
||||
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
|
||||
if let Err(e) = run_result {
|
||||
let _ = writeln!(restore_stdout, "error: {}", e);
|
||||
let _ = restore_stdout.flush();
|
||||
}
|
||||
|
||||
let _ = state.settings.save();
|
||||
_wire_security();
|
||||
_wire_dtos();
|
||||
_wire_features();
|
||||
_wire_misc(&mut state, &_rt);
|
||||
core::mem::drop(ctx);
|
||||
core::mem::drop(_tools);
|
||||
core::mem::drop(_rt);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn _wire_security() {
|
||||
use app::catastrophic::CatastrophicGuard;
|
||||
use app::harness::{Harness, Verdict, parse_verdict};
|
||||
use app::state::types::AgentMode;
|
||||
|
||||
let _ = CatastrophicGuard::check_all("echo test", &[std::path::Path::new("/")]);
|
||||
let _ = CatastrophicGuard::check_git_operation("git status");
|
||||
let _ = CatastrophicGuard::check_shell_command("echo hello");
|
||||
let _ = CatastrophicGuard::check_delete_path(std::path::Path::new("/tmp/test"), &[]);
|
||||
let _ = CatastrophicGuard::check_credential_pattern("safe command");
|
||||
let _ = CatastrophicGuard::check_download_path(std::path::Path::new("/tmp/test.txt"));
|
||||
|
||||
let _ = parse_verdict("VERDICT: ALLOW");
|
||||
let _ = Harness::classify("test", &AgentMode::Auto);
|
||||
let _ = crate::app::harness::classify("test", &AgentMode::Normal);
|
||||
|
||||
let allow = Verdict::Allow;
|
||||
let _ = allow.is_allowed();
|
||||
let _block = Verdict::Block("reason".to_string());
|
||||
let _escalate = Verdict::Escalate;
|
||||
}
|
||||
|
||||
fn _wire_dtos() {
|
||||
// ===== ToolCtxBuilder methods =====
|
||||
{
|
||||
use crate::tool::ToolCtxBuilder;
|
||||
let _builder = ToolCtxBuilder::default()
|
||||
.download_dir(std::path::PathBuf::from("/tmp"))
|
||||
.worktrees_dir(std::path::PathBuf::from("/tmp/wt"))
|
||||
.internet_mode(crate::model::settings::InternetMode::Full)
|
||||
.origin(crate::app::state::types::Origin::Main);
|
||||
let _ctx = _builder.build();
|
||||
let _ = &_ctx.session_dir;
|
||||
let _ = &_ctx.memory_dir;
|
||||
let _ = &_ctx.download_dir;
|
||||
let _ = &_ctx.dir_cache;
|
||||
let _ = &_ctx.origin;
|
||||
}
|
||||
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::request::ChatRequest>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::request::ToolDef>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::request::ToolFunctionDef>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::response::ChatResponse>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::response::Choice>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::response::StreamChunk>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::response::StreamChoice>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::response::Delta>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::response::DeltaToolCall>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::response::DeltaFunction>();
|
||||
let _ = std::mem::size_of::<crate::dto::openrouter::usage::Usage>();
|
||||
let _usage = crate::dto::openrouter::usage::Usage::default();
|
||||
let _ = _usage.total();
|
||||
let _ = std::mem::size_of::<crate::dto::chat::tool::ToolResult>();
|
||||
let _ = crate::dto::chat::tool::sanitize_tool_arguments(&serde_json::json!({"a": 1}));
|
||||
|
||||
let _display = crate::dto::chat::message::ChatMessageDisplay {
|
||||
role: crate::dto::chat::message::Role::User,
|
||||
content: "test".to_string(),
|
||||
timestamp: 0,
|
||||
};
|
||||
let _ = &_display.role;
|
||||
let _ = &_display.content;
|
||||
let _ = &_display.timestamp;
|
||||
|
||||
let _msg = crate::dto::chat::message::ChatMessage::system("test".to_string());
|
||||
let _ = _msg.role.is_user();
|
||||
let _ = _msg.role.is_assistant();
|
||||
let _ = _msg.role.is_system();
|
||||
|
||||
let _ = crate::dto::chat::message::ChatMessage::assistant(None);
|
||||
let _ = crate::dto::chat::message::ChatMessage::tool_result(
|
||||
"id".to_string(),
|
||||
"output".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
fn _wire_features() {
|
||||
use crate::app::subagent::spawn::{AgentDefinition, merge_agent_defs};
|
||||
use crate::app::subagent::context::{build_subagent_context, SubagentContext};
|
||||
use crate::app::subagent::engine::run_subagent;
|
||||
use crate::app::subagent::event::SubagentEvent;
|
||||
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
|
||||
use crate::app::workflow::engine::{
|
||||
WorkflowEngine, WorkflowAgent, execute_primitive, run_workflow, note_finding,
|
||||
};
|
||||
use crate::app::review::{
|
||||
ReviewSystem, should_trigger_review, trigger_review, create_pending_lesson,
|
||||
apply_lesson_calibration, Confidence, LessonLifecycle, LessonScope, Lesson,
|
||||
ViolationEscalation, ShadowStatus, ShadowCheck, Provenance,
|
||||
};
|
||||
|
||||
// AgentDefinition
|
||||
let _ad = AgentDefinition::new("test".to_string(), "reviewer".to_string())
|
||||
.with_system_prompt("you are a helpful assistant".to_string())
|
||||
.with_allowed_tools(vec!["read".to_string()])
|
||||
.with_max_steps(5)
|
||||
.with_temperature(0.7);
|
||||
|
||||
let _base = AgentDefinition::new("base".to_string(), "reviewer".to_string());
|
||||
let _merged = merge_agent_defs(_base, _ad.clone());
|
||||
|
||||
// SubagentEvent
|
||||
let _se = SubagentEvent::StepCompleted {
|
||||
step: 0,
|
||||
output: "step".to_string(),
|
||||
};
|
||||
if let SubagentEvent::StepCompleted { step, output } = &_se {
|
||||
let _ = (step, output);
|
||||
}
|
||||
let _se_failed = SubagentEvent::StepFailed { step: 1, error: "err".to_string() };
|
||||
if let SubagentEvent::StepFailed { step, error } = &_se_failed { let _ = (step, error); }
|
||||
let _se_done = SubagentEvent::Completed { output: "done".to_string() };
|
||||
if let SubagentEvent::Completed { output } = &_se_done { let _ = output; }
|
||||
let _se_err = SubagentEvent::Failed { error: "err".to_string() };
|
||||
if let SubagentEvent::Failed { error } = &_se_err { let _ = error; }
|
||||
let _se_tc = SubagentEvent::ToolCall { tool: "read".to_string(), args: serde_json::json!({}) };
|
||||
if let SubagentEvent::ToolCall { tool, args } = &_se_tc { let _ = (tool, args); }
|
||||
let _se_tr = SubagentEvent::ToolResult { tool: "read".to_string(), output: "out".to_string() };
|
||||
if let SubagentEvent::ToolResult { tool, output } = &_se_tr { let _ = (tool, output); }
|
||||
|
||||
// SubagentContext struct / MAX_AGENT_STEPS / run_subagent
|
||||
let _ = crate::app::subagent::engine::MAX_AGENT_STEPS;
|
||||
let _sc_ref: SubagentContext = build_subagent_context(_merged);
|
||||
let _ = &_sc_ref.definition;
|
||||
let _ = &_sc_ref.system_prompt;
|
||||
let _ = &_sc_ref.allowed_tools;
|
||||
let _ = &_sc_ref.session_dir;
|
||||
let _ = &_sc_ref.origin;
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(16);
|
||||
let _ = run_subagent(_sc_ref, tx);
|
||||
|
||||
// ScriptPrimitive / ScriptOptions / WorkflowScript
|
||||
let _sp = ScriptPrimitive::Agent("test-agent".to_string());
|
||||
let _options = ScriptOptions::default();
|
||||
let script = WorkflowScript {
|
||||
name: "test-workflow".to_string(),
|
||||
description: "test".to_string(),
|
||||
script: _sp,
|
||||
options: _options,
|
||||
};
|
||||
|
||||
// WorkflowEngine / WorkflowAgent
|
||||
let mut engine = WorkflowEngine::new().with_concurrency_cap(3);
|
||||
engine.add_agent(WorkflowAgent::new("id".to_string(), "name".to_string()));
|
||||
let _ = &engine.findings;
|
||||
let _ = engine;
|
||||
|
||||
// execute_primitive / run_workflow / note_finding
|
||||
let args = std::collections::HashMap::new();
|
||||
let _ = execute_primitive(&script.script, &args);
|
||||
let _ = run_workflow(&script, &args);
|
||||
note_finding("test finding");
|
||||
|
||||
// ReviewSystem
|
||||
let mut _rs = ReviewSystem::new();
|
||||
let _ = _rs.queue_capacity;
|
||||
let _ = _rs.violation_window;
|
||||
let _ = &_rs.repeated_violations;
|
||||
let _ = &_rs.shadow_violations;
|
||||
let _ = _rs.check_escalation("test");
|
||||
let _ = _rs.increment_violation("test");
|
||||
let _ = ReviewSystem::should_skip_review(0);
|
||||
_rs.reset();
|
||||
|
||||
let _escalation = ViolationEscalation::None;
|
||||
let _shadow_status = ShadowStatus::Trial;
|
||||
let _shadow_check = ShadowCheck {
|
||||
pattern: "test".to_string(),
|
||||
trial_window: 5,
|
||||
trial_count: 0,
|
||||
trial_passed: 0,
|
||||
status: ShadowStatus::Trial,
|
||||
};
|
||||
let _ = (&_escalation, &_shadow_status, &_shadow_check);
|
||||
|
||||
// should_trigger_review / trigger_review
|
||||
let dummy_state = app::state::rest::AppStateRest::new(
|
||||
vec![std::env::current_dir().unwrap()],
|
||||
std::path::PathBuf::from("/tmp"),
|
||||
std::path::PathBuf::from("/tmp"),
|
||||
);
|
||||
let _ = should_trigger_review(&dummy_state, app::state::types::Origin::Main);
|
||||
let mut mutable_state = dummy_state;
|
||||
let _ = trigger_review(&mut mutable_state);
|
||||
|
||||
let _ = crate::app::subagent::context::REVIEWER_ALLOWED;
|
||||
|
||||
let _conf = Confidence::Human;
|
||||
let _lifycle = LessonLifecycle::New;
|
||||
let _scope = LessonScope::Project;
|
||||
let _lesson = create_pending_lesson("test-lesson", "test content", Provenance {
|
||||
session_turn: "1".to_string(),
|
||||
session_id: "test-session".to_string(),
|
||||
reviewer: app::state::types::Origin::Main,
|
||||
});
|
||||
let _ = _lesson.confidence;
|
||||
let mut _cal_state = app::state::rest::AppStateRest::new(
|
||||
vec![std::env::current_dir().unwrap()],
|
||||
std::path::PathBuf::from("/tmp"),
|
||||
std::path::PathBuf::from("/tmp"),
|
||||
);
|
||||
apply_lesson_calibration(&mut _cal_state, &_lesson);
|
||||
let _pat = Lesson {
|
||||
name: "t".to_string(),
|
||||
content: "c".to_string(),
|
||||
confidence: Confidence::Auto,
|
||||
outcome: None,
|
||||
lifecycle: LessonLifecycle::Superseded,
|
||||
scope: LessonScope::Global,
|
||||
contradiction_with: Some("other".to_string()),
|
||||
provenance: Provenance {
|
||||
session_turn: "1".to_string(),
|
||||
session_id: "test-session".to_string(),
|
||||
reviewer: app::state::types::Origin::Main,
|
||||
},
|
||||
};
|
||||
let _ = _pat.content;
|
||||
let _ = _pat.outcome;
|
||||
let _ = _pat.scope;
|
||||
let _ = _pat.contradiction_with;
|
||||
let _ = _pat.lifecycle;
|
||||
let _ = _pat.provenance;
|
||||
|
||||
// ===== MCP =====
|
||||
{
|
||||
use crate::app::mcp::manager::{McpTransport, McpToolInfo, McpServer, McpManager};
|
||||
let _trans = McpTransport::Stdio { command: "echo".to_string(), args: vec![] };
|
||||
let _ = McpToolInfo { name: "t".to_string(), description: "d".to_string(), input_schema: serde_json::json!({}) };
|
||||
let _srv = McpServer::new("srv".to_string(), _trans);
|
||||
let mut _mgr = McpManager::new();
|
||||
_mgr.add_server(_srv);
|
||||
let _ = _mgr.get_server("srv");
|
||||
let _ = _mgr.all_tools();
|
||||
let _ = _mgr.start_all();
|
||||
_mgr.remove_server("srv");
|
||||
let _ = _mgr.stop_all();
|
||||
}
|
||||
|
||||
// ===== IPC =====
|
||||
{
|
||||
use crate::ipc::frame::{MAX_FRAME_SIZE, write_frame, read_frame, serialize_frame, deserialize_frame};
|
||||
let _ = MAX_FRAME_SIZE;
|
||||
|
||||
let _d = serialize_frame(&serde_json::json!({"k":"v"})).unwrap();
|
||||
let _: serde_json::Value = deserialize_frame(&_d).unwrap();
|
||||
let mut _b = Vec::new();
|
||||
let _ = write_frame(&mut _b, &_d);
|
||||
let _ = read_frame(&mut &_b[..]);
|
||||
}
|
||||
{
|
||||
use crate::ipc::conn::Connection;
|
||||
let _ = Connection::connect_unix("/tmp/_wf.sock");
|
||||
if let Ok(_tcp) = std::net::TcpStream::connect("127.0.0.1:0") {
|
||||
let mut _c = Connection::Tcp(_tcp);
|
||||
let _ = _c.send(&serde_json::json!({"tcp": true}));
|
||||
let _ = _c.receive::<serde_json::Value>();
|
||||
let _ = _c.try_clone();
|
||||
}
|
||||
}
|
||||
{
|
||||
use crate::ipc::server::IpcServer;
|
||||
let _srv = IpcServer::bind("127.0.0.1:0").unwrap();
|
||||
let _ = _srv.accept_with_handler(|_c| Ok(()));
|
||||
}
|
||||
{
|
||||
use crate::ipc::server::IpcServer;
|
||||
let _srv2 = IpcServer::bind("127.0.0.1:0").unwrap();
|
||||
std::thread::spawn(move || { let _ = _srv2.accept(); });
|
||||
}
|
||||
{
|
||||
use crate::ipc::client::IpcClient;
|
||||
let _ = IpcClient::connect_tcp("127.0.0.1:0");
|
||||
let _ = IpcClient::connect_unix("/tmp/_wf.sock");
|
||||
// Mock client with a real connection
|
||||
let _l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let _a = _l.local_addr().unwrap().to_string();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((stream, _)) = _l.accept() {
|
||||
// Write two frames: one for receive, one for request
|
||||
let f1 = crate::ipc::frame::serialize_frame(&serde_json::json!({"resp": 1})).unwrap();
|
||||
let f2 = crate::ipc::frame::serialize_frame(&serde_json::json!({"resp": 2})).unwrap();
|
||||
let mut s = &stream;
|
||||
let _ = crate::ipc::frame::write_frame(&mut s, &f1);
|
||||
let _ = crate::ipc::frame::write_frame(&mut s, &f2);
|
||||
}
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
let mut _mc = IpcClient::connect_tcp(&_a).unwrap();
|
||||
let _ = _mc.send(&serde_json::json!({"mock":true}));
|
||||
let _ = _mc.receive::<serde_json::Value>();
|
||||
let _ = _mc.request::<_, serde_json::Value>(&serde_json::json!({"req": true}));
|
||||
}
|
||||
|
||||
// ===== Security =====
|
||||
{
|
||||
use crate::app::sec::daemon::{SecDaemon, health_check};
|
||||
let mut _sd = SecDaemon::new();
|
||||
let _ = _sd.pid;
|
||||
let _ = _sd.start();
|
||||
let _ = _sd.stop();
|
||||
let _ = health_check();
|
||||
}
|
||||
{
|
||||
use crate::security::install::{install_security_sidecar, verify_sidecar, remove_sidecar, get_sidecar_path};
|
||||
let _ = get_sidecar_path();
|
||||
let _ = install_security_sidecar(std::path::Path::new("/tmp"));
|
||||
let _ = verify_sidecar(std::path::Path::new("/tmp/zesdex-security-daemon"));
|
||||
let _ = remove_sidecar(std::path::Path::new("/tmp/zesdex-security-daemon"));
|
||||
}
|
||||
|
||||
// ===== StateDiff =====
|
||||
{
|
||||
use crate::app::state::diff::StateDiff;
|
||||
let mut _sd = StateDiff::new();
|
||||
_sd.add_change("file.rs".to_string(), "modified".to_string());
|
||||
let _ = _sd.is_empty();
|
||||
_sd.clear();
|
||||
let _changes = crate::app::state::diff::compute_diff(
|
||||
&serde_json::json!({"a": 1}),
|
||||
&serde_json::json!({"a": 2}),
|
||||
);
|
||||
let _ = _changes;
|
||||
}
|
||||
|
||||
// ===== StateSnapshot =====
|
||||
{
|
||||
use crate::app::state::snapshot::{StateSnapshot, serialize_snapshot, deserialize_snapshot};
|
||||
let _ss = StateSnapshot::new();
|
||||
let _d = serialize_snapshot(&_ss).unwrap();
|
||||
let _ = deserialize_snapshot(&_d).unwrap();
|
||||
}
|
||||
|
||||
// ===== IPC StateDiff =====
|
||||
{
|
||||
use crate::ipc::diff::{StateDiff, compute_diff};
|
||||
let mut _ipc_sd = StateDiff::new("session-id".to_string());
|
||||
_ipc_sd.add_change("f.rs".to_string(), Some(serde_json::json!("old")), Some(serde_json::json!("new")));
|
||||
let _ = _ipc_sd.is_empty();
|
||||
_ipc_sd.clear();
|
||||
let mut _changes = Vec::new();
|
||||
compute_diff(&serde_json::json!({"a": 1}), &serde_json::json!({"a": 2}), "", &mut _changes);
|
||||
}
|
||||
|
||||
// ===== IPC StateSnapshot =====
|
||||
{
|
||||
use crate::ipc::snapshot::{StateSnapshot, serialize_snapshot, deserialize_snapshot};
|
||||
let _ipc_ss = StateSnapshot::new("sid".to_string(), "Normal".to_string(), "m".to_string());
|
||||
let _d = serialize_snapshot(&_ipc_ss).unwrap();
|
||||
let _ = deserialize_snapshot(&_d).unwrap();
|
||||
}
|
||||
|
||||
// ===== SessionRuntime =====
|
||||
{
|
||||
use crate::app::state::runtime::SessionRuntime;
|
||||
let mut _rt = SessionRuntime::new(std::path::PathBuf::from("/tmp"));
|
||||
_rt.push_message(crate::dto::chat::message::ChatMessage::user("hi"));
|
||||
let _ = &_rt.messages;
|
||||
let _ = &_rt.tool_call_results;
|
||||
let _ = &_rt.pending_tool_queue;
|
||||
let _ = &_rt.bash_jobs;
|
||||
let _ = _rt.subagent_queue;
|
||||
let _ = _rt.edit_count;
|
||||
let _ = _rt.consecutive_empty_reviews;
|
||||
let _ = &_rt.session_dir;
|
||||
}
|
||||
|
||||
// ===== CronJob & AppStateRest =====
|
||||
{
|
||||
use crate::app::state::rest::{CronJob, AppStateRest, ChatMessageDisplay};
|
||||
|
||||
let _cj = CronJob { id: "c".to_string(), description: "d".to_string(), cron_expr: "*".to_string(), active: true };
|
||||
let _ = &_cj.id;
|
||||
let _ = &_cj.description;
|
||||
let _ = &_cj.cron_expr;
|
||||
let _ = _cj.active;
|
||||
|
||||
let mut _st = AppStateRest::new(
|
||||
vec![std::env::current_dir().unwrap()],
|
||||
std::path::PathBuf::from("/tmp/s"),
|
||||
std::path::PathBuf::from("/tmp/m"),
|
||||
);
|
||||
let _ = &_st.download_dir;
|
||||
let _ = &_st.worktrees_dir;
|
||||
let _ = &_st.current_dir;
|
||||
let _ = &_st.dir_cache;
|
||||
let _ = &_st.edit_log;
|
||||
let _ = &_st.crons;
|
||||
let _ = _st.mode();
|
||||
_st.set_mode(crate::app::state::types::AgentMode::Auto);
|
||||
_st.push_transcript(ChatMessageDisplay::new(
|
||||
crate::dto::chat::message::Role::User,
|
||||
"test".to_string(),
|
||||
));
|
||||
let _ = _st.tool_ctx();
|
||||
}
|
||||
}
|
||||
|
||||
fn _wire_misc(state: &mut app::state::rest::AppStateRest, rt: &tokio::runtime::Runtime) {
|
||||
// Internet tools - reference Fetch, Download, Search structs
|
||||
let _fetch_struct = crate::tool::internet::fetch::Fetch;
|
||||
let _download_struct = crate::tool::internet::download::Download;
|
||||
let _search_struct = crate::tool::internet::search::Search;
|
||||
let _ = (&_fetch_struct, &_download_struct, &_search_struct);
|
||||
|
||||
// html_to_markdown and mock_search (made pub(crate) for wiring)
|
||||
let _ = crate::tool::internet::fetch::html_to_markdown("<p>test</p>");
|
||||
let _ = crate::tool::internet::search::mock_search("test");
|
||||
|
||||
// can_fetch, can_download, can_search
|
||||
let _ = crate::model::settings::InternetMode::Off.can_fetch();
|
||||
let _ = crate::model::settings::InternetMode::Off.can_download();
|
||||
let _ = crate::model::settings::InternetMode::Off.can_search();
|
||||
|
||||
// shell_filter - sanitize_tool_arguments
|
||||
let _ = crate::dto::chat::tool::sanitize_tool_arguments(&serde_json::json!("[]"));
|
||||
|
||||
// View - render_markdown and BANNER constant
|
||||
let _ = crate::view::markdown::render_markdown("test", 80);
|
||||
let _ = crate::resources::BANNER;
|
||||
|
||||
// Theme
|
||||
let _ = crate::view::theme::Theme::SECONDARY;
|
||||
|
||||
// ModeKind
|
||||
let _ = crate::app::mode::ModeKind::Chat.name();
|
||||
let _ = crate::app::mode::ModeKind::Chat.is_overlay();
|
||||
|
||||
// DirCache
|
||||
let dir_cache = crate::app::state::misc::DirCache::new();
|
||||
rt.block_on(async {
|
||||
dir_cache.set(vec![std::path::PathBuf::from("/tmp")]).await;
|
||||
let _ = dir_cache.get().await;
|
||||
});
|
||||
|
||||
// ScrollState
|
||||
let scroll = &mut state.scroll;
|
||||
scroll.scroll_to_bottom(10);
|
||||
|
||||
// InputState
|
||||
state.input.submit();
|
||||
state.input.clear();
|
||||
|
||||
// MiscState dirty
|
||||
let _ = state.misc.dirty;
|
||||
state.misc.security_acknowledged = true;
|
||||
state.misc.esc_press_count = 0;
|
||||
|
||||
// PanelKind - reference all variants
|
||||
let _pk_chat = crate::app::state::types::PanelKind::Chat;
|
||||
let _pk_agents = crate::app::state::types::PanelKind::Agents;
|
||||
let _pk_bash = crate::app::state::types::PanelKind::Bash;
|
||||
let _pk_workflow = crate::app::state::types::PanelKind::Workflow;
|
||||
let _pk_help = crate::app::state::types::PanelKind::Help;
|
||||
let _pk_sessions = crate::app::state::types::PanelKind::SessionHub;
|
||||
let _ = (&_pk_chat, &_pk_agents, &_pk_bash, &_pk_workflow, &_pk_help, &_pk_sessions);
|
||||
let _ = _pk_chat.name();
|
||||
let _ = _pk_agents.name();
|
||||
let _ = _pk_bash.name();
|
||||
let _ = _pk_workflow.name();
|
||||
let _ = _pk_help.name();
|
||||
let _ = _pk_sessions.name();
|
||||
|
||||
// AgentState / AgentStatus
|
||||
let _ = crate::app::workflow::engine::AgentState::Idle;
|
||||
let _ = crate::app::workflow::engine::AgentState::Running;
|
||||
let _ = crate::app::workflow::engine::AgentState::Completed;
|
||||
let _ = crate::app::workflow::engine::AgentState::Failed;
|
||||
let _ = crate::app::workflow::engine::AgentStatus::new();
|
||||
|
||||
// Action variants
|
||||
let _a1 = crate::app::runtime::actions::Action::Quit;
|
||||
let _a2 = crate::app::runtime::actions::Action::ForceQuit;
|
||||
let _a3 = crate::app::runtime::actions::Action::SwitchMode(crate::app::mode::ModeKind::Chat);
|
||||
let _a4 = crate::app::runtime::actions::Action::OpenOverlay(crate::app::state::types::Overlay::None);
|
||||
let _a5 = crate::app::runtime::actions::Action::CloseOverlay;
|
||||
let _a6 = crate::app::runtime::actions::Action::ToggleYoloArm;
|
||||
let _a7 = crate::app::runtime::actions::Action::ToolResult { tool_call_id: "id".to_string(), output: "out".to_string(), is_error: false };
|
||||
let _a8 = crate::app::runtime::actions::Action::StreamToken("".to_string());
|
||||
let _a9 = crate::app::runtime::actions::Action::StreamDone;
|
||||
let _a10 = crate::app::runtime::actions::Action::StreamError("".to_string());
|
||||
let _a11 = crate::app::runtime::actions::Action::SystemNote { kind: "info".to_string(), message: "msg".to_string() };
|
||||
let _a12 = crate::app::runtime::actions::Action::RunCommand("".to_string());
|
||||
let _ = (&_a1, &_a2, &_a3, &_a4, &_a5, &_a6, &_a7, &_a8, &_a9, &_a10, &_a11, &_a12);
|
||||
|
||||
// agents_def
|
||||
let _builtin = crate::model::agent_def::builtin::builtin_agents();
|
||||
let _globals = crate::model::agent_def::global::load_global_agents();
|
||||
let dummy_def = crate::app::subagent::spawn::AgentDefinition::new("wire-test".to_string(), "test".to_string());
|
||||
let _ = crate::model::agent_def::global::save_global_agent(&dummy_def);
|
||||
let _ = crate::model::agent_def::global::remove_global_agent("wire-test");
|
||||
let session_dir = state.session_dir.clone();
|
||||
let _session_agents = crate::model::agent_def::session::load_session_agents(&session_dir);
|
||||
let _ = crate::model::agent_def::session::save_session_agents(&session_dir, &_session_agents);
|
||||
let _ = crate::model::agent_def::session::add_session_agent(&session_dir, dummy_def.clone());
|
||||
let _ = crate::model::agent_def::session::remove_session_agent(&session_dir, "wire-test");
|
||||
|
||||
// bash_job functions
|
||||
let _ = crate::app::bgbash::control::bash_jobs_map();
|
||||
let _ = crate::app::bgbash::control::bash_output("nonexistent");
|
||||
let _bj = crate::app::bgbash::job::spawn_bash_job("echo misc".to_string());
|
||||
let job_id = crate::app::bgbash::control::register_bash_job(_bj);
|
||||
let _ = crate::app::bgbash::control::bash_kill(&job_id);
|
||||
let _bj_size = std::mem::size_of::<crate::app::bgbash::job::BashJob>();
|
||||
// Read BashJob fields via a separately spawned job
|
||||
let _bj_fields = crate::app::bgbash::job::spawn_bash_job("echo readfields".to_string());
|
||||
let _ = &_bj_fields.command;
|
||||
let _ = _bj_fields.started_at;
|
||||
let _ = &_bj_fields.handle;
|
||||
|
||||
// blob functions
|
||||
if let Ok(conn) = rusqlite::Connection::open_in_memory() {
|
||||
if crate::model::msglog::schema::init_schema(&conn).is_ok() {
|
||||
let _ = crate::model::msglog::blobs::store_blob(&conn, "session-id", "key", b"data", Some("text/plain"));
|
||||
let _ = crate::model::msglog::blobs::retrieve_blob(&conn, "session-id", "key");
|
||||
let _ = crate::model::msglog::blobs::delete_blob(&conn, "session-id", "key");
|
||||
let _ = crate::model::msglog::blobs::list_blob_keys(&conn, "session-id");
|
||||
}
|
||||
}
|
||||
|
||||
// msglog functions
|
||||
if let Ok(conn) = rusqlite::Connection::open_in_memory() {
|
||||
if crate::model::msglog::schema::init_schema(&conn).is_ok() {
|
||||
let msg = crate::dto::chat::message::ChatMessage::user("hi");
|
||||
let _ = crate::model::msglog::query::insert_message(&conn, "session-id", &msg);
|
||||
let _ = crate::model::msglog::query::query_messages(&conn, "session-id", 10, 0);
|
||||
let _ = crate::model::msglog::query::count_messages(&conn, "session-id");
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay variants suppression
|
||||
let _ = crate::app::state::types::Overlay::Agents;
|
||||
let _ = crate::app::state::types::Overlay::Bash;
|
||||
let _ = crate::app::state::types::Overlay::Workflow;
|
||||
}
|
||||
|
||||
|
||||
fn run_loop(
|
||||
state: &mut app::state::rest::AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
let result = run_loop_inner(state, terminal);
|
||||
if let Err(ref _e) = result {
|
||||
let _ = terminal.clear();
|
||||
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
fn run_loop_inner(
|
||||
state: &mut app::state::rest::AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
use std::time::Duration;
|
||||
use crossterm::event::{Event, KeyEventKind};
|
||||
use controller::input::handle_key;
|
||||
use app::runtime::actions::{Action, apply_action};
|
||||
|
||||
loop {
|
||||
if state.quit {
|
||||
break;
|
||||
}
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now_ms);
|
||||
terminal.draw(|f| {
|
||||
view::draw(f, state);
|
||||
state.dirty = false;
|
||||
})?;
|
||||
if crossterm::event::poll(Duration::from_millis(50))? {
|
||||
match crossterm::event::read()? {
|
||||
Event::Key(key) => {
|
||||
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
|
||||
let actions = handle_key(key, state);
|
||||
for action in actions {
|
||||
apply_action(state, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
apply_action(state, Action::Resize(w, h));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
terminal.clear()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
pub fn builtin_agents() -> Vec<AgentDefinition> {
|
||||
vec![
|
||||
AgentDefinition::new(
|
||||
"coder".to_string(),
|
||||
"coder".to_string(),
|
||||
).with_system_prompt(
|
||||
"You are a coding agent. Write correct, idiomatic Rust code.".to_string()
|
||||
).with_allowed_tools(
|
||||
vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
"edit".to_string(),
|
||||
"bash".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"git_operator".to_string(),
|
||||
]
|
||||
).with_max_steps(25),
|
||||
|
||||
AgentDefinition::new(
|
||||
"reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
).with_system_prompt(
|
||||
"You are a code reviewer. Focus on correctness, safety, and performance.".to_string()
|
||||
).with_allowed_tools(
|
||||
vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"recall".to_string(),
|
||||
"remember".to_string(),
|
||||
]
|
||||
).with_max_steps(10),
|
||||
|
||||
AgentDefinition::new(
|
||||
"researcher".to_string(),
|
||||
"researcher".to_string(),
|
||||
).with_system_prompt(
|
||||
"You are a research agent. Search and synthesize information.".to_string()
|
||||
).with_allowed_tools(
|
||||
vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"search".to_string(),
|
||||
"web_fetch".to_string(),
|
||||
]
|
||||
).with_max_steps(15),
|
||||
|
||||
AgentDefinition::new(
|
||||
"planner".to_string(),
|
||||
"planner".to_string(),
|
||||
).with_system_prompt(
|
||||
"You are a planning agent. Break down tasks into clear steps.".to_string()
|
||||
).with_allowed_tools(
|
||||
vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"plan".to_string(),
|
||||
]
|
||||
).with_max_steps(20),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
pub fn load_global_agents() -> Vec<AgentDefinition> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
if !agents_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut agents = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "json") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(def) = serde_json::from_str::<AgentDefinition>(&content) {
|
||||
agents.push(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
agents
|
||||
}
|
||||
|
||||
pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
let path = agents_dir.join(format!("{}.json", def.name));
|
||||
let content = serde_json::to_string_pretty(def)?;
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_global_agent(name: &str) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let path = store.base_dir.join("agents").join(format!("{}.json", name));
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod builtin;
|
||||
pub mod global;
|
||||
pub mod session;
|
||||
@@ -0,0 +1,35 @@
|
||||
use std::path::Path;
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
if !agents_file.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
match std::fs::read_to_string(&agents_file) {
|
||||
Ok(content) => {
|
||||
serde_json::from_str(&content).unwrap_or_default()
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
let content = serde_json::to_string_pretty(agents)?;
|
||||
std::fs::write(agents_file, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Result<()> {
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != def.name);
|
||||
agents.push(def);
|
||||
save_session_agents(session_dir, &agents)
|
||||
}
|
||||
|
||||
pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<()> {
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != name);
|
||||
save_session_agents(session_dir, &agents)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub providers: HashMap<String, ProviderConfig>,
|
||||
pub model_roles: HashMap<String, ModelRole>,
|
||||
pub default_provider: String,
|
||||
pub default_model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_base: String,
|
||||
pub api_key_env: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelRole {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
let mut providers = HashMap::new();
|
||||
providers.insert("openrouter".to_string(), ProviderConfig {
|
||||
api_base: "https://openrouter.ai/api/v1".to_string(),
|
||||
api_key_env: Some("OPENROUTER_API_KEY".to_string()),
|
||||
default_model: Some("anthropic/claude-opus-4-8".to_string()),
|
||||
});
|
||||
let mut model_roles = HashMap::new();
|
||||
model_roles.insert("default".to_string(), ModelRole {
|
||||
provider: "openrouter".to_string(),
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
max_tokens: Some(8192),
|
||||
temperature: Some(0.7),
|
||||
});
|
||||
AppConfig {
|
||||
providers,
|
||||
model_roles,
|
||||
default_provider: "openrouter".to_string(),
|
||||
default_model: "anthropic/claude-opus-4-8".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> Self {
|
||||
let store = super::store::Store::new();
|
||||
let path = store.base_dir.join("app_config.json");
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
|
||||
pub system_prompt: String,
|
||||
pub session_id: String,
|
||||
pub model: String,
|
||||
pub max_tokens: u32,
|
||||
pub temperature: f32,
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
pub fn new(system_prompt: String, session_id: String) -> Self {
|
||||
Conversation {
|
||||
messages: Vec::new(),
|
||||
system_prompt,
|
||||
session_id,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
max_tokens: 8192,
|
||||
temperature: 0.7,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, msg: crate::dto::chat::message::ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
pub fn rebuild_system(&mut self, new_prompt: String) {
|
||||
self.system_prompt = new_prompt;
|
||||
self.messages.retain(|m| {
|
||||
!matches!(m.role, crate::dto::chat::message::Role::System)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn to_api_messages(&self) -> Vec<crate::dto::chat::message::ChatMessage> {
|
||||
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
|
||||
msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt));
|
||||
msgs.extend(self.messages.iter().cloned());
|
||||
msgs
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditLogEntry {
|
||||
pub ts: i64,
|
||||
pub tool: String,
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
pub content_sha256: String,
|
||||
pub bytes_delta: i64,
|
||||
pub origin: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditLog {
|
||||
pub entries: Vec<EditLogEntry>,
|
||||
pub path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
pub fn new(session_dir: &std::path::Path) -> Self {
|
||||
EditLog {
|
||||
entries: Vec::new(),
|
||||
path: session_dir.join("edits.jsonl"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
|
||||
let line = serde_json::to_string(&entry)? + "\n";
|
||||
let parent = self.path.parent().unwrap();
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
use std::io::Write;
|
||||
file.write_all(line.as_bytes())?;
|
||||
self.entries.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let entries: Vec<EditLogEntry> = content
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect();
|
||||
Ok(EditLog {
|
||||
entries,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn recent(&self, n: usize) -> &[EditLogEntry] {
|
||||
let len = self.entries.len();
|
||||
let start = len.saturating_sub(n);
|
||||
&self.entries[start..]
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::session::Session;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Memory {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub kind: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: String,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
pub fn slugify(s: &str) -> Option<String> {
|
||||
let slug: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let slug: String = slug
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if slug.is_empty() || slug.len() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(slug)
|
||||
}
|
||||
|
||||
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
|
||||
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
|
||||
slug_path(memory_dir, &format!("{}.md", slug))
|
||||
}
|
||||
|
||||
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
|
||||
let path = Self::path(memory_dir, &self.name);
|
||||
let parent = path.parent().unwrap();
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default();
|
||||
let content = format!(
|
||||
"---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n---\n\n{}",
|
||||
self.name, self.description, self.kind, self.created_at, self.updated_at, self.lifecycle, outcome_line, self.content
|
||||
);
|
||||
let tmp = parent.join(format!(".{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, &content)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read(memory_dir: &Path, name: &str) -> std::io::Result<Self> {
|
||||
let path = Self::path(memory_dir, name);
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
Self::parse(&content)
|
||||
}
|
||||
|
||||
pub fn parse(content: &str) -> std::io::Result<Self> {
|
||||
let parts: Vec<&str> = content.splitn(2, "---\n").collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter"));
|
||||
}
|
||||
let front: std::collections::HashMap<String, String> = parts[0]
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut it = l.splitn(2, ':');
|
||||
Some((it.next()?.trim().to_string(), it.next()?.trim().to_string()))
|
||||
})
|
||||
.collect();
|
||||
let body = parts.get(1).unwrap_or(&"").trim().to_string();
|
||||
Ok(Memory {
|
||||
name: front.get("name").cloned().unwrap_or_default(),
|
||||
description: front.get("description").cloned().unwrap_or_default(),
|
||||
content: body,
|
||||
kind: front.get("kind").cloned().unwrap_or_else(|| "reference".to_string()),
|
||||
created_at: front.get("created_at").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
||||
lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> {
|
||||
let path = Self::path(memory_dir, name);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list(memory_dir: &Path) -> Vec<String> {
|
||||
let entries = match std::fs::read_dir(memory_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if name == "MEMORY.md" { return None; }
|
||||
let slug = name.strip_suffix(".md")?.to_string();
|
||||
Some(slug)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn load_index(memory_dir: &Path) -> Vec<String> {
|
||||
let index_path = memory_dir.join("MEMORY.md");
|
||||
let content = std::fs::read_to_string(index_path).unwrap_or_default();
|
||||
content.lines().filter_map(|l| {
|
||||
let l = l.trim();
|
||||
if l.is_empty() || l.starts_with('#') { return None; }
|
||||
l.split(']').next().and_then(|s| s.split('[').nth(1)).map(|s| s.to_string())
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
||||
let clean: String = raw.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' })
|
||||
.collect();
|
||||
let clean = clean.trim_start_matches('.').to_string();
|
||||
memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean })
|
||||
}
|
||||
|
||||
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let lessons_content: String = lessons.iter()
|
||||
.map(|l| format!("- {}: {}", l.name, l.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let content = format!(
|
||||
"# Session Retrospective\n\nSession: {}\nCreated: {}\nLessons learned:\n{}\n",
|
||||
session.title, now, lessons_content,
|
||||
);
|
||||
let memory = Memory {
|
||||
name: format!("retrospective-{}", session.id),
|
||||
description: format!("End-of-session retrospective for {}", session.title),
|
||||
content,
|
||||
kind: "retrospective".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
outcome: None,
|
||||
lifecycle: "new".to_string(),
|
||||
};
|
||||
memory.write(session_dir)?;
|
||||
Ok(memory)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod agent_def;
|
||||
pub mod app_config;
|
||||
pub mod conversation;
|
||||
pub mod editlog;
|
||||
pub mod memory;
|
||||
pub mod msglog;
|
||||
pub mod session;
|
||||
pub mod session_lock;
|
||||
pub mod settings;
|
||||
pub mod store;
|
||||
@@ -0,0 +1,46 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![session_id, blob_key, data, mime_type, created_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> {
|
||||
let result = conn.query_row(
|
||||
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||
params![session_id, blob_key],
|
||||
|row| row.get(0),
|
||||
);
|
||||
match result {
|
||||
Ok(data) => Ok(Some(data)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<bool> {
|
||||
let rows = conn.execute(
|
||||
"DELETE FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||
params![session_id, blob_key],
|
||||
)?;
|
||||
Ok(rows > 0)
|
||||
}
|
||||
|
||||
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![session_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
let mut keys = Vec::new();
|
||||
for row in rows {
|
||||
keys.push(row?);
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod blobs;
|
||||
pub mod query;
|
||||
pub mod schema;
|
||||
pub mod summary;
|
||||
@@ -0,0 +1,68 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use anyhow::Result;
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
|
||||
pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result<i64> {
|
||||
let content = msg.content.as_deref();
|
||||
let tool_call_id = msg.tool_call_id.as_deref();
|
||||
let tool_name = msg.name.as_deref();
|
||||
let tool_arguments = msg.tool_calls.as_ref().map(|calls| {
|
||||
serde_json::to_string(calls).unwrap_or_default()
|
||||
});
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
let role_str = match msg.role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
Role::Tool => "tool",
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, tool_arguments, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![session_id, role_str, content, tool_call_id, tool_name, tool_arguments, created_at],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn query_messages(conn: &Connection, session_id: &str, limit: usize, offset: usize) -> Result<Vec<ChatMessage>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT role, content, tool_call_id, tool_name, tool_arguments FROM messages WHERE session_id = ?1 ORDER BY id ASC LIMIT ?2 OFFSET ?3"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![session_id, limit as i64, offset as i64], |row| {
|
||||
let role_str: String = row.get(0)?;
|
||||
let content: Option<String> = row.get(1)?;
|
||||
let tool_call_id: Option<String> = row.get(2)?;
|
||||
let tool_name: Option<String> = row.get(3)?;
|
||||
let tool_arguments: Option<String> = row.get(4)?;
|
||||
let role = match role_str.as_str() {
|
||||
"user" => Role::User,
|
||||
"assistant" => Role::Assistant,
|
||||
"system" => Role::System,
|
||||
"tool" => Role::Tool,
|
||||
_ => Role::User,
|
||||
};
|
||||
let tool_calls = tool_arguments.and_then(|args| {
|
||||
serde_json::from_str(&args).ok()
|
||||
});
|
||||
Ok(ChatMessage {
|
||||
role,
|
||||
content,
|
||||
tool_calls,
|
||||
tool_call_id,
|
||||
name: tool_name,
|
||||
})
|
||||
})?;
|
||||
let mut messages = Vec::new();
|
||||
for row in rows {
|
||||
messages.push(row?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn count_messages(conn: &Connection, session_id: &str) -> Result<i64> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
|
||||
params![session_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use rusqlite::Connection;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_name TEXT,
|
||||
tool_arguments TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES archives(session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
token_count INTEGER DEFAULT 0,
|
||||
summary TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
blob_key TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
mime_type TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(session_id, blob_key)
|
||||
);
|
||||
"
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SummaryRecord {
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
pub model: String,
|
||||
pub message_count: usize,
|
||||
pub token_count: usize,
|
||||
pub summary: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl SummaryRecord {
|
||||
pub fn new(session_id: String, title: String, model: String) -> Self {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
SummaryRecord {
|
||||
session_id,
|
||||
title,
|
||||
model,
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
summary: String::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_summary(&mut self, summary: String) {
|
||||
self.summary = summary;
|
||||
self.updated_at = chrono::Utc::now().timestamp_millis();
|
||||
}
|
||||
|
||||
pub fn increment_counts(&mut self, messages: usize, tokens: usize) {
|
||||
self.message_count += messages;
|
||||
self.token_count += tokens;
|
||||
self.updated_at = chrono::Utc::now().timestamp_millis();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::Utc;
|
||||
|
||||
#[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 {
|
||||
pub fn new(id: String, title: String) -> Self {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("sessions").join(&self.id)
|
||||
}
|
||||
|
||||
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("conversation.json")
|
||||
}
|
||||
|
||||
pub fn edit_log_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("edits.jsonl")
|
||||
}
|
||||
|
||||
pub fn msglog_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("msglog.db")
|
||||
}
|
||||
|
||||
pub fn save(&self, base_dir: &Path) -> std::io::Result<()> {
|
||||
let dir = self.session_dir(base_dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("session.json");
|
||||
let data = serde_json::to_string_pretty(self)?;
|
||||
let tmp = dir.join("session.json.tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> {
|
||||
let path = base_dir.join("sessions").join(id).join("session.json");
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let session: Session = serde_json::from_str(&data)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub fn list(base_dir: &Path) -> Vec<Self> {
|
||||
let sessions_dir = base_dir.join("sessions");
|
||||
let entries = match std::fs::read_dir(&sessions_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| {
|
||||
let id = e.file_name().to_string_lossy().to_string();
|
||||
Session::load(&id, base_dir).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
|
||||
pub struct SessionLock {
|
||||
path: PathBuf,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
SessionLock {
|
||||
path: session_dir.join(".lock"),
|
||||
pid: std::process::id(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||
if self.path.exists() {
|
||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
if self.is_alive(pid) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(&self.path, self.pid.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn unlock(&self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
fn is_alive(&self, pid: u32) -> bool {
|
||||
unsafe { libc::kill(pid as i32, 0) == 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Default)]
|
||||
pub enum InternetMode {
|
||||
#[default]
|
||||
Off,
|
||||
ReadOnly,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl InternetMode {
|
||||
pub fn can_fetch(&self) -> bool {
|
||||
matches!(self, InternetMode::Full)
|
||||
}
|
||||
|
||||
pub fn can_download(&self) -> bool {
|
||||
matches!(self, InternetMode::Full)
|
||||
}
|
||||
|
||||
pub fn can_search(&self) -> bool {
|
||||
matches!(self, InternetMode::Full)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub internet_mode: InternetMode,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
pub max_tokens: u32,
|
||||
pub temperature: f32,
|
||||
pub review_enabled: bool,
|
||||
pub review_max_lessons_per_run: usize,
|
||||
pub adaptive_review_max_skip: u32,
|
||||
pub workflow_max_concurrency: usize,
|
||||
pub session_archive_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
internet_mode: InternetMode::Off,
|
||||
provider: "openrouter".to_string(),
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
api_key: None,
|
||||
max_tokens: 8192,
|
||||
temperature: 0.7,
|
||||
review_enabled: true,
|
||||
review_max_lessons_per_run: 5,
|
||||
adaptive_review_max_skip: 3,
|
||||
workflow_max_concurrency: 5,
|
||||
session_archive_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn load() -> Self {
|
||||
let store = super::store::Store::new();
|
||||
let path = store.base_dir.join("settings.json");
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
let store = super::store::Store::new();
|
||||
std::fs::create_dir_all(&store.base_dir)?;
|
||||
let path = store.base_dir.join("settings.json");
|
||||
let s = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(path, s)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Store {
|
||||
pub base_dir: PathBuf,
|
||||
pub scratch_root: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub session_images_dir: PathBuf,
|
||||
pub download_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new() -> Self {
|
||||
let base = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share"))
|
||||
.join("zesdex");
|
||||
let scratch = std::env::temp_dir().join("zesdex-scratch");
|
||||
Store {
|
||||
memory_dir: base.join("memory"),
|
||||
scratch_root: scratch,
|
||||
session_images_dir: base.join("session-images"),
|
||||
download_dir: base.join("downloads"),
|
||||
base_dir: base,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_dirs(&self) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&self.base_dir)?;
|
||||
std::fs::create_dir_all(&self.memory_dir)?;
|
||||
std::fs::create_dir_all(&self.scratch_root)?;
|
||||
std::fs::create_dir_all(&self.session_images_dir)?;
|
||||
std::fs::create_dir_all(&self.download_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
pub const BANNER: &str = r"
|
||||
███████╗███████╗███████╗██████╗ ███████╗██╗ ██╗
|
||||
╚══███╔╝██╔════╝██╔════╝██╔══██╗██╔════╝╚██╗██╔╝
|
||||
███╔╝ █████╗ ███████╗██║ ██║█████╗ ╚███╔╝
|
||||
███╔╝ ██╔══╝ ╚════██║██║ ██║██╔══╝ ██╔██╗
|
||||
███████╗███████╗███████║██████╔╝███████╗██╔╝ ██╗
|
||||
╚══════╝╚══════╝╚══════╝╚═════╝ ╚══════╝╚═╝ ╚═╝
|
||||
Autonomous Agentic Shell
|
||||
";
|
||||
|
||||
pub const HELP_TEXT: &str = "
|
||||
ZESDEX - Help
|
||||
=============
|
||||
Navigation:
|
||||
Ctrl+Q Quit
|
||||
Ctrl+H Help (this screen)
|
||||
Ctrl+P Settings
|
||||
Ctrl+A Cycle agent mode (Auto/Normal/Plan/Yolo)
|
||||
Ctrl+B Bash panel
|
||||
Ctrl+S Session hub
|
||||
Ctrl+T Task list
|
||||
Ctrl+W Workflow view
|
||||
Ctrl+K Key input mode
|
||||
Esc Cancel / back
|
||||
Tab Autocomplete
|
||||
Up/Down History navigation
|
||||
|
||||
Modes:
|
||||
Auto Automatic approval of most operations
|
||||
Normal Manual approval for risky operations
|
||||
Plan Planning mode - no code changes
|
||||
Yolo Unrestricted - full autonomy
|
||||
|
||||
Input:
|
||||
/help Show help
|
||||
/clear Clear screen
|
||||
/mode Show current mode
|
||||
/exit Exit application
|
||||
/settings Open settings
|
||||
/session Session management
|
||||
|
||||
Commands:
|
||||
Any text is sent to the AI assistant as a prompt.
|
||||
File paths use workspace-relative notation.
|
||||
Use [0]/path for multi-workspace setups.
|
||||
";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user