Skip to content

Commit 7c7c31e

Browse files
authored
fix: resolve issues #199, #200, #201 (#203)
subcommand present. Each subcommand has its own --db. Added CLI parsing tests. Feature was only available via MCP tools; now accessible from CLI. delete archived entities and VACUUM to reclaim disk space. Also added mimir_purge MCP tool with destructiveHint.
1 parent 2cec58e commit 7c7c31e

6 files changed

Lines changed: 232 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Mimir
22

3+
<!-- mcp-name: io.github.tcconnally/mimir -->
4+
35
> Persistent memory for AI agents. Structured entity model. SQLite + FTS5 + hybrid vector search. MCP-native. Fully local.
46
57
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)

src/db.rs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::connectors::Connector;
55
use crate::encryption::EncryptionManager;
66
use crate::models::{
77
AskParams, AskResult, AskSource, CompactReport, DecayReport, EmbedParams, Entity, GraphEdge,
8-
GraphNode, IngestParams, JournalEvent, MemoryLink, PruneParams, PruneReport, RecallParams,
8+
GraphNode, IngestParams, JournalEvent, MemoryLink, PruneParams, PruneReport, PurgeReport, RecallParams,
99
StateEntry, Stats, TimelineParams, VaultReport,
1010
};
1111
use crate::schema;
@@ -2295,6 +2295,63 @@ impl Database {
22952295
}))
22962296
}
22972297

2298+
/// Permanently delete all archived entities and run VACUUM to reclaim disk space.
2299+
/// This is the only way to actually remove entities; prune/forget only soft-archive.
2300+
/// Deleted entities are NOT recoverable. Use dry_run=true to preview first.
2301+
pub fn purge(&self, dry_run: bool) -> Result<PurgeReport, Box<dyn std::error::Error>> {
2302+
let before_size = match std::fs::metadata(&self.db_path) {
2303+
Ok(m) => m.len() as i64,
2304+
Err(_) => 0i64,
2305+
};
2306+
2307+
// Count archived entities
2308+
let mut stmt = self.conn.prepare("SELECT COUNT(*) FROM entities WHERE archived = 1")?;
2309+
let count: i64 = stmt.query_row([], |r| r.get(0))?;
2310+
stmt.finalize()?;
2311+
2312+
if dry_run {
2313+
return Ok(PurgeReport {
2314+
entities_deleted: count,
2315+
bytes_freed: 0,
2316+
dry_run: true,
2317+
completed_at_unix_ms: now_ms(),
2318+
});
2319+
}
2320+
2321+
if count == 0 {
2322+
return Ok(PurgeReport {
2323+
entities_deleted: 0,
2324+
bytes_freed: 0,
2325+
dry_run: false,
2326+
completed_at_unix_ms: now_ms(),
2327+
});
2328+
}
2329+
2330+
// Delete archived entities from FTS5 index first, then the entities table
2331+
let tx = self.conn.unchecked_transaction()?;
2332+
self.conn.execute_batch(
2333+
"DELETE FROM entities_fts WHERE rowid IN (SELECT rowid FROM entities WHERE archived = 1);
2334+
DELETE FROM entities WHERE archived = 1;"
2335+
)?;
2336+
tx.commit()?;
2337+
2338+
// VACUUM to reclaim disk space
2339+
self.conn.execute_batch("VACUUM;")?;
2340+
2341+
let after_size = match std::fs::metadata(&self.db_path) {
2342+
Ok(m) => m.len() as i64,
2343+
Err(_) => 0i64,
2344+
};
2345+
let freed = if before_size > after_size { before_size - after_size } else { 0 };
2346+
2347+
Ok(PurgeReport {
2348+
entities_deleted: count,
2349+
bytes_freed: freed,
2350+
dry_run: false,
2351+
completed_at_unix_ms: now_ms(),
2352+
})
2353+
}
2354+
22982355
/// Compact: archive entities below a decay threshold.
22992356
pub fn compact(
23002357
&self,
@@ -3565,6 +3622,7 @@ mod tests {
35653622
older_than_days: None,
35663623
limit: 0,
35673624
dry_run: false,
3625+
purge_all: false,
35683626
})
35693627
.unwrap();
35703628
assert_eq!(report.archived, 1);

