Skip to content

Commit c2a35fc

Browse files
tcconnallyclaude
andcommitted
feat: Anthropic /memories directory-convention adapter (mimir_memories)
New MCP tool implementing the memory_20250818 command set — view / create / str_replace / insert / delete / rename over paths under /memories — so clients built against Claude's native memory tool can point at the vault unchanged. Files are entities in the reserved 'memories' category (key = path): FTS-indexed, encrypted at rest, and edits are versioned through the normal bi-temporal history. view on a file returns cat -n numbered content like the native tool; str_replace enforces unique-match; path traversal is rejected. Supporting fixes: - remember_skip_dedup(): file-semantics writers must create THIS key even when similar content exists under another key — the near-dup merge that is right for organic memories silently loses files. remember() delegates to a shared impl; behavior unchanged. - Revival FTS bug (pre-existing, hit by delete→create): forget deletes the entity's FTS row, and remember's update path only UPDATEd it — a silent no-op, leaving any revived entity unsearchable forever. Now re-inserts when the UPDATE matches nothing. Test: memories_adapter_full_lifecycle_roundtrip (create/list/view/ str_replace incl. ambiguity+missing errors/insert-at-0/rename/ traversal-reject/delete/recreate-with-FTS-revival). Suite: 165 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d52d94d commit c2a35fc

3 files changed

Lines changed: 425 additions & 19 deletions

File tree

src/db.rs

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,11 +1482,30 @@ impl Database {
14821482
Ok(None)
14831483
}
14841484

1485-
/// Store or update an entity. Idempotent by (category, key).
1485+
/// Store or update an entity. Idempotent by (category, key, workspace).
14861486
/// Returns the entity id and whether this was a create or update.
14871487
pub fn remember(
14881488
&self,
14891489
entity: &Entity,
1490+
) -> Result<(String, String), Box<dyn std::error::Error>> {
1491+
self.remember_impl(entity, false)
1492+
}
1493+
1494+
/// Like `remember`, but never merges the write into a near-duplicate.
1495+
/// For file-semantics writers (the /memories adapter, renames) where
1496+
/// "similar content already exists under another key" must still create
1497+
/// THIS key — silently deduping a deliberate file write loses the file.
1498+
pub fn remember_skip_dedup(
1499+
&self,
1500+
entity: &Entity,
1501+
) -> Result<(String, String), Box<dyn std::error::Error>> {
1502+
self.remember_impl(entity, true)
1503+
}
1504+
1505+
fn remember_impl(
1506+
&self,
1507+
entity: &Entity,
1508+
skip_dedup: bool,
14901509
) -> Result<(String, String), Box<dyn std::error::Error>> {
14911510
let conn = self.conn()?;
14921511
let tags_json = serde_json::to_string(&entity.tags)?;
@@ -1668,39 +1687,52 @@ impl Database {
16681687
)?;
16691688
}
16701689

