Skip to content
This repository was archived by the owner on Aug 22, 2026. It is now read-only.

Commit e875cc4

Browse files
quangdang46claude
andcommitted
feat: port all Python gaps to Rust
Port 9 Python modules to Rust for full parity: - instructions.rs: CLI tool for reading instruction files - exporter.rs: Export palace as browsable markdown folder - fact_checker.rs: Verify text against entity registry and KG - dedup.rs: Near-duplicate drawer detection - repair.rs: Palace repair command - hooks_cli.rs: Claude Code hook runtime - diary_ingest.rs: Agent diary ingest with SHA256 state - closet_llm.rs: LLM-powered AAAK closet regeneration - migrate.rs: ChromaDB to embedvec migration Also add pub mod declarations to lib.rs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b36602e commit e875cc4

10 files changed

Lines changed: 2342 additions & 0 deletions

File tree

crates/core/src/closet_llm.rs

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
//! closet_llm.rs — LLM-powered AAAK closet regeneration.
2+
//!
3+
//! Regenerates AAAK closet entries via an OpenAI-compatible API.
4+
//! Runs in the background with exponential backoff on retries.
5+
//!
6+
//! Usage:
7+
//! mpr regenerate-closets [--wing X] [--dry-run]
8+
9+
use crate::config::Config;
10+
use crate::palace_db::PalaceDb;
11+
use serde::{Deserialize, Serialize};
12+
use std::path::Path;
13+
14+
/// Default endpoint (Ollama/local inference).
15+
const DEFAULT_ENDPOINT: &str = "http://localhost:11434/api/generate";
16+
const MAX_RETRIES: u32 = 5;
17+
const INITIAL_DELAY_MS: u64 = 1000;
18+
19+
/// AAAK prompt template for structured regeneration.
20+
const REGENERATE_PROMPT: &str = r#"You are a memory archivist. Regenerate the following memory entry in AAAK shorthand (dialect.py format).
21+
22+
Context: {context}
23+
24+
Requirements:
25+
- Keep ALL verbatim quotes exactly as written
26+
- Use AAAK abbreviations for entity names
27+
- Include source attribution
28+
- Output ONLY the AAAK compressed form, no explanation"#;
29+
30+
/// Regeneration statistics.
31+
#[derive(Debug, Clone, Serialize)]
32+
pub struct RegenerateStats {
33+
pub wings_processed: usize,
34+
pub entries_regenerated: usize,
35+
pub errors: usize,
36+
}
37+
38+
/// Result of a successful LLM generation.
39+
#[derive(Debug, Clone, Deserialize)]
40+
struct LlmResponse {
41+
response: String,
42+
}
43+
44+
pub use self::RegenerateError as Error;
45+
46+
#[derive(Debug, thiserror::Error)]
47+
pub enum RegenerateError {
48+
#[error("HTTP error: {0}")]
49+
Http(#[from] reqwest::Error),
50+
51+
#[error("LLM returned non-200: {code} — {message}")]
52+
NonOk { code: u16, message: String },
53+
54+
#[error("LLM returned invalid JSON: {0}")]
55+
InvalidJson(serde_json::Error),
56+
57+
#[error("LLM response empty")]
58+
Empty,
59+
60+
#[error("Palace error: {0}")]
61+
Palace(String),
62+
}
63+
64+
/// Regenerate closets for a wing (or all wings).
65+
pub fn regenerate_closets(
66+
palace_path: Option<&Path>,
67+
wing: Option<&str>,
68+
dry_run: bool,
69+
endpoint: Option<&str>,
70+
) -> anyhow::Result<RegenerateStats> {
71+
let config = Config::load()?;
72+
let palace_path = palace_path.unwrap_or_else(|| config.palace_path.as_path());
73+
let palace_db = PalaceDb::open(palace_path).map_err(|e| anyhow::anyhow!("{e}"))?;
74+
75+
let endpoint = endpoint.unwrap_or(DEFAULT_ENDPOINT);
76+
77+
println!("\n{}", "=".repeat(55));
78+
println!(" MemPalace Closet Regenerator");
79+
println!("{}", "=".repeat(55));
80+
println!(" Palace: {}", palace_path.display());
81+
println!(" Endpoint: {}", endpoint);
82+
if let Some(w) = wing {
83+
println!(" Wing: {}", w);
84+
}
85+
println!(" Mode: {}", if dry_run { "DRY RUN" } else { "LIVE" });
86+
println!("{}", "=".repeat(55));
87+
88+
let all_entries = palace_db.get_all(wing, None, usize::MAX);
89+
let mut entries_by_wing: std::collections::HashMap<String, Vec<_>> =
90+
std::collections::HashMap::new();
91+
for entry in &all_entries {
92+
let wing_name = entry.metadatas.first()
93+
.and_then(|m| m.get("wing"))
94+
.and_then(|v| v.as_str())
95+
.unwrap_or("unknown")
96+
.to_string();
97+
entries_by_wing
98+
.entry(wing_name)
99+
.or_default()
100+
.push(entry.clone());
101+
}
102+
103+
let mut total_regenerated = 0usize;
104+
let mut total_errors = 0usize;
105+
106+
for (wing_name, entries) in &entries_by_wing {
107+
println!("\n Processing wing: {}", wing_name);
108+
for entry in entries {
109+
let content = entry.documents.first().cloned().unwrap_or_default();
110+
if content.is_empty() {
111+
continue;
112+
}
113+
114+
if dry_run {
115+
println!(
116+
" [DRY RUN] Would regenerate: {}",
117+
&content[..content.len().min(80)]
118+
);
119+
total_regenerated += 1;
120+
} else {
121+
match regenerate_entry(&content, endpoint) {
122+
Ok(regenerated) => {
123+
if !regenerated.is_empty() {
124+
println!(
125+
" OK: {} → {}",
126+
&content[..content.len().min(40)],
127+
&regenerated[..regenerated.len().min(40)]
128+
);
129+
total_regenerated += 1;
130+
}
131+
}
132+
Err(e) => {
133+
eprintln!(" ERROR: {}", e);
134+
total_errors += 1;
135+
}
136+
}
137+
}
138+
}
139+
}
140+
141+
println!("\n{}", "=".repeat(55));
142+
if dry_run {
143+
println!(" [DRY RUN] No changes written.");
144+
}
145+
println!(
146+
" Done. Regenerated: {}, Errors: {}",
147+
total_regenerated, total_errors
148+
);
149+
println!("{}", "=".repeat(55));
150+
151+
Ok(RegenerateStats {
152+
wings_processed: entries_by_wing.len(),
153+
entries_regenerated: total_regenerated,
154+
errors: total_errors,
155+
})
156+
}
157+
158+
fn regenerate_entry(content: &str, endpoint: &str) -> Result<String, RegenerateError> {
159+
let prompt = REGENERATE_PROMPT.replace("{context}", content);
160+
161+
let client = reqwest::blocking::Client::new();
162+
let mut delay_ms = INITIAL_DELAY_MS;
163+
164+
for attempt in 0..MAX_RETRIES {
165+
let response = client
166+
.post(endpoint)
167+
.json(&serde_json::json!({
168+
"model": "llama3",
169+
"prompt": prompt,
170+
"stream": false,
171+
}))
172+
.timeout(std::time::Duration::from_secs(30))
173+
.send();
174+
175+
match response {
176+
Ok(resp) => {
177+
let status = resp.status();
178+
if status.is_success() {
179+
let parsed: LlmResponse = resp.json().map_err(RegenerateError::Http)?;
180+
if parsed.response.trim().is_empty() {
181+
return Err(RegenerateError::Empty);
182+
}
183+
return Ok(parsed.response.trim().to_string());
184+
}
185+
186+
if status.as_u16() == 429 || status.as_u16() == 503 {
187+
if attempt < MAX_RETRIES - 1 {
188+
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
189+
delay_ms *= 2;
190+
continue;
191+
}
192+
}
193+
194+
return Err(RegenerateError::NonOk {
195+
code: status.as_u16(),
196+
message: resp.text().unwrap_or_default(),
197+
});
198+
}
199+
Err(e) => {
200+
if attempt < MAX_RETRIES - 1 {
201+
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
202+
delay_ms *= 2;
203+
continue;
204+
}
205+
return Err(RegenerateError::Http(e));
206+
}
207+
}
208+
}
209+
210+
Err(RegenerateError::Empty)
211+
}
212+
213+
#[cfg(test)]
214+
mod tests {
215+
use super::*;
216+
217+
#[test]
218+
fn test_regenerate_prompt_includes_context() {
219+
let content = "Alice is the lead developer.";
220+
let prompt = REGENERATE_PROMPT.replace("{context}", content);
221+
assert!(prompt.contains(content));
222+
}
223+
224+
#[test]
225+
fn test_regenerate_stats_debug() {
226+
let stats = RegenerateStats {
227+
wings_processed: 2,
228+
entries_regenerated: 10,
229+
errors: 1,
230+
};
231+
let debug = format!("{:?}", stats);
232+
assert!(debug.contains("2"));
233+
}
234+
}

0 commit comments

Comments
 (0)