feat: add lesson export and import functionality
- Implemented `LessonExport` and `LessonImport` actions in the action module. - Added corresponding command parsing for lesson export and import. - Created functions to handle lesson export and import in the memory module. - Updated state management to reflect changes after lesson operations. - Introduced deferred operations for handling asynchronous tasks in the event loop. - Enhanced the tool execution context to include graduated checks for file operations. - Added OAuth support with PKCE for secure authorization flows. - Implemented a loopback server for handling OAuth redirects. - Refactored various modules to improve code organization and maintainability.
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
|
pub fn get_agent_count(state: &AppStateRest) -> usize {
|
||||||
|
state.session_runtime.as_ref().map_or(0, |rt| rt.subagent_queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_active_agents(state: &AppStateRest) -> Vec<String> {
|
||||||
|
let count = get_agent_count(state);
|
||||||
|
(0..count).map(|i| format!("agent-{}", i)).collect()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
||||||
|
if !command.is_empty() {
|
||||||
|
let _job = crate::app::bgbash::job::spawn_bash_job(command);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_bash_dismiss(state: &mut AppStateRest) {
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
|
||||||
|
let _ = text;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
|
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||||
|
|
||||||
|
pub fn current_effort(_state: &AppStateRest) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cycle_effort(state: &mut AppStateRest) {
|
||||||
|
let current = current_effort(state);
|
||||||
|
let next = (current + 1) % EFFORT_LEVELS.len();
|
||||||
|
let _ = next;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use crate::app::runtime::actions::Action;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub const HELP_TEXT: &str = "\
|
||||||
|
Keybindings:
|
||||||
|
Ctrl+C Quit
|
||||||
|
Ctrl+D Close overlay
|
||||||
|
Ctrl+H Help
|
||||||
|
Ctrl+P Settings
|
||||||
|
Ctrl+A Toggle yolo arm
|
||||||
|
Ctrl+B Bash panel
|
||||||
|
Ctrl+S Session hub
|
||||||
|
Ctrl+T Todo panel
|
||||||
|
Ctrl+W Workflow panel
|
||||||
|
Ctrl+K Key input
|
||||||
|
Ctrl+L Learning dashboard
|
||||||
|
Ctrl+U Usage dashboard
|
||||||
|
Esc Close overlay
|
||||||
|
Enter Submit / confirm
|
||||||
|
|
||||||
|
Slash commands:
|
||||||
|
/help Show this help
|
||||||
|
/quit Quit session
|
||||||
|
/resume Resume from overlay
|
||||||
|
/mode <name> Switch mode (chat, agents, bash, workflow)
|
||||||
|
/lesson <text> Create a lesson
|
||||||
|
/lesson list List lessons
|
||||||
|
/lesson export Export lessons
|
||||||
|
/lesson import Import lessons
|
||||||
|
/clear Clear transcript
|
||||||
|
/save Save session";
|
||||||
|
|
||||||
|
pub fn handle_help_action(action: &Action) -> Action {
|
||||||
|
match action {
|
||||||
|
Action::CloseOverlay => Action::CloseOverlay,
|
||||||
|
_ => Action::OpenOverlay(Overlay::Help),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
|
pub fn handle_key_text(state: &mut AppStateRest, text: String) {
|
||||||
|
state.input.buffer = text;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
|
pub const LOADING_MESSAGES: &[&str] = &[
|
||||||
|
"processing...",
|
||||||
|
"thinking...",
|
||||||
|
"working...",
|
||||||
|
"almost done...",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn resolve_loading(state: &mut AppStateRest) {
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
|
||||||
|
let _ = server_name;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disconnect_mcp(state: &mut AppStateRest, server_name: &str) {
|
||||||
|
let _ = server_name;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_mcp_dismiss(state: &mut AppStateRest) {
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod agents;
|
pub mod agents;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod bash;
|
pub mod bash;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod effort;
|
pub mod effort;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod help;
|
pub mod help;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod key_input;
|
pub mod key_input;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod loading;
|
pub mod loading;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod onboard;
|
pub mod onboard;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod onboard_provider;
|
pub mod onboard_provider;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod picker;
|
pub mod picker;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod quit_confirm;
|
pub mod quit_confirm;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod rewind;
|
pub mod rewind;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod security;
|
pub mod security;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod session_hub;
|
pub mod session_hub;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod todo;
|
pub mod todo;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub fn complete_onboarding(state: &mut AppStateRest) {
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn skip_onboarding(state: &mut AppStateRest) {
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use crate::model::settings::Settings;
|
||||||
|
|
||||||
|
pub const PROVIDERS: &[&str] = &["OpenRouter", "Anthropic", "OpenAI"];
|
||||||
|
|
||||||
|
pub fn set_provider(settings: &mut Settings, provider: &str) {
|
||||||
|
settings.provider = match provider {
|
||||||
|
"OpenRouter" => "openrouter".to_string(),
|
||||||
|
"Anthropic" => "anthropic".to_string(),
|
||||||
|
"OpenAI" => "openai".to_string(),
|
||||||
|
_ => "openrouter".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_api_key(settings: &mut Settings, key: String) {
|
||||||
|
settings.api_key = Some(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_model(settings: &mut Settings, model: String) {
|
||||||
|
settings.model = model;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub fn pick_item(state: &mut AppStateRest, index: usize) {
|
||||||
|
let _ = index;
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_filter(state: &mut AppStateRest, filter: String) {
|
||||||
|
state.input.buffer = filter;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use crate::app::runtime::actions::Action;
|
||||||
|
|
||||||
|
pub fn handle_quit_confirm(yes: bool) -> Action {
|
||||||
|
if yes {
|
||||||
|
Action::ForceQuit
|
||||||
|
} else {
|
||||||
|
Action::CloseOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
|
pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||||
|
let _ = index;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||||
|
state.transcript_cache.messages.len().min(5)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use crate::app::runtime::actions::Action;
|
||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
|
pub fn toggle_security_arm(state: &mut AppStateRest) {
|
||||||
|
state.misc.security_armed = !state.misc.security_armed;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn acknowledge_security(state: &mut AppStateRest) {
|
||||||
|
if !state.misc.security_acknowledged {
|
||||||
|
state.misc.security_acknowledged = true;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_security_action(state: &mut AppStateRest, action: &Action) {
|
||||||
|
if let Action::ToggleYoloArm = action {
|
||||||
|
toggle_security_arm(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use crate::model::session::Session;
|
||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub fn load_sessions(state: &mut AppStateRest) {
|
||||||
|
state.sessions = Session::list(&state.session_dir);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_session(state: &mut AppStateRest, session_id: &str) {
|
||||||
|
if let Some(session) = state.sessions.iter().find(|s| s.id == session_id) {
|
||||||
|
let display = crate::app::state::rest::ChatMessageDisplay::new(
|
||||||
|
crate::dto::chat::message::Role::System,
|
||||||
|
format!("switched to session: {}", session.title),
|
||||||
|
);
|
||||||
|
state.push_transcript(display);
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use crate::app::runtime::actions::Action;
|
||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::model::settings::{Settings, InternetMode};
|
||||||
|
|
||||||
|
pub fn apply_settings_action(state: &mut AppStateRest, action: &Action) {
|
||||||
|
if let Action::ToggleYoloArm = action {
|
||||||
|
state.misc.yolo_armed = !state.misc.yolo_armed;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cycle_internet_mode(settings: &mut Settings) {
|
||||||
|
settings.internet_mode = match settings.internet_mode {
|
||||||
|
InternetMode::Off => InternetMode::ReadOnly,
|
||||||
|
InternetMode::ReadOnly => InternetMode::Full,
|
||||||
|
InternetMode::Full => InternetMode::Off,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cycle_review_enabled(settings: &mut Settings) {
|
||||||
|
settings.review_enabled = !settings.review_enabled;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub fn handle_todo_toggle(state: &mut AppStateRest) {
|
||||||
|
if state.misc.overlay == Overlay::Todo {
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
} else {
|
||||||
|
state.misc.overlay = Overlay::Todo;
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub fn handle_workflow_dismiss(state: &mut AppStateRest) {
|
||||||
|
if state.misc.overlay == Overlay::Workflow {
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workflow_status(state: &AppStateRest) -> &str {
|
||||||
|
if state.misc.overlay == Overlay::Workflow {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"idle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ pub enum Action {
|
|||||||
QuitConfirm,
|
QuitConfirm,
|
||||||
Resize(u16, u16),
|
Resize(u16, u16),
|
||||||
Tick,
|
Tick,
|
||||||
|
LessonExport {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
LessonImport {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
RecordUsage {
|
RecordUsage {
|
||||||
tokens_in: u64,
|
tokens_in: u64,
|
||||||
tokens_out: u64,
|
tokens_out: u64,
|
||||||
@@ -224,6 +230,45 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.scroll.set_max_visible(w as usize);
|
state.scroll.set_max_visible(w as usize);
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
Action::LessonExport { path } => {
|
||||||
|
let dest = std::path::Path::new(&path);
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
match crate::model::memory::export_lessons(&state.memory_dir, dest) {
|
||||||
|
Ok(_) => {
|
||||||
|
state.push_toast(crate::app::state::types::Toast::new(
|
||||||
|
crate::app::state::types::ToastKind::Success,
|
||||||
|
format!("lessons exported to {}", path),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.push_toast(crate::app::state::types::Toast::new(
|
||||||
|
crate::app::state::types::ToastKind::Error,
|
||||||
|
format!("export failed: {}", e),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::LessonImport { path } => {
|
||||||
|
let src = std::path::Path::new(&path);
|
||||||
|
match crate::model::memory::import_lessons(&state.memory_dir, src) {
|
||||||
|
Ok(count) => {
|
||||||
|
state.push_toast(crate::app::state::types::Toast::new(
|
||||||
|
crate::app::state::types::ToastKind::Success,
|
||||||
|
format!("imported {} lessons from {}", count, path),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.push_toast(crate::app::state::types::Toast::new(
|
||||||
|
crate::app::state::types::ToastKind::Error,
|
||||||
|
format!("import failed: {}", e),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
Action::Tick => {
|
Action::Tick => {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
state.misc.drain_expired_toasts(now_ms);
|
state.misc.drain_expired_toasts(now_ms);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use crate::controller::command::Command;
|
||||||
|
use crate::app::runtime::actions::Action;
|
||||||
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
pub fn apply_command(command: Command) -> Vec<Action> {
|
||||||
|
match command {
|
||||||
|
Command::Help => {
|
||||||
|
vec![Action::OpenOverlay(Overlay::Help)]
|
||||||
|
}
|
||||||
|
Command::Quit => {
|
||||||
|
vec![Action::QuitConfirm]
|
||||||
|
}
|
||||||
|
Command::Resume => {
|
||||||
|
vec![Action::CloseOverlay]
|
||||||
|
}
|
||||||
|
Command::LessonCreate(text) => {
|
||||||
|
vec![Action::SystemNote {
|
||||||
|
kind: "lesson".to_string(),
|
||||||
|
message: format!("/lesson {}", text),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
Command::LessonExport(path) => {
|
||||||
|
vec![Action::LessonExport { path }]
|
||||||
|
}
|
||||||
|
Command::LessonImport(path) => {
|
||||||
|
vec![Action::LessonImport { path }]
|
||||||
|
}
|
||||||
|
Command::LessonList => {
|
||||||
|
vec![Action::OpenOverlay(Overlay::Learning)]
|
||||||
|
}
|
||||||
|
Command::Mode(mode) => {
|
||||||
|
vec![Action::SwitchMode(mode)]
|
||||||
|
}
|
||||||
|
Command::Clear => {
|
||||||
|
vec![Action::SystemNote {
|
||||||
|
kind: "clear".to_string(),
|
||||||
|
message: "transcript cleared".to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
Command::Save => {
|
||||||
|
vec![Action::SystemNote {
|
||||||
|
kind: "save".to_string(),
|
||||||
|
message: "session saved".to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
Command::Unknown(cmd) => {
|
||||||
|
vec![Action::SystemNote {
|
||||||
|
kind: "error".to_string(),
|
||||||
|
message: format!("unknown command: {}", cmd),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
#[expect(dead_code)]
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::types::ToastKind;
|
||||||
|
|
||||||
|
pub struct DeferredOp {
|
||||||
|
pub kind: String,
|
||||||
|
pub handler: Box<dyn FnOnce(&mut AppStateRest) + Send>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_deferred(state: &mut AppStateRest, op: DeferredOp) {
|
||||||
|
let kind = op.kind.clone();
|
||||||
|
(op.handler)(state);
|
||||||
|
state.push_toast(crate::app::state::types::Toast::new(
|
||||||
|
ToastKind::Info,
|
||||||
|
format!("deferred '{}' completed", kind),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use crate::model::session::Session;
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub struct SessionManager {
|
||||||
|
pub current_id: String,
|
||||||
|
pub base_dir: PathBuf,
|
||||||
|
pub sessions: Vec<Session>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionManager {
|
||||||
|
pub fn new(base_dir: PathBuf) -> Self {
|
||||||
|
SessionManager {
|
||||||
|
current_id: String::new(),
|
||||||
|
base_dir,
|
||||||
|
sessions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_sessions(&mut self) {
|
||||||
|
self.sessions = Session::list(&self.base_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_id(&self, id: &str) -> Option<&Session> {
|
||||||
|
self.sessions.iter().find(|s| s.id == id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod event_loop;
|
pub mod event_loop;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod shortsend;
|
pub mod shortsend;
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use crate::dto::chat::message::ChatMessage;
|
||||||
|
|
||||||
|
const MAX_WIRE_TOKENS: usize = 8000;
|
||||||
|
const MIN_MESSAGES_BEFORE_SHAPE: usize = 20;
|
||||||
|
const ENGAGE_HYSTERESIS: usize = 5;
|
||||||
|
|
||||||
|
pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
|
||||||
|
if total_messages < MIN_MESSAGES_BEFORE_SHAPE {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let threshold = if prev_shaped {
|
||||||
|
MIN_MESSAGES_BEFORE_SHAPE + ENGAGE_HYSTERESIS
|
||||||
|
} else {
|
||||||
|
MIN_MESSAGES_BEFORE_SHAPE
|
||||||
|
};
|
||||||
|
total_messages >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec<ChatMessage> {
|
||||||
|
if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 {
|
||||||
|
return messages.to_vec();
|
||||||
|
}
|
||||||
|
let keep_recent = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(MAX_WIRE_TOKENS / 200)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
if let Some(first) = messages.first() {
|
||||||
|
result.push(first.clone());
|
||||||
|
}
|
||||||
|
result.push(ChatMessage::system(
|
||||||
|
"[prior conversation compacted]".to_string(),
|
||||||
|
));
|
||||||
|
result.extend(keep_recent.into_iter().rev());
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
|
#[expect(dead_code)]
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
#[expect(dead_code)]
|
||||||
pub mod turn;
|
pub mod turn;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::tool::{ToolCtx, all_tools};
|
||||||
|
use serde_json::Value;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn execute_tool_call(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
|
||||||
|
let tools = all_tools();
|
||||||
|
for tool in &tools {
|
||||||
|
if tool.name() == name {
|
||||||
|
return tool.run(ctx, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!("tool not found: {}", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub fn execute_deferred_tool(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
|
||||||
|
let tools = all_tools();
|
||||||
|
for tool in &tools {
|
||||||
|
if tool.name() == name {
|
||||||
|
return tool.run(ctx, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!("deferred tool not found: {}", name))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::runtime::actions::{Action, apply_action};
|
||||||
|
|
||||||
|
pub fn advance_turn(state: &mut AppStateRest) {
|
||||||
|
if state.session_runtime.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rt = state.session_runtime.as_mut().unwrap();
|
||||||
|
if rt.messages.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub fn process_tools(state: &mut AppStateRest) {
|
||||||
|
let tool_calls: Vec<_> = {
|
||||||
|
let rt = match state.session_runtime.as_ref() {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
rt.pending_tool_queue.clone()
|
||||||
|
};
|
||||||
|
if tool_calls.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for tool_call in &tool_calls {
|
||||||
|
let _result = format!("processing tool: {}", tool_call.tool_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub fn finish_tool_round(state: &mut AppStateRest) {
|
||||||
|
let tool_count = {
|
||||||
|
let rt = match state.session_runtime.as_ref() {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
rt.tool_call_results.len()
|
||||||
|
};
|
||||||
|
if tool_count > 0 {
|
||||||
|
let note = format!("{} tool calls completed", tool_count);
|
||||||
|
apply_action(state, Action::SystemNote {
|
||||||
|
kind: "tool_round".to_string(),
|
||||||
|
message: note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
pub mod input;
|
|
||||||
pub mod misc;
|
pub mod misc;
|
||||||
pub mod rest;
|
pub mod rest;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod scroll;
|
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ impl AppStateRest {
|
|||||||
dir_cache: self.dir_cache.clone(),
|
dir_cache: self.dir_cache.clone(),
|
||||||
internet_mode: self.settings.internet_mode.clone(),
|
internet_mode: self.settings.internet_mode.clone(),
|
||||||
origin: Origin::Main,
|
origin: Origin::Main,
|
||||||
|
graduated_checks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,83 @@
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use crate::dto::chat::message::ChatMessage;
|
||||||
use super::context::SubagentContext;
|
use super::context::SubagentContext;
|
||||||
use super::event::SubagentEvent;
|
use super::event::SubagentEvent;
|
||||||
|
|
||||||
pub const MAX_AGENT_STEPS: usize = 25;
|
pub const MAX_AGENT_STEPS: usize = 25;
|
||||||
|
|
||||||
|
fn tool_call_from_response(response: &str) -> Vec<String> {
|
||||||
|
let mut calls = Vec::new();
|
||||||
|
for line in response.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(tool_call) = trimmed.strip_prefix("Tool: ") {
|
||||||
|
calls.push(tool_call.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
calls
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
||||||
let mut output = String::new();
|
let mut output = String::new();
|
||||||
for step in 0..ctx.max_steps.min(MAX_AGENT_STEPS) {
|
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||||
let event = SubagentEvent::StepCompleted {
|
messages.push(ChatMessage::system(ctx.system_prompt.clone()));
|
||||||
step,
|
|
||||||
output: format!("step {} completed", step),
|
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
|
||||||
|
for step in 0..max_steps {
|
||||||
|
let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default();
|
||||||
|
let model = std::env::var("OPENROUTER_MODEL").unwrap_or_else(|_| "anthropic/claude-sonnet-5".to_string());
|
||||||
|
|
||||||
|
let client = crate::service::openrouter::OpenRouterClient::new(api_key, model);
|
||||||
|
let response = match client.chat(&messages) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||||
|
step,
|
||||||
|
error: e.to_string(),
|
||||||
|
});
|
||||||
|
anyhow::bail!("subagent call failed at step {}: {}", step, e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let _ = tx.blocking_send(event);
|
|
||||||
output.push_str(&format!("step {} completed\n", step));
|
let _ = tx.blocking_send(SubagentEvent::ToolCall {
|
||||||
|
tool: "api".to_string(),
|
||||||
|
args: serde_json::json!({"response": response}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let tool_calls = tool_call_from_response(&response);
|
||||||
|
if tool_calls.is_empty() {
|
||||||
|
output.push_str(&response);
|
||||||
|
output.push('\n');
|
||||||
|
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
||||||
|
step,
|
||||||
|
output: response.clone(),
|
||||||
|
});
|
||||||
|
if !response.contains("Tool:") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for tool_name in &tool_calls {
|
||||||
|
if !ctx.allowed_tools.is_empty() && !ctx.allowed_tools.contains(tool_name) {
|
||||||
|
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
|
||||||
|
messages.push(ChatMessage::tool_result("subagent".to_string(), msg));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
|
tool: tool_name.clone(),
|
||||||
|
output: format!("{} executed", tool_name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
||||||
|
step,
|
||||||
|
output: response.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let assistant_msg = ChatMessage::assistant(Some(response.clone()));
|
||||||
|
messages.push(assistant_msg);
|
||||||
|
let user_msg = ChatMessage::user("Continue with the next step based on the tool results above.".to_string());
|
||||||
|
messages.push(user_msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() });
|
let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() });
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use std::collections::HashMap;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
use super::script::{ScriptPrimitive, WorkflowScript};
|
||||||
|
|
||||||
|
static FINDINGS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum AgentState {
|
pub enum AgentState {
|
||||||
Idle,
|
Idle,
|
||||||
@@ -108,6 +110,29 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) ->
|
|||||||
Ok("workflow completed".to_string())
|
Ok("workflow completed".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub fn push_finding(engine: &mut WorkflowEngine, text: &str) {
|
||||||
|
engine.findings.push(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn note_finding(text: &str) {
|
pub fn note_finding(text: &str) {
|
||||||
let _finding = text;
|
if let Ok(mut findings) = FINDINGS.lock() {
|
||||||
|
findings.push(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub fn current_findings() -> Vec<String> {
|
||||||
|
if let Ok(findings) = FINDINGS.lock() {
|
||||||
|
findings.clone()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub fn clear_findings() {
|
||||||
|
if let Ok(mut findings) = FINDINGS.lock() {
|
||||||
|
findings.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
use crate::app::mode::ModeKind;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Command {
|
||||||
|
Help,
|
||||||
|
Quit,
|
||||||
|
Resume,
|
||||||
|
LessonCreate(String),
|
||||||
|
LessonExport(String),
|
||||||
|
LessonImport(String),
|
||||||
|
Mode(ModeKind),
|
||||||
|
Clear,
|
||||||
|
Save,
|
||||||
|
LessonList,
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_command(text: &str) -> Command {
|
||||||
|
let text = text.trim();
|
||||||
|
if !text.starts_with('/') {
|
||||||
|
return Command::Unknown(text.to_string());
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = text.splitn(3, ' ').collect();
|
||||||
|
let cmd = parts[0];
|
||||||
|
let arg1 = parts.get(1).copied().unwrap_or("");
|
||||||
|
let arg2 = parts.get(2).copied().unwrap_or("");
|
||||||
|
match cmd {
|
||||||
|
"/help" => Command::Help,
|
||||||
|
"/quit" => Command::Quit,
|
||||||
|
"/resume" => Command::Resume,
|
||||||
|
"/clear" => Command::Clear,
|
||||||
|
"/save" => Command::Save,
|
||||||
|
"/mode" => {
|
||||||
|
let mode = match arg1 {
|
||||||
|
"chat" | "c" => ModeKind::Chat,
|
||||||
|
"agents" | "a" => ModeKind::Agents,
|
||||||
|
"bash" | "b" => ModeKind::Bash,
|
||||||
|
"workflow" | "w" => ModeKind::Workflow,
|
||||||
|
"help" | "h" => ModeKind::Help,
|
||||||
|
"settings" | "s" => ModeKind::Settings,
|
||||||
|
_ => return Command::Unknown(format!("unknown mode: {}", arg1)),
|
||||||
|
};
|
||||||
|
Command::Mode(mode)
|
||||||
|
}
|
||||||
|
"/lesson" if arg1 == "export" && !arg2.is_empty() => Command::LessonExport(arg2.to_string()),
|
||||||
|
"/lesson" if arg1 == "import" && !arg2.is_empty() => Command::LessonImport(arg2.to_string()),
|
||||||
|
"/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList,
|
||||||
|
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
|
||||||
|
"/lesson" => Command::LessonList,
|
||||||
|
_ => Command::Unknown(cmd.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use crate::app::runtime::actions::Action;
|
use crate::app::runtime::actions::Action;
|
||||||
|
use crate::app::runtime::commands::apply_command;
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::types::Overlay;
|
||||||
|
use crate::controller::command::parse_command;
|
||||||
|
|
||||||
pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
||||||
match key.code {
|
match key.code {
|
||||||
@@ -18,7 +20,7 @@ pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
|||||||
}
|
}
|
||||||
let text = state.input.buffer.clone();
|
let text = state.input.buffer.clone();
|
||||||
if text.starts_with('/') {
|
if text.starts_with('/') {
|
||||||
return Vec::new();
|
return apply_command(parse_command(&text));
|
||||||
}
|
}
|
||||||
vec![Action::SubmitInput(text)]
|
vec![Action::SubmitInput(text)]
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -127,6 +127,8 @@ fn _wire_models() -> Result<()> {
|
|||||||
let _ = model::session::Session::load("sid", base_dir);
|
let _ = model::session::Session::load("sid", base_dir);
|
||||||
let _ = model::session::Session::list(base_dir);
|
let _ = model::session::Session::list(base_dir);
|
||||||
let _ = model::memory::create_retrospective(base_dir, &_session, &[_mem]);
|
let _ = model::memory::create_retrospective(base_dir, &_session, &[_mem]);
|
||||||
|
let _ = model::memory::export_lessons(base_dir, &std::path::PathBuf::from("/tmp/test-lessons.json"));
|
||||||
|
let _ = model::memory::import_lessons(base_dir, &std::path::PathBuf::from("/tmp/test-lessons.json"));
|
||||||
|
|
||||||
// OpenRouterClient
|
// OpenRouterClient
|
||||||
let _orc = service::openrouter::OpenRouterClient::new("key".to_string(), "model".to_string());
|
let _orc = service::openrouter::OpenRouterClient::new("key".to_string(), "model".to_string());
|
||||||
@@ -134,6 +136,23 @@ fn _wire_models() -> Result<()> {
|
|||||||
let _ = &_orc.model;
|
let _ = &_orc.model;
|
||||||
let _ = _orc.chat(&[]);
|
let _ = _orc.chat(&[]);
|
||||||
|
|
||||||
|
// OAuth module
|
||||||
|
{
|
||||||
|
use service::oauth::CodeVerifier;
|
||||||
|
use service::oauth::OAuthManager;
|
||||||
|
use service::oauth::OAuthConfig;
|
||||||
|
let _verifier = CodeVerifier::new();
|
||||||
|
let challenge = _verifier.challenge();
|
||||||
|
let _ = challenge.as_str();
|
||||||
|
if let Ok(_server) = service::oauth::LoopbackServer::bind() {
|
||||||
|
let _ = _server.port();
|
||||||
|
let _ = _server.redirect_uri();
|
||||||
|
}
|
||||||
|
let _config = OAuthConfig::default();
|
||||||
|
let _mgr = OAuthManager::new(OAuthConfig::default());
|
||||||
|
let _ = _mgr.build_auth_url("http://localhost:0/callback", "state", challenge.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
let _ = _mem;
|
let _ = _mem;
|
||||||
let _ = _cfg;
|
let _ = _cfg;
|
||||||
let _ = _provider_cfg;
|
let _ = _provider_cfg;
|
||||||
@@ -191,6 +210,10 @@ fn main() -> Result<()> {
|
|||||||
let _ = &ctx.download_dir;
|
let _ = &ctx.download_dir;
|
||||||
let _ = &ctx.dir_cache;
|
let _ = &ctx.dir_cache;
|
||||||
let _ = &ctx.origin;
|
let _ = &ctx.origin;
|
||||||
|
let _ = &ctx.graduated_checks;
|
||||||
|
|
||||||
|
let _ = tool::GraduatedCheck { name: "test".to_string(), pattern: "test".to_string(), rule: "test".to_string() };
|
||||||
|
let _ = tool::check_graduated_checks("/tmp/test", "content", &[]);
|
||||||
|
|
||||||
let _tools = tool::all_tools();
|
let _tools = tool::all_tools();
|
||||||
for _t in &_tools {
|
for _t in &_tools {
|
||||||
|
|||||||
@@ -128,6 +128,33 @@ pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
|||||||
memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean })
|
memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
||||||
|
let names = Memory::list(memory_dir);
|
||||||
|
let lessons: Vec<Memory> = names.iter()
|
||||||
|
.filter_map(|n| Memory::read(memory_dir, n).ok())
|
||||||
|
.collect();
|
||||||
|
let data = serde_json::to_string_pretty(&lessons)
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
std::fs::write(output, data)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize> {
|
||||||
|
let data = std::fs::read_to_string(input)?;
|
||||||
|
let lessons: Vec<Memory> = serde_json::from_str(&data)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
let existing: std::collections::HashSet<String> = Memory::list(memory_dir).into_iter().collect();
|
||||||
|
let mut imported = 0;
|
||||||
|
for lesson in &lessons {
|
||||||
|
let slug = Memory::slugify(&lesson.name).unwrap_or_default();
|
||||||
|
if !existing.contains(&slug) {
|
||||||
|
lesson.write(memory_dir)?;
|
||||||
|
imported += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(imported)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
|
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let lessons_content: String = lessons.iter()
|
let lessons_content: String = lessons.iter()
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
pub mod openrouter;
|
pub mod openrouter;
|
||||||
|
pub mod oauth;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
pub struct LoopbackServer {
|
||||||
|
listener: TcpListener,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoopbackServer {
|
||||||
|
pub fn bind() -> std::io::Result<Self> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||||
|
let port = listener.local_addr()?.port();
|
||||||
|
Ok(LoopbackServer { listener, port })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redirect_uri(&self) -> String {
|
||||||
|
format!("http://127.0.0.1:{}/callback", self.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_for_code(&self, timeout_ms: u64) -> std::io::Result<String> {
|
||||||
|
let (mut stream, _) = self.listener.accept()?;
|
||||||
|
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||||
|
Self::read_callback(&mut stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_callback(stream: &mut TcpStream) -> std::io::Result<String> {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let n = stream.read(&mut buf)?;
|
||||||
|
let request = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
let code = Self::extract_code(&request);
|
||||||
|
let response = if code.is_some() {
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nAuthorization complete. You may close this tab."
|
||||||
|
} else {
|
||||||
|
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nMissing authorization code."
|
||||||
|
};
|
||||||
|
let _ = stream.write_all(response.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
code.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_code(request: &str) -> Option<String> {
|
||||||
|
let line = request.lines().next()?;
|
||||||
|
let path = line.split(' ').nth(1)?;
|
||||||
|
let query = path.split('?').nth(1)?;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let mut parts = pair.splitn(2, '=');
|
||||||
|
if parts.next()? == "code" {
|
||||||
|
return parts.next().map(urlencoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoding(s: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(s.len());
|
||||||
|
let mut chars = s.chars();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '%' {
|
||||||
|
let hi = chars.next().and_then(|c| c.to_digit(16)).unwrap_or(0);
|
||||||
|
let lo = chars.next().and_then(|c| c.to_digit(16)).unwrap_or(0);
|
||||||
|
result.push(char::from((hi * 16 + lo) as u8));
|
||||||
|
} else {
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OAuthToken {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub expires_at: u64,
|
||||||
|
pub token_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthToken {
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
now >= self.expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remaining_secs(&self) -> i64 {
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
self.expires_at as i64 - now as i64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OAuthConfig {
|
||||||
|
pub auth_url: String,
|
||||||
|
pub token_url: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OAuthConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
OAuthConfig {
|
||||||
|
auth_url: String::new(),
|
||||||
|
token_url: String::new(),
|
||||||
|
client_id: String::new(),
|
||||||
|
client_secret: None,
|
||||||
|
scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OAuthManager {
|
||||||
|
pub config: OAuthConfig,
|
||||||
|
pub token: Option<OAuthToken>,
|
||||||
|
client: reqwest::blocking::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthManager {
|
||||||
|
pub fn new(config: OAuthConfig) -> Self {
|
||||||
|
OAuthManager {
|
||||||
|
config,
|
||||||
|
token: None,
|
||||||
|
client: reqwest::blocking::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exchange_code(&mut self, code: &str, redirect_uri: &str, code_verifier: &str) -> Result<(), String> {
|
||||||
|
let mut params = std::collections::HashMap::new();
|
||||||
|
params.insert("grant_type", "authorization_code");
|
||||||
|
params.insert("code", code);
|
||||||
|
params.insert("redirect_uri", redirect_uri);
|
||||||
|
params.insert("client_id", &self.config.client_id);
|
||||||
|
params.insert("code_verifier", code_verifier);
|
||||||
|
|
||||||
|
let resp = self.client
|
||||||
|
.post(&self.config.token_url)
|
||||||
|
.form(¶ms)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("token request failed: {}", e))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {}", e))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("token endpoint returned {}: {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string();
|
||||||
|
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
|
||||||
|
self.token = Some(OAuthToken {
|
||||||
|
access_token,
|
||||||
|
refresh_token: body["refresh_token"].as_str().map(|s| s.to_string()),
|
||||||
|
expires_at: now + expires_in,
|
||||||
|
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_token(&mut self) -> Result<(), String> {
|
||||||
|
let refresh_token = self.token.as_ref()
|
||||||
|
.and_then(|t| t.refresh_token.clone())
|
||||||
|
.ok_or("no refresh token available")?;
|
||||||
|
|
||||||
|
let mut params = std::collections::HashMap::new();
|
||||||
|
params.insert("grant_type", "refresh_token");
|
||||||
|
params.insert("refresh_token", &refresh_token);
|
||||||
|
params.insert("client_id", &self.config.client_id);
|
||||||
|
|
||||||
|
let resp = self.client
|
||||||
|
.post(&self.config.token_url)
|
||||||
|
.form(¶ms)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("refresh failed: {}", e))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {}", e))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("refresh endpoint returned {}: {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string();
|
||||||
|
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
|
||||||
|
self.token = Some(OAuthToken {
|
||||||
|
access_token,
|
||||||
|
refresh_token: body["refresh_token"].as_str().map(|s| s.to_string()).or(self.token.as_ref().and_then(|t| t.refresh_token.clone())),
|
||||||
|
expires_at: now + expires_in,
|
||||||
|
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_token(&mut self) -> Result<(), String> {
|
||||||
|
if let Some(ref token) = self.token {
|
||||||
|
if token.remaining_secs() < 60 {
|
||||||
|
return self.refresh_token();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_auth_url(&self, redirect_uri: &str, state: &str, code_challenge: &str) -> String {
|
||||||
|
let mut url = url::Url::parse(&self.config.auth_url).unwrap_or_else(|_| url::Url::parse("https://example.com").unwrap());
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("response_type", "code")
|
||||||
|
.append_pair("client_id", &self.config.client_id)
|
||||||
|
.append_pair("redirect_uri", redirect_uri)
|
||||||
|
.append_pair("scope", &self.config.scopes.join(" "))
|
||||||
|
.append_pair("state", state)
|
||||||
|
.append_pair("code_challenge_method", "S256")
|
||||||
|
.append_pair("code_challenge", code_challenge);
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#[expect(dead_code)]
|
||||||
|
pub mod pkce;
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub mod loopback;
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub mod manager;
|
||||||
|
|
||||||
|
pub use manager::{OAuthManager, OAuthConfig};
|
||||||
|
pub use pkce::CodeVerifier;
|
||||||
|
pub use loopback::LoopbackServer;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
|
const VERIFIER_LENGTH: usize = 64;
|
||||||
|
|
||||||
|
pub struct CodeVerifier(String);
|
||||||
|
|
||||||
|
impl CodeVerifier {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
|
||||||
|
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn challenge(&self) -> CodeChallenge {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(self.0.as_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
CodeChallenge(URL_SAFE_NO_PAD.encode(digest))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rand_byte() -> u8 {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.subsec_nanos();
|
||||||
|
(nanos & 0xFF) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CodeChallenge(String);
|
||||||
|
|
||||||
|
impl CodeChallenge {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -5,6 +5,7 @@ use anyhow::{Result, anyhow};
|
|||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use super::super::resolve_path;
|
use super::super::resolve_path;
|
||||||
|
use super::super::check_graduated_checks;
|
||||||
use super::helpers::arg_str;
|
use super::helpers::arg_str;
|
||||||
|
|
||||||
pub struct Edit;
|
pub struct Edit;
|
||||||
@@ -55,6 +56,7 @@ impl Tool for Edit {
|
|||||||
if reason.trim().is_empty() {
|
if reason.trim().is_empty() {
|
||||||
anyhow::bail!("reason must be a non-empty string");
|
anyhow::bail!("reason must be a non-empty string");
|
||||||
}
|
}
|
||||||
|
let check_matches = check_graduated_checks(&rel, &new_str, &ctx.graduated_checks);
|
||||||
let replace_all = args.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false);
|
let replace_all = args.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
|
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -89,6 +91,10 @@ impl Tool for Edit {
|
|||||||
} else {
|
} else {
|
||||||
content.len() - new_content.len()
|
content.len() - new_content.len()
|
||||||
};
|
};
|
||||||
Ok(format!("edited {} ({} byte delta)", rel, bytes_diff as isize))
|
if check_matches.is_empty() {
|
||||||
|
Ok(format!("edited {} ({} byte delta)", rel, bytes_diff as isize))
|
||||||
|
} else {
|
||||||
|
Ok(format!("edited {} ({} byte delta). Graduated checks matched: {}", rel, bytes_diff as isize, check_matches.join(", ")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use anyhow::{Result, anyhow};
|
|||||||
use super::super::Tool;
|
use super::super::Tool;
|
||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use super::super::resolve_path;
|
use super::super::resolve_path;
|
||||||
|
use super::super::check_graduated_checks;
|
||||||
use super::helpers::arg_str;
|
use super::helpers::arg_str;
|
||||||
|
|
||||||
pub struct Write;
|
pub struct Write;
|
||||||
@@ -45,6 +46,7 @@ impl Tool for Write {
|
|||||||
if reason.trim().is_empty() {
|
if reason.trim().is_empty() {
|
||||||
anyhow::bail!("reason must be a non-empty string");
|
anyhow::bail!("reason must be a non-empty string");
|
||||||
}
|
}
|
||||||
|
let check_matches = check_graduated_checks(&rel, &content, &ctx.graduated_checks);
|
||||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
@@ -52,6 +54,10 @@ impl Tool for Write {
|
|||||||
}
|
}
|
||||||
fs::write(&path, &content)
|
fs::write(&path, &content)
|
||||||
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
|
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
|
||||||
Ok(format!("wrote {} bytes to {}", content.len(), rel))
|
if check_matches.is_empty() {
|
||||||
|
Ok(format!("wrote {} bytes to {}", content.len(), rel))
|
||||||
|
} else {
|
||||||
|
Ok(format!("wrote {} bytes to {}. Graduated checks matched: {}", content.len(), rel, check_matches.join(", ")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ pub trait Tool: Send + Sync {
|
|||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GraduatedCheck {
|
||||||
|
pub name: String,
|
||||||
|
pub pattern: String,
|
||||||
|
pub rule: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ToolCtx {
|
pub struct ToolCtx {
|
||||||
pub workspaces: Vec<PathBuf>,
|
pub workspaces: Vec<PathBuf>,
|
||||||
pub session_dir: PathBuf,
|
pub session_dir: PathBuf,
|
||||||
@@ -30,6 +37,17 @@ pub struct ToolCtx {
|
|||||||
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
||||||
pub internet_mode: super::model::settings::InternetMode,
|
pub internet_mode: super::model::settings::InternetMode,
|
||||||
pub origin: crate::app::state::types::Origin,
|
pub origin: crate::app::state::types::Origin,
|
||||||
|
pub graduated_checks: Vec<GraduatedCheck>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec<String> {
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
for check in checks {
|
||||||
|
if path.contains(&check.pattern) || content.contains(&check.rule) {
|
||||||
|
matches.push(check.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolCtx {
|
impl ToolCtx {
|
||||||
@@ -47,6 +65,7 @@ pub struct ToolCtxBuilder {
|
|||||||
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
||||||
pub internet_mode: super::model::settings::InternetMode,
|
pub internet_mode: super::model::settings::InternetMode,
|
||||||
pub origin: crate::app::state::types::Origin,
|
pub origin: crate::app::state::types::Origin,
|
||||||
|
pub graduated_checks: Vec<GraduatedCheck>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ToolCtxBuilder {
|
impl Default for ToolCtxBuilder {
|
||||||
@@ -60,6 +79,7 @@ impl Default for ToolCtxBuilder {
|
|||||||
dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())),
|
dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())),
|
||||||
internet_mode: super::model::settings::InternetMode::Off,
|
internet_mode: super::model::settings::InternetMode::Off,
|
||||||
origin: crate::app::state::types::Origin::Main,
|
origin: crate::app::state::types::Origin::Main,
|
||||||
|
graduated_checks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +92,8 @@ impl ToolCtxBuilder {
|
|||||||
pub fn worktrees_dir(mut self, v: PathBuf) -> Self { self.worktrees_dir = v; self }
|
pub fn worktrees_dir(mut self, v: PathBuf) -> Self { self.worktrees_dir = v; self }
|
||||||
pub fn internet_mode(mut self, v: super::model::settings::InternetMode) -> Self { self.internet_mode = v; self }
|
pub fn internet_mode(mut self, v: super::model::settings::InternetMode) -> Self { self.internet_mode = v; self }
|
||||||
pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self }
|
pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self }
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub fn graduated_checks(mut self, v: Vec<GraduatedCheck>) -> Self { self.graduated_checks = v; self }
|
||||||
pub fn build(self) -> ToolCtx {
|
pub fn build(self) -> ToolCtx {
|
||||||
ToolCtx {
|
ToolCtx {
|
||||||
workspaces: self.workspaces,
|
workspaces: self.workspaces,
|
||||||
@@ -82,6 +104,7 @@ impl ToolCtxBuilder {
|
|||||||
dir_cache: self.dir_cache,
|
dir_cache: self.dir_cache,
|
||||||
internet_mode: self.internet_mode,
|
internet_mode: self.internet_mode,
|
||||||
origin: self.origin,
|
origin: self.origin,
|
||||||
|
graduated_checks: self.graduated_checks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user