// 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; /// 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(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 result = unsafe { Command::new("claude") .args(["-p", "--model", model, "--tools", "", "--no-session-persistence"]) .stdin(fs::File::open(&tmp).map_err(|e| format!("open temp: {}", e))?) .env_remove("CLAUDECODE") .pre_exec(|| { // Kill this child if the parent dies libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); Ok(()) }) .output() }; fs::remove_file(&tmp).ok(); match result { Ok(output) => { if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr); let preview: String = stderr.chars().take(500).collect(); 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(prompt: &str, _timeout_secs: u64) -> Result { call_model("sonnet", prompt) } /// Call Haiku via claude CLI (cheaper, faster — good for high-volume extraction). pub(crate) fn call_haiku(prompt: &str) -> Result { call_model("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: String = cleaned.chars().take(200).collect(); Err(format!("no valid JSON in response: {preview}...")) } /// Get semantic keys (non-journal, non-system) for prompt context. pub(crate) fn semantic_keys(store: &Store) -> Vec { let mut keys: Vec = store.nodes.keys() .filter(|k| { !k.starts_with("journal.md#") && *k != "journal.md" && *k != "MEMORY.md" && *k != "where-am-i.md" && *k != "work-queue.md" && *k != "work-state" }) .cloned() .collect(); keys.sort(); keys.truncate(200); keys }