src/main.rs

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,6 @@ struct Cli {
2323
#[command(subcommand)]
2424
command: Option<Commands>,
2525

26-
/// SQLite database path (used when no subcommand given)
27-
#[arg(long, default_value_t = default_db_path())]
28-
db: String,
29-
3026
/// Path to AES-256-GCM encryption key file (base64-encoded, 32 bytes)
3127
#[arg(long)]
3228
encryption_key: Option<String>,
@@ -293,6 +289,39 @@ enum Commands {
293289
#[arg(long, default_value_t = default_db_path())]
294290
db: String,
295291
},
292+
293+
/// Export all non-archived entities to .md files in a vault directory
294+
VaultExport {
295+
/// SQLite database path
296+
#[arg(long, default_value_t = default_db_path())]
297+
db: String,
298+
/// Target directory for .md files (created if needed)
299+
#[arg(long, default_value_t = String::from("~/.mimir/vault"))]
300+
vault_dir: String,
301+
/// Optional workspace hash to scope the export
302+
#[arg(long)]
303+
workspace_hash: Option<String>,
304+
},
305+
306+
/// Import .md files from a vault directory into the database
307+
VaultImport {
308+
/// SQLite database path
309+
#[arg(long, default_value_t = default_db_path())]
310+
db: String,
311+
/// Source directory containing .md files
312+
#[arg(long, default_value_t = String::from("~/.mimir/vault"))]
313+
vault_dir: String,
314+
},
315+
316+
/// Permanently delete archived entities and run VACUUM to reclaim disk space
317+
Purge {
318+
/// SQLite database path
319+
#[arg(long, default_value_t = default_db_path())]
320+
db: String,
321+
/// Preview what would be deleted without changing anything
322+
#[arg(long)]
323+
dry_run: bool,
324+
},
296325
}
297326

