Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
20 KiB
JSON
1 line
20 KiB
JSON
{"repo": "billy1900/mneme", "files": {"mneme-consolidate/src/backends.rs": {"178b18a060f74bb7b1d6a1d802159aa095c9a959": {"t": 1786068397.385199, "n": 18274, "h": "42ab68707889694b", "c": "//! LLM backends for consolidation.\n\nuse async_trait::async_trait;\n\nuse crate::{ConsolidateError, ConsolidationLLM};\n\n/// Extract the first valid JSON object from a response string.\n/// More robust than stripping markdown fences character-by-character.\nfn extract_json(text: &str) -> &str {\n let s = text.trim();\n if let (Some(start), Some(end)) = (s.find('{'), s.rfind('}')) {\n if start <= end {\n return &s[start..=end];\n }\n }\n s\n}\n\n// ─────────────────────────────────────────────────────────────\n// Anthropic LLM (production)\n// ─────────────────────────────────────────────────────────────\n\n#[derive(Clone)]\npub struct AnthropicLLM {\n api_key: String,\n /// True if api_key is an OAuth Bearer token (sk-ant-oat01-…),\n /// false if it's a plain API key (sk-ant-api03-…).\n use_bearer: bool,\n model: String,\n client: reqwest::Client,\n}\n\nimpl AnthropicLLM {\n pub fn new(api_key: String) -> Self {\n let use_bearer = api_key.starts_with(\"sk-ant-oat\");\n Self {\n api_key,\n use_bearer,\n model: \"claude-haiku-4-5-20251001\".to_string(),\n client: reqwest::Client::new(),\n }\n }\n\n pub fn with_model(api_key: String, model: &str) -> Self {\n let use_bearer = api_key.starts_with(\"sk-ant-oat\");\n Self {\n api_key,\n use_bearer,\n model: model.to_string(),\n client: reqwest::Client::new(),\n }\n }\n\n /// Load credentials from ~/.claude/.credentials.json (Claude Code OAuth).\n pub fn from_claude_credentials() -> Result<Self, String> {\n let home = std::env::var(\"HOME\").map_err(|e| e.to_string())?;\n let path = format!(\"{}/.claude/.credentials.json\", home);\n let text = std::fs::read_to_string(&path).map_err(|e| format!(\"read {path}: {e}\"))?;\n let val: serde_json::Value =\n serde_json::from_str(&text).map_err(|e| format!(\"parse credentials: {e}\"))?;\n let token = val[\"claudeAiOauth\"][\"accessToken\"]\n .as_str()\n .ok_or(\"no accessToken\")?\n .to_string();\n Ok(Self::new(token))\n }\n}\n\n#[async_trait]\nimpl ConsolidationLLM for AnthropicLLM {\n async fn complete(&self, prompt: &str) -> Result<String, ConsolidateError> {\n let body = serde_json::json!({\n \"model\": self.model,\n \"max_tokens\": 512,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n });\n\n let mut req = self\n .client\n .post(\"https://api.anthropic.com/v1/messages\")\n .header(\"anthropic-version\", \"2023-06-01\")\n .header(\"content-type\", \"application/json\");\n\n req = if self.use_bearer {\n req.header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n } else {\n req.header(\"x-api-key\", &self.api_key)\n };\n\n let response = req\n .json(&body)\n .send()\n .await\n .map_err(|e| ConsolidateError::LLM(e.to_string()))?;\n\n if !response.status().is_success() {\n let status = response.status();\n let body = response\n .text()\n .await\n .unwrap_or_else(|_| \"unknown error\".to_string());\n return Err(ConsolidateError::LLM(format!(\n \"Anthropic API error {}: {}\",\n status, body\n )));\n }\n\n let data: serde_json::Value = response\n .json()\n .await\n .map_err(|e| ConsolidateError::LLM(e.to_string()))?;\n\n let text = data[\"content\"]\n .as_array()\n .and_then(|arr| arr.first())\n .and_then(|block| block[\"text\"].as_str())\n .ok_or_else(|| ConsolidateError::LLM(\"no text in response\".into()))?;\n\n let cleaned = extract_json(text).to_string();\n\n Ok(cleaned)\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// OpenAI LLM (gpt-4o-mini, for benchmarks without Anthropic key)\n// ─────────────────────────────────────────────────────────────\n\n#[derive(Clone)]\npub struct OpenAILLM {\n api_key: String,\n model: String,\n client: reqwest::Client,\n}\n\nimpl OpenAILLM {\n pub fn new(api_key: String) -> Self {\n Self {\n api_key,\n model: \"gpt-4o-mini\".to_string(),\n client: reqwest::Client::new(),\n }\n }\n\n pub fn with_model(api_key: String, model: &str) -> Self {\n Self {\n api_key,\n model: model.to_string(),\n client: reqwest::Client::new(),\n }\n }\n}\n\n#[async_trait]\nimpl ConsolidationLLM for OpenAILLM {\n async fn complete(&self, prompt: &str) -> Result<String, ConsolidateError> {\n let body = serde_json::json!({\n \"model\": self.model,\n // Fact extraction returns up to 12 facts, each a full sentence\n // plus a subject/relation/object decomposition. 512 truncated\n // that mid-object, which under `json_object` mode comes back as\n // an empty completion rather than a partial one.\n \"max_tokens\": 2048,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n // This mode can only produce a top-level JSON *object*, so every\n // prompt sent through this backend must ask for one — a request\n // for a bare array returns empty. See `parse_extraction_list`.\n \"response_format\": {\"type\": \"json_object\"},\n });\n\n let response = self\n .client\n .post(\"https://api.openai.com/v1/chat/completions\")\n .header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .map_err(|e| ConsolidateError::LLM(e.to_string()))?;\n\n if !response.status().is_success() {\n let status = response.status();\n let body = response\n .text()\n .await\n .unwrap_or_else(|_| \"unknown error\".to_string());\n return Err(ConsolidateError::LLM(format!(\n \"OpenAI API error {}: {}\",\n status, body\n )));\n }\n\n let data: serde_json::Value = response\n .json()\n .await\n .map_err(|e| ConsolidateError::LLM(e.to_string()))?;\n\n let text = data[\"choices\"]\n .as_array()\n .and_then(|arr| arr.first())\n .and_then(|c| c[\"message\"][\"content\"].as_str())\n .ok_or_else(|| ConsolidateError::LLM(\"no content in response\".into()))?;\n\n // An empty completion with `finish_reason: length` means the token\n // budget ran out before any visible content — for a reasoning model,\n // usually spent entirely on reasoning tokens. Surfacing it as an error\n // matters because the callers here parse JSON out of this string, and\n // an empty string parses to \"nothing extracted\", silently turning a\n // misconfiguration into a plausible-looking result.\n if text.trim().is_empty() {\n let finish_reason = data[\"choices\"][0][\"finish_reason\"]\n .as_str()\n .unwrap_or(\"unknown\");\n return Err(ConsolidateError::LLM(format!(\n \"empty completion (finish_reason: {finish_reason}) — if 'length', raise max_tokens; reasoning models spend it before emitting content\"\n )));\n }\n\n let cleaned = text\n .trim()\n .strip_prefix(\"```json\")\n .unwrap_or(text.trim())\n .strip_prefix(\"```\")\n .unwrap_or(text.trim())\n .strip_suffix(\"```\")\n .unwrap_or(text.trim())\n .trim()\n .to_string();\n\n Ok(cleaned)\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// DeepSeek LLM (OpenAI-compatible chat/completions API)\n// ─────────────────────────────────────────────────────────────\n\n#[derive(Clone)]\npub struct DeepSeekLLM {\n api_key: String,\n model: String,\n client: reqwest::Client,\n}\n\nimpl DeepSeekLLM {\n pub fn new(api_key: String) -> Self {\n Self {\n api_key,\n model: \"deepseek-v4-flash\".to_string(),\n client: build_client(),\n }\n }\n\n pub fn with_model(api_key: String, model: &str) -> Self {\n Self {\n api_key,\n model: model.to_string(),\n client: build_client(),\n }\n }\n}\n\n/// DeepSeek's endpoint occasionally accepts a connection and then never\n/// responds (observed hang with zero bytes received, indefinitely). A bare\n/// `reqwest::Client::new()` has no timeout, so a request can wedge forever;\n/// bound it so callers can retry instead of hanging the whole pipeline.\nfn build_client() -> reqwest::Client {\n reqwest::Client::builder()\n .timeout(std::time::Duration::from_secs(60))\n .build()\n .expect(\"failed to build reqwest client\")\n}\n\n#[async_trait]\nimpl ConsolidationLLM for DeepSeekLLM {\n async fn complete(&self, prompt: &str) -> Result<String, ConsolidateError> {\n let body = serde_json::json!({\n \"model\": self.model,\n // DeepSeek's V4 models are reasoners, and reasoning tokens are\n // charged against `max_tokens` before any visible content is\n // emitted. Measured on the fact-extraction prompt: at 2048 the\n // model burned all 2048 on reasoning and returned\n // `finish_reason: length` with an EMPTY content string — which\n // looks exactly like \"this excerpt had no facts\". At 8192 the same\n // three-turn prompt answered normally, but a full LoCoMo session\n // (~22 turns) exhausted 8192 too. Hence the large ceiling; it is a\n // floor on reasoning headroom, not an expectation about answer\n // length. 16384 is accepted by the API; 32768 also is, if needed.\n \"max_tokens\": 16384,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n // This mode can only produce a top-level JSON *object*, so every\n // prompt sent through this backend must ask for one — a request\n // for a bare array returns empty. See `parse_extraction_list`.\n \"response_format\": {\"type\": \"json_object\"},\n });\n\n let response = self\n .client\n .post(\"https://api.deepseek.com/v1/chat/completions\")\n .header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .map_err(|e| ConsolidateError::LLM(e.to_string()))?;\n\n if !response.status().is_success() {\n let status = response.status();\n let body = response\n .text()\n .await\n .unwrap_or_else(|_| \"unknown error\".to_string());\n return Err(ConsolidateError::LLM(format!(\n \"DeepSeek API error {}: {}\",\n status, body\n )));\n }\n\n let data: serde_json::Value = response\n .json()\n .await\n .map_err(|e| ConsolidateError::LLM(e.to_string()))?;\n\n let text = data[\"choices\"]\n .as_array()\n .and_then(|arr| arr.first())\n .and_then(|c| c[\"message\"][\"content\"].as_str())\n .ok_or_else(|| ConsolidateError::LLM(\"no content in response\".into()))?;\n\n // An empty completion with `finish_reason: length` means the token\n // budget ran out before any visible content — for a reasoning model,\n // usually spent entirely on reasoning tokens. Surfacing it as an error\n // matters because the callers here parse JSON out of this string, and\n // an empty string parses to \"nothing extracted\", silently turning a\n // misconfiguration into a plausible-looking result.\n if text.trim().is_empty() {\n let finish_reason = data[\"choices\"][0][\"finish_reason\"]\n .as_str()\n .unwrap_or(\"unknown\");\n return Err(ConsolidateError::LLM(format!(\n \"empty completion (finish_reason: {finish_reason}) — if 'length', raise max_tokens; reasoning models spend it before emitting content\"\n )));\n }\n\n let cleaned = text\n .trim()\n .strip_prefix(\"```json\")\n .unwrap_or(text.trim())\n .strip_prefix(\"```\")\n .unwrap_or(text.trim())\n .strip_suffix(\"```\")\n .unwrap_or(text.trim())\n .trim()\n .to_string();\n\n Ok(cleaned)\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// Ollama LLM (local, no API key)\n// ─────────────────────────────────────────────────────────────\n\n/// Talks to a local Ollama daemon (default `http://localhost:11434`) instead\n/// of a paid cloud API. Lets compaction produce real (if lower-quality)\n/// synthesis without an OpenAI/Anthropic key — useful when those are\n/// unavailable (exhausted quota, no credentials) but a local model is.\n#[derive(Clone)]\npub struct OllamaLLM {\n base_url: String,\n model: String,\n client: reqwest::Client,\n}\n\nimpl OllamaLLM {\n pub fn new(model: &str) -> Self {\n Self {\n base_url: \"http://localhost:11434\".to_string(),\n model: model.to_string(),\n client: reqwest::Client::new(),\n }\n }\n\n pub fn with_base_url(base_url: &str, model: &str) -> Self {\n Self {\n base_url: base_url.trim_end_matches('/').to_string(),\n model: model.to_string(),\n client: reqwest::Client::new(),\n }\n }\n}\n\n#[async_trait]\nimpl ConsolidationLLM for OllamaLLM {\n async fn complete(&self, prompt: &str) -> Result<String, ConsolidateError> {\n let body = serde_json::json!({\n \"model\": self.model,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"stream\": false,\n // Ollama's structured-output mode: constrains the model to emit\n // valid JSON, so we don't need markdown-fence stripping like the\n // cloud backends above.\n \"format\": \"json\",\n // Reasoning models (e.g. Qwen3) emit a long chain-of-thought\n // before the final JSON by default — 5-10x slower for no\n // quality benefit on a short extraction/synthesis prompt like\n // ours. Ignored by non-reasoning models.\n \"think\": false,\n });\n\n let response = self\n .client\n .post(format!(\"{}/api/chat\", self.base_url))\n .json(&body)\n .send()\n .await\n .map_err(|e| ConsolidateError::LLM(format!(\"Ollama request failed: {e}\")))?;\n\n if !response.status().is_success() {\n let status = response.status();\n let body = response\n .text()\n .await\n .unwrap_or_else(|_| \"unknown error\".to_string());\n return Err(ConsolidateError::LLM(format!(\n \"Ollama API error {}: {}\",\n status, body\n )));\n }\n\n let data: serde_json::Value = response\n .json()\n .await\n .map_err(|e| ConsolidateError::LLM(e.to_string()))?;\n\n let text = data[\"message\"][\"content\"]\n .as_str()\n .ok_or_else(|| ConsolidateError::LLM(\"no content in Ollama response\".into()))?;\n\n Ok(extract_json(text).to_string())\n }\n}\n\n// ─────────────────────────────────────────────────────────────\n// Mock LLM (for testing)\n// ─────────────────────────────────────────────────────────────\n\npub struct MockLLM {\n default_response: String,\n}\n\nimpl MockLLM {\n pub fn new() -> Self {\n Self {\n default_response: serde_json::json!({\n \"full_text\": \"Mock consolidated memory\",\n \"summary\": \"Mock summary\",\n \"tags\": [\"mock\"],\n \"confidence\": 0.8\n })\n .to_string(),\n }\n }\n\n pub fn with_default_response(response: &str) -> Self {\n Self {\n default_response: response.to_string(),\n }\n }\n}\n\nimpl Default for MockLLM {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[async_trait]\nimpl ConsolidationLLM for MockLLM {\n async fn complete(&self, prompt: &str) -> Result<String, ConsolidateError> {\n if prompt.contains(\"reconsolidation engine\") {\n if prompt.contains(\"CONFLICT_TRIGGER\") {\n return Ok(serde_json::json!({\n \"decision\": \"conflict\",\n \"reasoning\": \"Mock: conflict detected\",\n \"confidence_adjustment\": -0.1\n })\n .to_string());\n }\n if prompt.contains(\"UPDATE_TRIGGER\") {\n return Ok(serde_json::json!({\n \"decision\": \"update\",\n \"reasoning\": \"Mock: update needed\",\n \"updated_text\": \"Mock updated memory text\",\n \"updated_summary\": \"Mock updated summary\",\n \"confidence_adjustment\": 0.05\n })\n .to_string());\n }\n return Ok(serde_json::json!({\n \"decision\": \"keep\",\n \"reasoning\": \"Mock: memory is still accurate\",\n \"confidence_adjustment\": 0.0\n })\n .to_string());\n }\n\n if prompt.contains(\"evolution engine\") {\n return Ok(serde_json::json!({\n \"full_text\": \"Mock evolved memory combining old and new evidence\",\n \"summary\": \"Mock evolved summary\",\n \"confidence\": 0.85\n })\n .to_string());\n }\n\n if prompt.contains(\"conflict\") || prompt.contains(\"contradiction\") {\n return Ok(serde_json::json!({\n \"strategy\": \"temporal_supersede\",\n \"reasoning\": \"Mock: newer evidence supersedes older\",\n \"winner_index\": 1\n })\n .to_string());\n }\n\n Ok(self.default_response.clone())\n }\n}\n"}}}} |