use std::fs;
use std::path::Path;
use kimetsu_core::KimetsuResult;
use kimetsu_core::paths::ProjectPaths;
use rusqlite::params;
use crate::lock::ProjectLock;
use crate::project::*;
use crate::projector;
use crate::trace::{self};
#[derive(Debug, Clone)]
pub struct PruneOptions {
pub scope: Option<String>,
pub min_uses: u32,
pub max_ratio: f32,
pub apply: bool,
}
impl Default for PruneOptions {
fn default() -> Self {
Self {
scope: None,
min_uses: 3,
max_ratio: -0.2,
apply: false,
}
}
}
#[derive(Debug, Clone)]
pub struct PruneCandidate {
pub memory_id: String,
pub scope: String,
pub kind: String,
pub use_count: u32,
pub usefulness_score: f32,
pub text: String,
}
#[derive(Debug, Clone, Default)]
pub struct PruneSummary {
pub candidates: Vec<PruneCandidate>,
pub invalidated: u32,
pub failed: u32,
}
pub fn prune_low_usefulness(start: &Path, opts: PruneOptions) -> KimetsuResult<PruneSummary> {
let min_uses = opts.min_uses.max(1) as i64;
let candidates = {
let (_paths, _config, conn) = load_project(start)?;
let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref()
{
(
"
SELECT memory_id, scope, kind, text, use_count, usefulness_score
FROM memories
WHERE invalidated_at IS NULL
AND superseded_by IS NULL
AND use_count >= ?1
AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
AND lower(scope) = lower(?3)
ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
",
Some(scope.to_string()),
)
} else {
(
"
SELECT memory_id, scope, kind, text, use_count, usefulness_score
FROM memories
WHERE invalidated_at IS NULL
AND superseded_by IS NULL
AND use_count >= ?1
AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
",
None,
)
};
let mut stmt = conn.prepare(sql)?;
let max_ratio = opts.max_ratio as f64;
let mut found: Vec<PruneCandidate> = if let Some(scope) = scope_param {
stmt.query_map(params![min_uses, max_ratio, scope], |row| {
Ok(PruneCandidate {
memory_id: row.get(0)?,
scope: row.get(1)?,
kind: row.get(2)?,
text: row.get(3)?,
use_count: row.get(4)?,
usefulness_score: row.get::<_, f64>(5)? as f32,
})
})?
.collect::<Result<Vec<_>, _>>()?
} else {
stmt.query_map(params![min_uses, max_ratio], |row| {
Ok(PruneCandidate {
memory_id: row.get(0)?,
scope: row.get(1)?,
kind: row.get(2)?,
text: row.get(3)?,
use_count: row.get(4)?,
usefulness_score: row.get::<_, f64>(5)? as f32,
})
})?
.collect::<Result<Vec<_>, _>>()?
};
found.sort_by(|a, b| {
let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
ra.partial_cmp(&rb)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.use_count.cmp(&a.use_count))
});
found
};
let mut summary = PruneSummary {
candidates: candidates.clone(),
invalidated: 0,
failed: 0,
};
if !opts.apply {
return Ok(summary);
}
for candidate in &candidates {
let ratio = candidate.usefulness_score / candidate.use_count.max(1) as f32;
let reason = format!(
"pruned_by_usefulness ratio={:+.2} use_count={}",
ratio, candidate.use_count
);
match invalidate_memory(start, &candidate.memory_id, Some(&reason)) {
Ok(()) => summary.invalidated += 1,
Err(_) => summary.failed += 1,
}
}
Ok(summary)
}
pub fn rebuild_projection(start: &Path, from_traces: bool) -> KimetsuResult<usize> {
let (paths, _config, conn) = load_project(start)?;
let _lock = ProjectLock::acquire(&paths, "brain rebuild", None)?;
if from_traces {
let events = trace::read_all_traces(&paths)?;
projector::rebuild(&conn, &events)?;
return Ok(events.len());
}
let event_count: i64 = conn.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))?;
if event_count == 0 {
let events = trace::read_all_traces(&paths)?;
if !events.is_empty() {
eprintln!(
"[kimetsu] events table empty; importing {} event(s) from legacy traces",
events.len()
);
projector::rebuild(&conn, &events)?;
return Ok(events.len());
}
}
projector::rebuild_in_place(&conn)
}
pub fn clear_lock(start: &Path) -> KimetsuResult<bool> {
let paths = ProjectPaths::discover(start)?;
crate::lock::clear_force(&paths)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CompactReport {
pub bytes_before: u64,
pub bytes_after: u64,
pub events_trimmed: u64,
pub invalidated_memories_purged: u64,
}
pub fn compact_brain(
start: &Path,
trim_events_older_than: Option<std::time::Duration>,
purge_invalidated: bool,
) -> KimetsuResult<CompactReport> {
let (paths, _config, conn) = load_project(start)?;
let _lock = ProjectLock::acquire(&paths, "brain compact", None)?;
let bytes_before = fs::metadata(&paths.brain_db).map(|m| m.len()).unwrap_or(0);
let invalidated_memories_purged = if purge_invalidated {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM memories WHERE invalidated_at IS NOT NULL",
[],
|r| r.get(0),
)?;
conn.execute_batch(
"DELETE FROM memories_fts WHERE memory_id IN (
SELECT memory_id FROM memories WHERE invalidated_at IS NOT NULL
);
DELETE FROM memories WHERE invalidated_at IS NOT NULL;",
)?;
count as u64
} else {
0
};
let events_trimmed = if let Some(dur) = trim_events_older_than {
let age_seconds = dur.as_secs().min(i64::MAX as u64) as i64;
conn.execute(
"DELETE FROM events
WHERE kind IN ('context.served','retrieval.stats','digest_served','resume_served')
AND julianday(ts) < julianday('now') - CAST(?1 AS REAL) / 86400.0",
rusqlite::params![age_seconds],
)? as u64
} else {
0
};
conn.execute_batch("VACUUM;")?;
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
let bytes_after = fs::metadata(&paths.brain_db).map(|m| m.len()).unwrap_or(0);
Ok(CompactReport {
bytes_before,
bytes_after,
events_trimmed,
invalidated_memories_purged,
})
}