1use std::path::{Path, PathBuf};
8use std::sync::mpsc::sync_channel;
9use std::sync::{Mutex, OnceLock};
10use std::thread;
11use std::time::Duration;
12
13use qualia_client_core::model_lifecycle::{
14 self, lifecycle_label, wait_for_eviction_scrub, ActiveModelRecord, VaultGgufEntry,
15};
16use qualia_client_core::system_telemetry::SystemTelemetryEvent;
17use qualia_core_db::llm_agent::{AgentBackend, AgentIntent, AgentRuntime, LocalLlmAgent};
18use qualia_core_db::modalities::logic::n3_compiler::N3OutputMode;
19use qualia_core_db::orchestrator::OrchestrationResult;
20use qualia_core_db::q_hash;
21
22static CLI_SESSION: OnceLock<Mutex<Option<CliSession>>> = OnceLock::new();
23
24struct CliSession {
25 record: ActiveModelRecord,
26 agent: LocalLlmAgent,
27}
28
29fn session_lock() -> &'static Mutex<Option<CliSession>> {
30 CLI_SESSION.get_or_init(|| Mutex::new(None))
31}
32
33fn store_session(record: ActiveModelRecord, agent: LocalLlmAgent) {
34 *session_lock().lock().expect("cli session lock") = Some(CliSession { record, agent });
35}
36
37fn with_session<F, T>(f: F) -> Result<T, String>
38where
39 F: FnOnce(&CliSession) -> Result<T, String>,
40{
41 let guard = session_lock().lock().map_err(|e| e.to_string())?;
42 let session = guard.as_ref().ok_or_else(|| {
43 "No model loaded — run `qualia-cli llm load --vault-path <DIR> <MODEL>` first".to_string()
44 })?;
45 f(session)
46}
47
48fn clear_session() {
49 if let Ok(mut guard) = session_lock().lock() {
50 *guard = None;
51 }
52}
53
54pub fn init_log_stream(enable_telemetry: bool) {
56 let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
57 .format(|buf, record| {
58 use std::io::Write;
59 let target = record.target();
60 if target.contains("qualia") || record.args().to_string().contains("LLM_LOAD") {
61 writeln!(buf, "[{}] {} — {}", record.level(), target, record.args())
62 } else {
63 writeln!(buf, "[{}] {}", record.level(), record.args())
64 }
65 })
66 .try_init();
67
68 if !enable_telemetry {
69 return;
70 }
71
72 let (tx, rx) = sync_channel::<SystemTelemetryEvent>(64);
73 qualia_client_core::system_telemetry::subscribe_system_telemetry(tx);
74 thread::Builder::new()
75 .name("qualia-cli-telemetry".into())
76 .spawn(move || {
77 for event in rx {
78 eprintln!(
79 "[TELEMETRY] RAM {}/{} MB | VRAM {}/{} MB | LLM {} MB | KV {} MB | lifecycle={} | {}",
80 event.ram_used_mb,
81 event.ram_total_mb,
82 event.vram_used_mb,
83 event.vram_total_mb,
84 event.llm_memory_mb,
85 event.kv_cache_mb,
86 event.lifecycle,
87 event.status,
88 );
89 }
90 })
91 .ok();
92}
93
94pub fn run_list(vault_path: &Path) -> Result<(), String> {
95 let entries = model_lifecycle::scan_vault_gguf(vault_path).map_err(|e| e.to_string())?;
96 if entries.is_empty() {
97 println!("No `.p64` / `.gguf` files under {}", vault_path.display());
98 return Ok(());
99 }
100 println!(
101 "{:<32} {:>6} {:>12} {:>18} {}",
102 "NAME", "KIND", "SIZE (MiB)", "PROFILE_ID", "PATH"
103 );
104 println!("{}", "-".repeat(100));
105 for VaultGgufEntry {
106 name,
107 path,
108 profile_id,
109 size_bytes,
110 container,
111 } in entries
112 {
113 let mib = size_bytes as f64 / (1024.0 * 1024.0);
114 println!(
115 "{:<32} {:>6} {:>12.1} 0x{profile_id:016x} {path}",
116 name, container, mib,
117 );
118 }
119 Ok(())
120}
121
122pub fn run_duplicate_audit(vault_path: &Path) -> Result<(), String> {
123 let groups = model_lifecycle::audit_vault_duplicates(vault_path).map_err(|e| e.to_string())?;
124 if groups.is_empty() {
125 println!(
126 "No byte-identical GGUF duplicates under {}",
127 vault_path.display()
128 );
129 return Ok(());
130 }
131
132 let mut reclaimable = 0u64;
133 for group in &groups {
134 reclaimable = reclaimable.saturating_add(group.reclaimable_bytes());
135 println!(
136 "\nSHA-256 {} ({:.1} MiB each)",
137 group.sha256,
138 group.size_bytes as f64 / (1024.0 * 1024.0)
139 );
140 println!(" keep: {}", group.canonical_path);
141 for path in &group.duplicate_paths {
142 println!(" duplicate: {path}");
143 }
144 }
145 println!(
146 "\nRead-only audit: {} exact duplicate group(s), {:.1} MiB reclaimable.",
147 groups.len(),
148 reclaimable as f64 / (1024.0 * 1024.0)
149 );
150 println!("No files were copied, moved, or deleted.");
151 Ok(())
152}
153
154pub fn run_load(vault_path: &Path, model_ref: &str) -> Result<(), String> {
155 let gguf =
156 model_lifecycle::resolve_vault_model(vault_path, model_ref).map_err(|e| e.to_string())?;
157
158 println!("Loading {} …", gguf.display());
159 qualia_client_core::system_telemetry::start_activation_telemetry("CLI load");
160
161 let record = tokio::task::block_in_place(|| {
162 tokio::runtime::Handle::current().block_on(model_lifecycle::activate_vault_gguf(&gguf))
163 })
164 .map_err(|e| {
165 qualia_client_core::system_telemetry::stop_activation_telemetry();
166 e.to_string()
167 })?;
168
169 qualia_client_core::system_telemetry::stop_activation_telemetry();
170 qualia_client_core::system_telemetry::publish_idle_telemetry();
171
172 let agent = LocalLlmAgent::with_local_backend(
173 format!("did:qualia:cli-vault:{}", record.profile_id),
174 AgentBackend::Local {
175 model_path: record.gguf_path.clone(),
176 context_window: record.context_window,
177 quantization: record.quantization.clone(),
178 vision_projector_path: record.mmproj_path.clone(),
179 modality: record.modality.clone(),
180 architecture: record.architecture.clone(),
181 },
182 );
183
184 store_session(record.clone(), agent);
185
186 let orch = model_lifecycle::task_orchestrator();
187 println!("Model ready.");
188 println!(" model_id : {}", record.model_id);
189 println!(" profile_id : 0x{:016x}", record.profile_id);
190 println!(" path : {}", record.gguf_path);
191 println!(" lifecycle : {}", record.lifecycle_state);
192 println!(
193 " resident : {} bytes mapped (+ KV cache tracked separately)",
194 orch.resident_memory_bytes()
195 );
196 println!(" backend : native GGUF → wgpu (DirectML when available)");
197 Ok(())
198}
199
200pub fn run_status() -> Result<(), String> {
201 let orch = model_lifecycle::task_orchestrator();
202 let lifecycle = model_lifecycle::get_model_lifecycle_state();
203 let resident_id = orch.resident_model_id();
204 let llm_mb = model_lifecycle::get_llm_memory_bytes() / (1024 * 1024);
205 let kv_mb = model_lifecycle::get_kv_cache_used_mb();
206
207 println!("Lifecycle state : {}", lifecycle_label(lifecycle));
208 println!(
209 "Resident id : {:?}",
210 resident_id.map(|id| format!("0x{id:016x}"))
211 );
212 println!("Resident bytes : {}", orch.resident_memory_bytes());
213 println!("LLM memory : {} MiB", llm_mb);
214 println!("KV cache : {} MiB", kv_mb);
215 println!(
216 "Thermal : {}",
217 model_lifecycle::get_thermal_state_label()
218 );
219 println!(
220 "Scrubbing : {}",
221 orch.scrubbing_lock
222 .load(std::sync::atomic::Ordering::Acquire)
223 );
224
225 if let Ok(guard) = session_lock().lock() {
226 if let Some(session) = guard.as_ref() {
227 println!(
228 "CLI session : {} ({})",
229 session.record.model_id, session.record.gguf_path
230 );
231 } else {
232 println!("CLI session : none");
233 }
234 }
235 Ok(())
236}
237
238pub fn run_eval(prompt: &str, orchestrated: bool, stream: bool) -> Result<(), String> {
239 with_session(|session| {
240 if orchestrated {
241 let intent = cli_read_intent();
242 let orch = model_lifecycle::task_orchestrator();
243 let started = std::time::Instant::now();
244 match orch.orchestrate_inference(
245 &session.agent,
246 prompt,
247 "ctx:qualia-cli-eval",
248 intent,
249 None,
250 ) {
251 OrchestrationResult::Committed { text, .. } => {
252 println!(
253 "\n--- output ({} ms) ---\n{text}\n",
254 started.elapsed().as_millis()
255 );
256 }
257 OrchestrationResult::Blocked {
258 rule_violated,
259 reason,
260 } => {
261 return Err(format!(
262 "Webizen blocked inference: {reason} (rule 0x{rule_violated:016x})"
263 ));
264 }
265 OrchestrationResult::Failed(msg) => return Err(format!("Inference failed: {msg}")),
266 }
267 } else if stream {
268 let (text, _prov, tokens, _quin) = session.agent.infer_local_model_streaming(
269 prompt,
270 "ctx:qualia-cli-eval",
271 Some(|delta: String| {
272 print!("{delta}");
273 let _ = std::io::Write::flush(&mut std::io::stdout());
274 }),
275 );
276 println!("\n--- tokens generated: {tokens} ---");
277 if !text.is_empty() {
278 println!("(final length {} chars)", text.len());
279 }
280 } else {
281 let output = session
282 .agent
283 .infer(prompt, "ctx:qualia-cli-eval")
284 .map_err(|e| format!("{e:?}"))?;
285 println!(
286 "\n--- output ({} ms, {} tokens) ---\n{}\n",
287 output.inference_duration_ms, output.tokens_generated, output.text
288 );
289 }
290 Ok(())
291 })
292}
293
294pub fn run_evict(model_id_ref: &str) -> Result<(), String> {
295 let profile_id = parse_model_id_ref(model_id_ref)?;
296 println!("Evicting model 0x{profile_id:016x} …");
297 model_lifecycle::unload_active_model(Some(profile_id));
298 if !wait_for_eviction_scrub(Duration::from_secs(10)) {
299 eprintln!("Warning: scrub did not finish within 10 s");
300 }
301 clear_session();
302 qualia_client_core::system_telemetry::publish_idle_telemetry();
303 println!("Eviction complete. Lifecycle → Discovered.");
304 run_status()
305}
306
307fn parse_model_id_ref(raw: &str) -> Result<u64, String> {
308 let trimmed = raw.trim();
309 if let Some(hex) = trimmed
310 .strip_prefix("0x")
311 .or_else(|| trimmed.strip_prefix("0X"))
312 {
313 u64::from_str_radix(hex, 16).map_err(|e| format!("Invalid hex profile id: {e}"))
314 } else if let Ok(id) = trimmed.parse::<u64>() {
315 Ok(id)
316 } else if let Ok(guard) = session_lock().lock() {
317 if let Some(session) = guard.as_ref() {
318 if session.record.model_id == trimmed {
319 return Ok(session.record.profile_id);
320 }
321 }
322 drop(guard);
323 Err(format!(
324 "Unknown model id `{trimmed}` — use 0x{{16 hex}} profile id or loaded model stem"
325 ))
326 } else {
327 Err("Session lock poisoned".to_string())
328 }
329}
330
331fn cli_read_intent() -> AgentIntent {
332 AgentIntent {
333 intent_predicate: q_hash("llm:ReadGraph"),
334 requested_graph_scope: vec![q_hash("ctx:qualia-cli-eval")],
335 context_namespaces: vec![],
336 requires_network: false,
337 ilp_offer_micro_cents: 0,
338 principal_did_hash: q_hash("did:qualia:cli-operator"),
339 mcp_intent_frame_hash: q_hash("purpose:CliEval"),
340 output_mode: N3OutputMode::FreeText,
341 clearance_ceiling: 0,
342 max_sentinel_depth: 32,
343 active_profile: None,
344 }
345}
346
347pub fn default_vault_path() -> PathBuf {
349 if let Ok(dir) = std::env::var("QUALIA_LLM_VAULT") {
350 let trimmed = dir.trim();
351 if !trimmed.is_empty() {
352 return PathBuf::from(trimmed);
353 }
354 }
355 let canonical = PathBuf::from("C:/LLM_Models");
356 if canonical.is_dir() {
357 return canonical;
358 }
359 PathBuf::from("C:/llmmodels")
360}