1671-
// Update FTS5 index
1672-
tx.execute(
1690+
// Update FTS5 index. A row revived from archive has NO fts row
1691+
// (forget deletes it), so a plain UPDATE was a silent no-op and
1692+
// the revived entity stayed unsearchable forever — re-insert in
1693+
// that case.
1694+
let fts_rows = tx.execute(
16731695
"UPDATE entities_fts SET body_json = ?1 WHERE rowid = (SELECT rowid FROM entities WHERE id = ?2)",
16741696
params![entity.body_json, id],
16751697
)?;
1698+
if fts_rows == 0 {
1699+
tx.execute(
1700+
"INSERT INTO entities_fts (rowid, body_json)
1701+
VALUES ((SELECT rowid FROM entities WHERE id = ?2), ?1)",
1702+
params![entity.body_json, id],
1703+
)?;
1704+
}
16761705
tx.commit()?;
16771706

16781707
action = "updated".to_string();
16791708
should_embed = content_changed;
16801709
} else {
1681-
// Check for near-duplicates before inserting
1710+
// Check for near-duplicates before inserting (unless the caller is
1711+
// a file-semantics writer — see remember_skip_dedup).
16821712
let dup_threshold = 0.7; // 70% trigram similarity
16831713
// MIMIR_DEDUP_FTS_PREFILTER (default off) trades exact dedup for an
16841714
// FTS candidate prefilter that collapses the O(M*N) bulk-import cost.
16851715
// See find_near_duplicate for the lossiness tradeoff. (#228)
16861716
let fts_prefilter = std::env::var("MIMIR_DEDUP_FTS_PREFILTER")
16871717
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
16881718
.unwrap_or(false);
1689-
if let Ok(Some(dup_id)) = self.find_near_duplicate(
1690-
&entity.category,
1691-
&entity.workspace_hash,
1692-
&entity.body_json,
1693-
dup_threshold,
1694-
fts_prefilter,
1695-
) {
1696-
// Near-duplicate found — bump its importance instead of creating new
1697-
let _ = conn.execute(
1698-
"UPDATE entities SET decay_score = MIN(1.0, decay_score + 0.15),
1699-
retrieval_count = retrieval_count + 1,
1700-
last_accessed_unix_ms = ?1 WHERE id = ?2",
1701-
params![now_ms(), dup_id],
1702-
);
1703-
return Ok((dup_id, "deduped (new key not created)".to_string()));
1719+
if !skip_dedup {
1720+
if let Ok(Some(dup_id)) = self.find_near_duplicate(
1721+
&entity.category,
1722+
&entity.workspace_hash,
1723+
&entity.body_json,
1724+
dup_threshold,
1725+
fts_prefilter,
1726+
) {
1727+
// Near-duplicate found — bump its importance instead of creating new
1728+
let _ = conn.execute(
1729+
"UPDATE entities SET decay_score = MIN(1.0, decay_score + 0.15),
1730+
retrieval_count = retrieval_count + 1,
1731+
last_accessed_unix_ms = ?1 WHERE id = ?2",
1732+
params![now_ms(), dup_id],
1733+
);
1734+
return Ok((dup_id, "deduped (new key not created)".to_string()));
1735+
}
17041736
}
17051737

17061738
// Insert new entity

src/mcp.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,56 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
15661566
},
15671567
"title": "Purge Archived Entities"
15681568
},
1569+
{
1570+
"name": "mimir_memories",
1571+
"description": "Anthropic memory-tool compatible file interface over the vault: view / create / str_replace / insert / delete / rename on paths under /memories. Files are stored as vault entities (category 'memories', FTS-indexed, encrypted at rest, edits versioned via history), so clients built against Claude's native memory directory convention can use the vault unchanged. Use command='view' with path='/memories' to list files.",
1572+
"inputSchema": {
1573+
"type": "object",
1574+
"properties": {
1575+
"command": {
1576+
"type": "string",
1577+
"enum": ["view", "create", "str_replace", "insert", "delete", "rename"],
1578+
"description": "The operation to perform"
1579+
},
1580+
"path": {
1581+
"type": "string",
1582+
"description": "Path under /memories (e.g. '/memories/notes.md'). For view, '/memories' lists the directory."
1583+
},
1584+
"file_text": {
1585+
"type": "string",
1586+
"description": "create: full file content to write (overwrites an existing file)"
1587+
},
1588+
"old_str": {
1589+
"type": "string",
1590+
"description": "str_replace: exact text to replace — must occur exactly once in the file"
1591+
},
1592+
"new_str": {
1593+
"type": "string",
1594+
"description": "str_replace: replacement text"
1595+
},
1596+
"insert_line": {
1597+
"type": "integer",
1598+
"description": "insert: line number to insert AT (0 = beginning of file)"
1599+
},
1600+
"insert_text": {
1601+
"type": "string",
1602+
"description": "insert: the line to insert"
1603+
},
1604+
"old_path": {
1605+
"type": "string",
1606+
"description": "rename: current path"
1607+
},
1608+
"new_path": {
1609+
"type": "string",
1610+
"description": "rename: destination path (must not exist)"
1611+
}
1612+
},
1613+
"required": [
1614+
"command"
1615+
]
1616+
},
1617+
"title": "Memories Directory (Anthropic convention)"
1618+
},
15691619
{
15701620
"name": "mimir_migrate",
15711621
"description": "Migrate a v0.1.x Mneme 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.",
@@ -2925,6 +2975,7 @@ fn call_tool(name: &str, db: &Database, args: Value, _id: Option<Value>) -> Stri
29252975
"mimir_compact" => Ok(tools::handle_compact(db, args)),
29262976

29272977
"mimir_purge" => tools::handle_purge(db, args).map_err(|e| e.to_string()),
2978+
"mimir_memories" => tools::handle_memories(db, args).map_err(|e| e.to_string()),
29282979

29292980
"mimir_migrate" => Ok(tools::handle_migrate(db, args)),
29302981

@@ -2991,6 +3042,92 @@ mod tests {
29913042
use super::*;
29923043
use std::fs;
29933044

3045+
#[test]
3046+
fn memories_adapter_full_lifecycle_roundtrip() {
3047+
// The Anthropic /memories directory convention over vault entities:
3048+
// create, list, view (numbered), str_replace (unique-match), insert,
3049+
// rename, delete, and recreate-after-delete (revival must also
3050+
// restore the FTS row so the file is searchable again).
3051+
let db_path = std::env::temp_dir()
3052+
.join(format!("mimir-memories-{}.db", uuid::Uuid::new_v4()));
3053+
let db = Database::open(db_path.to_str().expect("temp db path")).expect("open temp db");
3054+
let call = |args: Value| -> String {
3055+
call_tool("mimir_memories", &db, args, None)
3056+
};
3057+
3058+
// create
3059+
let r = call(json!({"command": "create", "path": "/memories/notes.md",
3060+
"file_text": "alpha\nbeta\ngamma"}));
3061+
assert!(r.contains("created"), "create failed: {r}");
3062+
3063+
// view directory
3064+
let r = call(json!({"command": "view", "path": "/memories"}));
3065+
let v: Value = serde_json::from_str(&r).unwrap();
3066+
assert_eq!(v["files"], json!(["notes.md"]), "dir listing: {r}");
3067+
3068+
// view file — numbered content
3069+
let r = call(json!({"command": "view", "path": "/memories/notes.md"}));
3070+
assert!(r.contains("beta"), "view content missing: {r}");
3071+
let v: Value = serde_json::from_str(&r).unwrap();
3072+
assert!(
3073+
v["content"].as_str().unwrap().contains(" 2\tbeta"),
3074+
"expected cat -n numbering: {r}"
3075+
);
3076+
3077+
// str_replace — must reject ambiguous and missing matches
3078+
let r = call(json!({"command": "str_replace", "path": "/memories/notes.md",
3079+
"old_str": "beta", "new_str": "BETA"}));
3080+
assert!(r.contains("replaced"), "str_replace failed: {r}");
3081+
let r = call(json!({"command": "str_replace", "path": "/memories/notes.md",
3082+
"old_str": "missing", "new_str": "x"}));
3083+
assert!(r.contains("not found"), "missing old_str must error: {r}");
3084+
3085+
// insert at line 0
3086+
let r = call(json!({"command": "insert", "path": "/memories/notes.md",
3087+
"insert_line": 0, "insert_text": "header"}));
3088+
assert!(r.contains("inserted"), "insert failed: {r}");
3089+
let r = call(json!({"command": "view", "path": "/memories/notes.md"}));
3090+
let v: Value = serde_json::from_str(&r).unwrap();
3091+
assert!(
3092+
v["content"].as_str().unwrap().starts_with(" 1\theader"),
3093+
"insert at 0 must lead the file: {r}"
3094+
);
3095+
3096+
// rename
3097+
let r = call(json!({"command": "rename", "old_path": "/memories/notes.md",
3098+
"new_path": "/memories/archive/notes.md"}));
3099+
assert!(r.contains("renamed"), "rename failed: {r}");
3100+
let r = call(json!({"command": "view", "path": "/memories"}));
3101+
let v: Value = serde_json::from_str(&r).unwrap();
3102+
assert_eq!(v["files"], json!(["archive/notes.md"]), "post-rename listing: {r}");
3103+
3104+
// path traversal is rejected
3105+
let r = call(json!({"command": "view", "path": "/memories/../etc/passwd"}));
3106+
assert!(r.contains("invalid path") || r.contains("error"), "traversal must be rejected: {r}");
3107+
3108+
// delete, then recreate: revival must restore searchability (the FTS
3109+
// row is deleted by forget; the remember update path must re-insert it).
3110+
let r = call(json!({"command": "delete", "path": "/memories/archive/notes.md"}));
3111+
assert!(r.contains("deleted"), "delete failed: {r}");
3112+
let r = call(json!({"command": "create", "path": "/memories/archive/notes.md",
3113+
"file_text": "reborn searchable zanzibar"}));
3114+
assert!(r.contains("created"), "recreate failed: {r}");
3115+
let hits = db
3116+
.recall(&crate::models::RecallParams {
3117+
query: "zanzibar".to_string(),
3118+
skip_side_effects: true,
3119+
..crate::models::RecallParams::default()
3120+
})
3121+
.unwrap();
3122+
assert!(
3123+
hits.iter().any(|e| e.key == "archive/notes.md"),
3124+
"revived file must be FTS-searchable again"
3125+
);
3126+
3127+
drop(db);
3128+
let _ = fs::remove_file(&db_path);
3129+
}
3130+
29943131
#[test]
29953132
fn unknown_tool_error_reports_original_unnormalized_name() {
29963133
let db_path = std::env::temp_dir()

0 commit comments

Comments
 (0)