Skip to main content

qualia_cli/
llm_lifecycle.rs

1//! CLI harness for native GGUF lifecycle: discover → mmap → infer → evict.
2//!
3//! Uses the in-process stack (`gguf_bridge` / `wgpu` / Phase 8 bifurcated compute),
4//! not Ollama or external daemons. Models are memory-mapped via `memmap2`; the UI
5//! RAM ceiling is tracked separately from the 42 MB SlgArena Sentinel budget.
6
7use 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
54/// Attach structured logging and optional 100 ms telemetry samples to stdout.
55pub 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
347/// Default vault path when none is supplied (Windows-friendly).
348pub 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}