// LLM utilities: model invocation and response parsing // // Calls claude CLI as a subprocess. Uses prctl(PR_SET_PDEATHSIG) // so child processes die when the daemon exits, preventing orphans. use crate::store::Store; use regex::Regex; use std::fs; use std::os::unix::process::CommandExt; use std::process::Command; fn log_usage(agent: &str, model: &str, prompt: &str, response: &str, duration_ms: u128, ok: bool) { let dir = crate::config::get().data_dir.join("llm-logs").join(agent); let _ = fs::create_dir_all(&dir); let date = chrono::Local::now().format("%Y-%m-%d"); let path = dir.join(format!("{}.md", date)); let ts = chrono::Local::now().format("%H:%M:%S"); let status = if ok { "ok" } else { "ERROR" }; let entry = format!( "\n## {} — {} ({}, {:.1}s, {})\n\n\ ### Prompt ({} chars)\n\n\ ```\n{}\n```\n\n\ ### Response ({} chars)\n\n\ ```\n{}\n```\n\n---\n", ts, agent, model, duration_ms as f64 / 1000.0, status, prompt.len(), prompt, response.len(), response, ); use std::io::Write; if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&path) { let _ = f.write_all(entry.as_bytes()); } } /// Call a model via claude CLI. Returns the response text. /// /// Sets PR_SET_PDEATHSIG on the child so it gets SIGTERM if the /// parent daemon exits — no more orphaned claude processes. fn call_model(agent: &str, model: &str, prompt: &str) -> Result { // Write prompt to temp file (claude CLI needs file input for large prompts) let tmp = std::env::temp_dir().join(format!("poc-llm-{}-{:?}.txt", std::process::id(), std::thread::current().id())); fs::write(&tmp, prompt) .map_err(|e| format!("write temp prompt: {}", e))?; let mut cmd = Command::new("claude"); cmd.args(["-p", "--model", model, "--tools", "", "--no-session-persistence"]) .stdin(fs::File::open(&tmp).map_err(|e| format!("open temp: {}", e))?) .env_remove("CLAUDECODE"); // Use separate OAuth credentials for agent work if configured if let Some(ref dir) = crate::config::get().agent_config_dir { cmd.env("CLAUDE_CONFIG_DIR", dir); } // Tell hooks this is a daemon agent call, not interactive cmd.env("POC_AGENT", "1"); let start = std::time::Instant::now(); let result = unsafe { cmd.pre_exec(|| { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); Ok(()) }) .output() }; fs::remove_file(&tmp).ok(); match result { Ok(output) => { let elapsed = start.elapsed().as_millis(); if output.status.success() { let response = String::from_utf8_lossy(&output.stdout).trim().to_string(); log_usage(agent, model, prompt, &response, elapsed, true); Ok(response) } else { let stderr = String::from_utf8_lossy(&output.stderr); let preview = crate::util::first_n_chars(&stderr, 500); log_usage(agent, model, prompt, &preview, elapsed, false); Err(format!("claude exited {}: {}", output.status, preview.trim())) } } Err(e) => Err(format!("spawn claude: {}", e)), } } /// Call Sonnet via claude CLI. pub(crate) fn call_sonnet(agent: &str, prompt: &str) -> Result { call_model(agent, "sonnet", prompt) } /// Call Haiku via claude CLI (cheaper, faster — good for high-volume extraction). pub(crate) fn call_haiku(agent: &str, prompt: &str) -> Result { call_model(agent, "haiku", prompt) } /// Parse a JSON response, handling markdown fences. pub(crate) fn parse_json_response(response: &str) -> Result { let cleaned = response.trim(); let cleaned = cleaned.strip_prefix("```json").unwrap_or(cleaned); let cleaned = cleaned.strip_prefix("```").unwrap_or(cleaned); let cleaned = cleaned.strip_suffix("```").unwrap_or(cleaned); let cleaned = cleaned.trim(); if let Ok(v) = serde_json::from_str(cleaned) { return Ok(v); } // Try to find JSON object or array let re_obj = Regex::new(r"\{[\s\S]*\}").unwrap(); let re_arr = Regex::new(r"\[[\s\S]*\]").unwrap(); if let Some(m) = re_obj.find(cleaned) { if let Ok(v) = serde_json::from_str(m.as_str()) { return Ok(v); } } if let Some(m) = re_arr.find(cleaned) { if let Ok(v) = serde_json::from_str(m.as_str()) { return Ok(v); } } let preview = crate::util::first_n_chars(cleaned, 200); Err(format!("no valid JSON in response: {preview}...")) } /// Get all keys for prompt context. pub(crate) fn semantic_keys(store: &Store) -> Vec { let mut keys: Vec = store.nodes.keys() .cloned() .collect(); keys.sort(); keys.truncate(200); keys }