298327
fn default_db_path() -> String {
@@ -438,6 +467,7 @@ fn main() {
438467
older_than_days,
439468
limit,
440469
dry_run,
470+
purge_all: false,
441471
};
442472
match database.prune(&params) {
443473
Ok(report) => print_json(&report),
@@ -545,6 +575,65 @@ fn main() {
545575
}
546576
}
547577
}
578+
Some(Commands::VaultExport {
579+
db: ref db_path,
580+
ref vault_dir,
581+
ref workspace_hash,
582+
}) => {
583+
check_legacy_db(db_path);
584+
let database = open_db_or_exit(db_path);
585+
let dir = if vault_dir.starts_with("~/") {
586+
let home = std::env::var("HOME")
587+
.or_else(|_| std::env::var("USERPROFILE"))
588+
.unwrap_or_else(|_| "/root".to_string());
589+
vault_dir.replacen("~", &home, 1)
590+
} else {
591+
vault_dir.clone()
592+
};
593+
match database.vault_export(&dir, workspace_hash.as_deref()) {
594+
Ok(report) => print_json(&report),
595+
Err(e) => {
596+
eprintln!("mimir: vault export failed: {}", e);
597+
std::process::exit(1);
598+
}
599+
}
600+
}
601+
Some(Commands::VaultImport {
602+
db: ref db_path,
603+
ref vault_dir,
604+
}) => {
605+
check_legacy_db(db_path);
606+
let database = open_db_or_exit(db_path);
607+
let dir = if vault_dir.starts_with("~/") {
608+
let home = std::env::var("HOME")
609+
.or_else(|_| std::env::var("USERPROFILE"))
610+
.unwrap_or_else(|_| "/root".to_string());
611+
vault_dir.replacen("~", &home, 1)
612+
} else {
613+
vault_dir.clone()
614+
};
615+
match database.vault_import(&dir) {
616+
Ok(report) => print_json(&report),
617+
Err(e) => {
618+
eprintln!("mimir: vault import failed: {}", e);
619+
std::process::exit(1);
620+
}
621+
}
622+
}
623+
Some(Commands::Purge {
624+
db: ref db_path,
625+
dry_run,
626+
}) => {
627+
check_legacy_db(db_path);
628+
let database = open_db_or_exit(db_path);
629+
match database.purge(dry_run) {
630+
Ok(report) => print_json(&report),
631+
Err(e) => {
632+
eprintln!("mimir: purge failed: {}", e);
633+
std::process::exit(1);
634+
}
635+
}
636+
}
548637
Some(Commands::Migrate { from, to }) => {
549638
let target_db = match db::Database::open(&to) {
550639
Ok(db) => db,
@@ -756,7 +845,7 @@ fn main() {
756845
}
757846
}
758847
None => {
759-
let db_path = cli.db.clone();
848+
let db_path = default_db_path();
760849
let mut database = match db::Database::open(&db_path) {
761850
Ok(db) => db,
762851
Err(e) => {
@@ -1006,10 +1095,18 @@ mod tests {
10061095
use clap::Parser;
10071096

10081097
#[test]
1009-
fn parses_direct_server_with_db() {
1010-
let cli = Cli::parse_from(["mimir", "--db", "/tmp/mimir-direct.db"]);
1098+
fn parses_direct_server_without_subcommand() {
1099+
let cli = Cli::parse_from(["mimir"]);
10111100
assert!(cli.command.is_none());
1012-
assert_eq!(cli.db, "/tmp/mimir-direct.db");
1101+
}
1102+
1103+
#[test]
1104+
fn parses_serve_with_db() {
1105+
let cli = Cli::parse_from(["mimir", "serve", "--db", "/tmp/mimir-serve.db"]);
1106+
match cli.command {
1107+
Some(Commands::Serve { db, .. }) => assert_eq!(db, "/tmp/mimir-serve.db"),
1108+
_ => panic!("expected serve subcommand"),
1109+
}
10131110
}
10141111

10151112
#[test]

src/mcp.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,45 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
11191119
"destructiveHint": true
11201120
}
11211121
},
1122+
{
1123+
"name": "mimir_purge",
1124+
"description": "Permanently delete all archived entities and run VACUUM to reclaim disk space. This is the only operation that actually removes entities \u2014 prune/forget only soft-archive. Archived entities are DELETED and NOT RECOVERABLE. Supports dry_run=true to preview first.",
1125+
"inputSchema": {
1126+
"type": "object",
1127+
"properties": {
1128+
"dry_run": {
1129+
"type": "boolean",
1130+
"default": false,
1131+
"description": "If true, report what would be deleted without making changes"
1132+
}
1133+
},
1134+
"required": []
1135+
},
1136+
"outputSchema": {
1137+
"type": "object",
1138+
"properties": {
1139+
"entities_deleted": {
1140+
"type": "integer",
1141+
"description": "Number of archived entities permanently deleted"
1142+
},
1143+
"bytes_freed": {
1144+
"type": "integer",
1145+
"description": "Bytes reclaimed after VACUUM (0 in dry-run mode)"
1146+
},
1147+
"dry_run": {
1148+
"type": "boolean",
1149+
"description": "Whether this was a dry run"
1150+
},
1151+
"completed_at_unix_ms": {
1152+
"type": "integer",
1153+
"description": "Completion timestamp"
1154+
}
1155+
}
1156+
},
1157+
"annotations": {
1158+
"destructiveHint": true
1159+
}
1160+
},
11221161
{
11231162
"name": "mimir_migrate",
11241163
"description": "Migrate a v0.1.x Mimir database to the current v0.5.0 schema. Reads the old database, converts memories to the entity model, and merges into the current database. Use this once per legacy database during upgrade.",
@@ -2042,6 +2081,8 @@ fn call_tool(name: &str, db: &Database, args: Value, _id: Option<Value>) -> Stri
20422081

20432082
"mimir_compact" => Ok(tools::handle_compact(db, args)),
20442083

2084+
"mimir_purge" => tools::handle_purge(db, args).map_err(|e| e.to_string()),
2085+
20452086
"mimir_migrate" => Ok(tools::handle_migrate(db, args)),
20462087

20472088
"mimir_context" => Ok(tools::handle_context(db, args)),

src/models.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,15 @@ pub struct CompactReport {
319319
pub completed_at_unix_ms: i64,
320320
}
321321

322+
/// Purge report — permanently deletes archived entities and runs VACUUM.
323+
#[derive(Debug, Clone, Serialize)]
324+
pub struct PurgeReport {
325+
pub entities_deleted: i64,
326+
pub bytes_freed: i64,
327+
pub dry_run: bool,
328+
pub completed_at_unix_ms: i64,
329+
}
330+
322331
/// Parameters for the coherence daemon pass.
323332
#[derive(Debug, Clone, Deserialize, Default)]
324333
pub struct CohereParams {

src/tools.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,7 @@ pub fn handle_synthesize(db: &Database, args: Value) -> Result<String, String> {
14081408
serde_json::to_string(&result).map_err(|e| format!("Serialization failed: {}", e))
14091409
}
14101410

1411+
14111412
// ─── mimir_bench handler ─────────────────────────────────────────
14121413

14131414
#[derive(Debug, Deserialize)]
@@ -1447,3 +1448,18 @@ pub fn handle_bench(db: &Database, args: Value) -> Result<String, String> {
14471448
serde_json::to_string(&result).map_err(|e| format!("Serialization failed: {}", e))
14481449
}
14491450

1451+
/// Permanently delete all archived entities and VACUUM the database.
1452+
#[derive(Debug, Deserialize)]
1453+
pub struct PurgeArgs {
1454+
#[serde(default)]
1455+
pub dry_run: bool,
1456+
}
1457+
1458+
pub fn handle_purge(db: &Database, args: Value) -> Result<String, String> {
1459+
let a: PurgeArgs = serde_json::from_value(args)
1460+
.map_err(|e| format!("Invalid purge arguments: {}", e))?;
1461+
let report = db.purge(a.dry_run)
1462+
.map_err(|e| format!("Purge failed: {}", e))?;
1463+
serde_json::to_string(&report).map_err(|e| format!("Serialization failed: {}", e))
1464+
}
1465+

0 commit comments

Comments
 (0)