2026-03-05 15:30:57 -05:00
|
|
|
// LLM utilities: model invocation and response parsing
|
2026-03-03 17:18:18 -05:00
|
|
|
//
|
2026-03-05 21:15:57 -05:00
|
|
|
// Calls claude CLI as a subprocess. Uses prctl(PR_SET_PDEATHSIG)
|
|
|
|
|
// so child processes die when the daemon exits, preventing orphans.
|
2026-03-03 17:18:18 -05:00
|
|
|
|
|
|
|
|
use crate::store::Store;
|
|
|
|
|
|
|
|
|
|
use regex::Regex;
|
|
|
|
|
use std::fs;
|
2026-03-05 21:15:57 -05:00
|
|
|
use std::os::unix::process::CommandExt;
|
2026-03-03 17:18:18 -05:00
|
|
|
use std::process::Command;
|
|
|
|
|
|
2026-03-05 15:30:57 -05:00
|
|
|
/// Call a model via claude CLI. Returns the response text.
|
2026-03-05 21:15:57 -05:00
|
|
|
///
|
|
|
|
|
/// Sets PR_SET_PDEATHSIG on the child so it gets SIGTERM if the
|
|
|
|
|
/// parent daemon exits — no more orphaned claude processes.
|
2026-03-05 15:30:57 -05:00
|
|
|
fn call_model(model: &str, prompt: &str) -> Result<String, String> {
|
2026-03-03 17:18:18 -05:00
|
|
|
// 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))?;
|
|
|
|
|
|
2026-03-05 22:28:39 -05:00
|
|
|
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 API key for agent work if configured
|
|
|
|
|
if let Some(ref key) = crate::config::get().agent_api_key {
|
|
|
|
|
cmd.env("ANTHROPIC_API_KEY", key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 21:15:57 -05:00
|
|
|
let result = unsafe {
|
2026-03-05 22:28:39 -05:00
|
|
|
cmd.pre_exec(|| {
|
2026-03-05 21:15:57 -05:00
|
|
|
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
|
|
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
.output()
|
|
|
|
|
};
|
2026-03-03 17:18:18 -05:00
|
|
|
|
|
|
|
|
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);
|
2026-03-05 21:15:57 -05:00
|
|
|
let preview: String = stderr.chars().take(500).collect();
|
|
|
|
|
Err(format!("claude exited {}: {}", output.status, preview.trim()))
|
2026-03-03 17:18:18 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => Err(format!("spawn claude: {}", e)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 15:30:57 -05:00
|
|
|
/// Call Sonnet via claude CLI.
|
|
|
|
|
pub(crate) fn call_sonnet(prompt: &str, _timeout_secs: u64) -> Result<String, String> {
|
|
|
|
|
call_model("sonnet", prompt)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Call Haiku via claude CLI (cheaper, faster — good for high-volume extraction).
|
|
|
|
|
pub(crate) fn call_haiku(prompt: &str) -> Result<String, String> {
|
|
|
|
|
call_model("haiku", prompt)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse a JSON response, handling markdown fences.
|
2026-03-03 17:18:18 -05:00
|
|
|
pub(crate) fn parse_json_response(response: &str) -> Result<serde_json::Value, String> {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 21:15:57 -05:00
|
|
|
let preview: String = cleaned.chars().take(200).collect();
|
|
|
|
|
Err(format!("no valid JSON in response: {preview}..."))
|
2026-03-03 17:18:18 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get semantic keys (non-journal, non-system) for prompt context.
|
|
|
|
|
pub(crate) fn semantic_keys(store: &Store) -> Vec<String> {
|
|
|
|
|
let mut keys: Vec<String> = 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
|
|
|
|
|
}
|