1use crate::agent;
2use crate::agent::conversation::{ConversationManager, UserTurn};
3use crate::agent::git_monitor::GitState;
4use crate::agent::inference::{InferenceEngine, InferenceEvent};
5use crate::ui;
6use crate::ui::gpu_monitor::GpuState;
7use crate::ui::voice::VoiceManager;
8use crate::CliCockpit;
9use notify::RecommendedWatcher;
10use std::sync::Arc;
11use tokio::sync::mpsc;
12
13pub struct RuntimeServices {
14 pub engine: Arc<InferenceEngine>,
15 pub gpu_state: Arc<GpuState>,
16 pub git_state: Arc<GitState>,
17 pub voice_manager: Arc<VoiceManager>,
18 pub swarm_coordinator: Arc<agent::swarm::SwarmCoordinator>,
19 pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
20}
21
22pub struct RuntimeChannels {
23 pub specular_rx: mpsc::Receiver<agent::specular::SpecularEvent>,
24 pub agent_tx: mpsc::Sender<InferenceEvent>,
25 pub agent_rx: mpsc::Receiver<InferenceEvent>,
26 pub swarm_tx: mpsc::Sender<agent::swarm::SwarmMessage>,
27 pub swarm_rx: mpsc::Receiver<agent::swarm::SwarmMessage>,
28 pub user_input_tx: mpsc::Sender<UserTurn>,
29 pub user_input_rx: mpsc::Receiver<UserTurn>,
30}
31
32pub struct RuntimeBundle {
33 pub services: RuntimeServices,
34 pub channels: RuntimeChannels,
35 pub watcher_guard: RecommendedWatcher,
36}
37
38pub struct AgentLoopRuntime {
39 pub user_input_rx: mpsc::Receiver<UserTurn>,
40 pub agent_tx: mpsc::Sender<InferenceEvent>,
41 pub services: RuntimeServices,
42}
43
44pub struct AgentLoopConfig {
45 pub yolo: bool,
46 pub professional: bool,
47 pub brief: bool,
48 pub snark: u8,
49 pub chaos: u8,
50 pub soul_personality: String,
51 pub fast_model: Option<String>,
52 pub think_model: Option<String>,
53}
54
55pub async fn build_runtime_bundle(
56 cockpit: &CliCockpit,
57 species: &str,
58 snark: u8,
59 professional: bool,
60) -> Result<RuntimeBundle, Box<dyn std::error::Error>> {
61 println!("Booting Hematite systems...");
62 let config = crate::agent::config::load_config();
63
64 crate::agent::searx_lifecycle::boot_searx_if_needed(&config).await;
66
67 let api_url = config
69 .api_url
70 .clone()
71 .unwrap_or_else(|| cockpit.url.clone());
72 let mut engine_raw = InferenceEngine::new(api_url, species.to_string(), snark)?;
73 let gpu_state = ui::gpu_monitor::spawn_gpu_monitor();
74 let git_state = agent::git_monitor::spawn_git_monitor();
75
76 if !engine_raw.health_check().await {
77 println!(
78 "ERROR: LLM Provider not detected at {}",
79 engine_raw.base_url
80 );
81 println!("Check if LM Studio (or your local server) is running and port mapped correctly.");
82 std::process::exit(1);
83 }
84
85 let model_name = engine_raw.get_loaded_model().await;
86 if let Some(name) = model_name {
87 engine_raw.set_runtime_profile(&name, engine_raw.current_context_length());
88 }
89 let detected_context = engine_raw.detect_context_length().await;
90 let detected_model = engine_raw.current_model();
91 engine_raw.set_runtime_profile(&detected_model, detected_context);
92
93 let (specular_tx, specular_rx) = mpsc::channel(32);
94 let watcher_guard = agent::specular::spawn_watcher(specular_tx)?;
95
96 let (agent_tx, agent_rx) = mpsc::channel::<InferenceEvent>(100);
97 let (swarm_tx, swarm_rx) = mpsc::channel(32);
98 let voice_manager = Arc::new(VoiceManager::new(agent_tx.clone()));
99
100 if let Some(ref worker) = cockpit.fast_model {
101 engine_raw.worker_model = Some(worker.clone());
102 }
103
104 let engine = Arc::new(engine_raw);
105 let swarm_coordinator = Arc::new(agent::swarm::SwarmCoordinator::new(
106 engine.clone(),
107 gpu_state.clone(),
108 cockpit.fast_model.clone(),
109 professional,
110 ));
111
112 let (user_input_tx, user_input_rx) = mpsc::channel::<UserTurn>(32);
113 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
114
115 Ok(RuntimeBundle {
116 services: RuntimeServices {
117 engine,
118 gpu_state,
119 git_state,
120 voice_manager,
121 swarm_coordinator,
122 cancel_token,
123 },
124 channels: RuntimeChannels {
125 specular_rx,
126 agent_tx,
127 agent_rx,
128 swarm_tx,
129 swarm_rx,
130 user_input_tx,
131 user_input_rx,
132 },
133 watcher_guard,
134 })
135}
136
137pub fn spawn_runtime_profile_sync(
138 engine: Arc<InferenceEngine>,
139 agent_tx: mpsc::Sender<InferenceEvent>,
140) -> tokio::task::JoinHandle<()> {
141 tokio::spawn(async move {
142 tokio::time::sleep(tokio::time::Duration::from_secs(4)).await;
144
145 let mut last_embed: Option<String> = None;
146
147 loop {
148 let result = engine.refresh_runtime_profile().await;
149
150 let Some((model_id, context_length, _changed)) = result else {
151 if agent_tx.is_closed() {
152 break;
153 }
154 tokio::time::sleep(tokio::time::Duration::from_secs(15)).await;
156 continue;
157 };
158
159 let poll_interval = if model_id == "no model loaded" {
161 tokio::time::Duration::from_secs(12)
162 } else {
163 tokio::time::Duration::from_secs(4)
164 };
165
166 if agent_tx
167 .send(InferenceEvent::RuntimeProfile {
168 model_id,
169 context_length,
170 })
171 .await
172 .is_err()
173 {
174 break;
175 }
176
177 let current_embed = engine.get_embedding_model().await;
179 if current_embed != last_embed {
180 if agent_tx
181 .send(InferenceEvent::EmbedProfile {
182 model_id: current_embed.clone(),
183 })
184 .await
185 .is_err()
186 {
187 break;
188 }
189 last_embed = current_embed;
190 }
191
192 tokio::time::sleep(poll_interval).await;
193 }
194 })
195}
196
197pub async fn run_agent_loop(runtime: AgentLoopRuntime, config: AgentLoopConfig) {
198 let AgentLoopRuntime {
199 mut user_input_rx,
200 agent_tx,
201 services,
202 } = runtime;
203 let RuntimeServices {
204 engine,
205 gpu_state,
206 git_state,
207 voice_manager,
208 swarm_coordinator,
209 cancel_token,
210 } = services;
211
212 let mut manager = ConversationManager::new(
213 engine,
214 config.professional,
215 config.brief,
216 config.snark,
217 config.chaos,
218 config.soul_personality,
219 config.fast_model,
220 config.think_model,
221 gpu_state.clone(),
222 git_state,
223 swarm_coordinator,
224 voice_manager,
225 );
226 manager.cancel_token = cancel_token;
227
228 let _ = agent_tx
229 .send(InferenceEvent::RuntimeProfile {
230 model_id: manager.engine.current_model(),
231 context_length: manager.engine.current_context_length(),
232 })
233 .await;
234
235 let workspace_root = crate::tools::file_ops::workspace_root();
236 let _ = crate::agent::workspace_profile::ensure_workspace_profile(&workspace_root);
237
238 let gpu_name = gpu_state.gpu_name();
241 let vram = gpu_state.label();
242 let voice_cfg = crate::agent::config::load_config();
243 let voice_status = format!(
244 "Voice: {} | Speed: {}x | Volume: {}x",
245 crate::agent::config::effective_voice(&voice_cfg),
246 crate::agent::config::effective_voice_speed(&voice_cfg),
247 crate::agent::config::effective_voice_volume(&voice_cfg),
248 );
249 let embed_status = match manager.engine.get_embedding_model().await {
250 Some(id) => format!("Embed: {} (semantic search ready)", id),
251 None => "Embed: none loaded (load nomic-embed-text-v2 for semantic search)".to_string(),
252 };
253 let workspace_root = crate::tools::file_ops::workspace_root();
254 let docs_only_mode = !crate::tools::file_ops::is_project_workspace();
255 let workspace_mode = if docs_only_mode {
256 "docs-only"
257 } else {
258 "project"
259 };
260 let launched_from_home = home::home_dir()
261 .and_then(|home| std::env::current_dir().ok().map(|cwd| cwd == home))
262 .unwrap_or(false);
263 let project_hint = if !docs_only_mode {
264 String::new()
265 } else if launched_from_home {
266 "\nTip: you launched Hematite from your home directory. That is fine for workstation questions and docs-only memory, but for project-specific build, test, script, or repo work you should relaunch in the target project directory. `.hematite/docs/`, `.hematite/imports/`, and recent local session reports remain searchable in docs-only vein mode.".to_string()
267 } else {
268 "\nTip: source indexing is disabled outside a project folder. Launch Hematite in the target project directory for project-specific build, test, script, or repo work. `.hematite/docs/`, `.hematite/imports/`, and recent local session reports remain searchable in docs-only vein mode.".to_string()
269 };
270 let display_model = {
271 let m = manager.engine.current_model();
272 if m.is_empty() {
273 "no chat model loaded".to_string()
274 } else {
275 m
276 }
277 };
278 let greeting = format!(
279 "Hematite {} Online | Model: {} | CTX: {} | GPU: {} | VRAM: {}\nEndpoint: {}\nWorkspace: {} ({})\n{}\n{}\n/ask · read-only analysis /code · implement /architect · plan-first /chat · conversation\nRecovery: /undo · /new · /forget · /clear | /version · /about{}",
280 crate::hematite_version_display(),
281 display_model,
282 manager.engine.current_context_length(),
283 gpu_name,
284 vram,
285 format!("{}/v1", manager.engine.base_url),
286 workspace_root.display(),
287 workspace_mode,
288 embed_status,
289 voice_status,
290 project_hint
291 );
292 let _ = agent_tx
293 .send(InferenceEvent::MutedToken(format!("\n{}", greeting)))
294 .await;
295
296 if let Err(e) = manager.initialize_mcp(&agent_tx).await {
297 let _ = agent_tx
298 .send(InferenceEvent::Error(format!("MCP Init Failed: {}", e)))
299 .await;
300 }
301 let indexed = manager.initialize_vein();
302 manager.initialize_repo_map();
303 let _ = agent_tx
304 .send(InferenceEvent::VeinStatus {
305 file_count: manager.vein.file_count(),
306 embedded_count: manager.vein.embedded_chunk_count(),
307 docs_only: docs_only_mode,
308 })
309 .await;
310 let _ = agent_tx
311 .send(InferenceEvent::Thought(format!(
312 "The Vein: indexed {} files",
313 indexed
314 )))
315 .await;
316
317 if let Some(cp) = crate::agent::conversation::load_checkpoint() {
319 let verify_tag = match cp.last_verify_ok {
320 Some(true) => " | last verify: PASS",
321 Some(false) => " | last verify: FAIL",
322 None => "",
323 };
324 let files_tag = if cp.working_files.is_empty() {
325 String::new()
326 } else {
327 format!(" | files: {}", cp.working_files.join(", "))
328 };
329 let goal_preview: String = cp.last_goal.chars().take(120).collect();
330 let trail = if cp.last_goal.len() > 120 { "…" } else { "" };
331 let resume_msg = format!(
332 "Resumed: {} turn{}{}{} — last goal: \"{}{}\"",
333 cp.turn_count,
334 if cp.turn_count == 1 { "" } else { "s" },
335 verify_tag,
336 files_tag,
337 goal_preview,
338 trail,
339 );
340 let _ = agent_tx.send(InferenceEvent::Thought(resume_msg)).await;
341 } else {
342 let session_path = crate::tools::file_ops::hematite_dir().join("session.json");
343 if !session_path.exists() {
344 let first_run_msg = "\nWelcome to Hematite! I'm your local AI workstation assistant.\n\n\
345 Since this is your first time here, what would you like to do?\n\
346 - System Check: Wondering if your tools are working? Run `/health`\n\
347 - Code: Ready to build something? Run `/architect Let's build a new feature`\n\
348 - Setup: Need help configuring Git or the workspace? Run `/ask What should I set up first?`\n\
349 - Help: Have a weird error? Type `/explain ` and paste it.\n\n\
350 Just type \"hello\" to start a normal conversation!".to_string();
351 let _ = agent_tx.send(InferenceEvent::Thought(first_run_msg)).await;
352
353 let _ = std::fs::write(&session_path, "{\"turn_count\": 0}");
355 }
356 }
357
358 let _ = agent_tx.send(InferenceEvent::Done).await;
359 let startup_config = crate::agent::config::load_config();
360 manager.engine.set_gemma_native_formatting(
361 crate::agent::config::effective_gemma_native_formatting(
362 &startup_config,
363 &manager.engine.current_model(),
364 ),
365 );
366 let startup_model = manager.engine.current_model();
367 if crate::agent::inference::is_hematite_native_model(&startup_model) {
368 let mode = crate::agent::config::gemma_native_mode_label(&startup_config, &startup_model);
369 let status = match mode {
370 "on" => "Sovereign Engine detected | Native Turn-Formatting: ON (forced)",
371 "auto" => "Sovereign Engine detected | Native Turn-Formatting: ON (auto)",
372 _ => "Sovereign Engine detected | Native Turn-Formatting: OFF (use /gemma-native auto|on)",
373 };
374 let _ = agent_tx
375 .send(InferenceEvent::MutedToken(status.to_string()))
376 .await;
377 }
378
379 while let Some(input) = user_input_rx.recv().await {
380 if let Err(e) = manager
381 .run_turn(&input, agent_tx.clone(), config.yolo)
382 .await
383 {
384 let _ = agent_tx.send(InferenceEvent::Error(e.to_string())).await;
385 let _ = agent_tx.send(InferenceEvent::Done).await;
386 }
387 }
388}