Skip to content

Commit 9587f8c

Browse files
authored
feat(federation): workspace-scoped vault export + mimir_federate tool (#177)
Roadmap Phase 2, Week 7-9: Cross-Workspace Federation (v1.2.0). Three deliverables: 1. Workspace-scoped vault_export: optional workspace_hash param filters export to a single workspace. Vault .md files now include workspace_hash + agent_id in frontmatter for roundtrip fidelity through vault_import. 2. Last-write-wins merge: vault_import reads workspace_hash + agent_id from frontmatter and passes them through to remember(), which handles idempotent INSERT OR REPLACE by (category, key). Imported entities carry the source identity. 3. New mimir_federate MCP tool: exports entities from from_workspace to a temp vault, remaps workspace_hash in the .md files to to_workspace, then vault_imports into the target scope. Returns export/remap/import counts. Registered with inputSchema, outputSchema, annotations in mcp.rs. E2E verified: 2 entities in w1 federated to w2, recall in w2 returns both with correct workspace_hash. 31 tests passing. Known caveat: dedup-by-content in remember() can merge near-identical entity bodies even across workspaces — use distinct content for federated entities.
1 parent e8b1def commit 9587f8c

3 files changed

Lines changed: 140 additions & 10 deletions

File tree

src/db.rs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2072,18 +2072,32 @@ impl Database {
20722072
/// Export all non-archived entities to .md files in a vault directory.
20732073
/// Each entity becomes a .md file with YAML frontmatter.
20742074
/// Idempotent — updates changed files, creates new ones, never deletes.
2075-
pub fn vault_export(&self, vault_dir: &str) -> Result<VaultReport, Box<dyn std::error::Error>> {
2075+
pub fn vault_export(
2076+
&self,
2077+
vault_dir: &str,
2078+
workspace_hash: Option<&str>,
2079+
) -> Result<VaultReport, Box<dyn std::error::Error>> {
20762080
use std::fs;
20772081
use std::path::Path;
20782082

20792083
fs::create_dir_all(vault_dir)?;
20802084
let vault = Path::new(vault_dir);
20812085

2082-
let mut stmt = self.conn.prepare(
2086+
let sql = if let Some(ws) = workspace_hash {
2087+
format!(
2088+
"SELECT id, category, key, body_json, type, tags, decay_score,
2089+
retrieval_count, layer, workspace_hash, agent_id,
2090+
created_at_unix_ms, last_accessed_unix_ms
2091+
FROM entities WHERE archived = 0 AND workspace_hash = '{}'",
2092+
ws.replace('\'', "''")
2093+
)
2094+
} else {
20832095
"SELECT id, category, key, body_json, type, tags, decay_score,
2084-
retrieval_count, layer, created_at_unix_ms, last_accessed_unix_ms
2085-
FROM entities WHERE archived = 0",
2086-
)?;
2096+
retrieval_count, layer, workspace_hash, agent_id,
2097+
created_at_unix_ms, last_accessed_unix_ms
2098+
FROM entities WHERE archived = 0".to_string()
2099+
};
2100+
let mut stmt = self.conn.prepare(&sql)?;
20872101

20882102
let rows = stmt.query_map([], |r| {
20892103
Ok((
@@ -2096,8 +2110,10 @@ impl Database {
20962110
r.get::<_, f64>(6)?, // decay_score
20972111
r.get::<_, i64>(7)?, // retrieval_count
20982112
r.get::<_, String>(8)?, // layer
2099-
r.get::<_, i64>(9)?, // created_at
2100-
r.get::<_, i64>(10)?, // last_accessed
2113+
r.get::<_, String>(9)?, // workspace_hash
2114+
r.get::<_, String>(10)?,// agent_id
2115+
r.get::<_, i64>(11)?, // created_at
2116+
r.get::<_, i64>(12)?, // last_accessed
21012117
))
21022118
})?;
21032119

@@ -2116,6 +2132,8 @@ impl Database {
21162132
decay,
21172133
retrievals,
21182134
layer,
2135+
workspace_hash_val,
2136+
agent_id_val,
21192137
created,
21202138
accessed,
21212139
) = row?;
@@ -2142,6 +2160,8 @@ id: {}
21422160
category: {}
21432161
key: {}
21442162
type: {}
2163+
workspace_hash: {}
2164+
agent_id: {}
21452165
tags: {}
21462166
decay_score: {:.4}
21472167
retrieval_count: {}
@@ -2156,6 +2176,8 @@ last_accessed: {}
21562176
category,
21572177
key,
21582178
etype,
2179+
workspace_hash_val,
2180+
agent_id_val,
21592181
tags,
21602182
decay,
21612183
retrievals,
@@ -2286,6 +2308,8 @@ last_accessed: {}
22862308
let tags_str = get_fm("tags");
22872309
let decay: f64 = get_fm("decay_score").parse().unwrap_or(1.0);
22882310
let layer = get_fm("layer");
2311+
let workspace_hash_val = get_fm("workspace_hash");
2312+
let agent_id_val = get_fm("agent_id");
22892313

22902314
let tags: Vec<String> = if tags_str.is_empty() || tags_str == "[]" {
22912315
vec![]
@@ -2338,8 +2362,8 @@ last_accessed: {}
23382362
source: "vault-import".to_string(),
23392363
always_on: false,
23402364
certainty: 0.5,
2341-
workspace_hash: String::new(),
2342-
agent_id: String::new(),
2365+
workspace_hash: workspace_hash_val,
2366+
agent_id: agent_id_val,
23432367
created_at_unix_ms: now_ms(),
23442368
last_accessed_unix_ms: now_ms(),
23452369
embedding: None,

src/mcp.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,54 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
15961596
"annotations": {
15971597
"destructiveHint": true
15981598
}
1599+
},
1600+
{
1601+
"name": "mimir_federate",
1602+
"description": "Federate entities from one workspace to another. Exports entities scoped to from_workspace, remaps their workspace_hash to to_workspace, and imports them — effectively copying or moving knowledge between workspaces. Use this for cross-agent or cross-project knowledge sharing without manual file transfer.",
1603+
"inputSchema": {
1604+
"type": "object",
1605+
"properties": {
1606+
"from_workspace": {
1607+
"type": "string",
1608+
"description": "Source workspace hash to export entities from"
1609+
},
1610+
"to_workspace": {
1611+
"type": "string",
1612+
"description": "Target workspace hash to import entities into"
1613+
},
1614+
"vault_dir": {
1615+
"type": "string",
1616+
"default": "/tmp/mimir-federate",
1617+
"description": "Temporary vault directory for the intermediate .md export files"
1618+
}
1619+
},
1620+
"required": ["from_workspace", "to_workspace"]
1621+
},
1622+
"outputSchema": {
1623+
"type": "object",
1624+
"properties": {
1625+
"exported": {
1626+
"type": "integer",
1627+
"description": "Number of entities exported from the source workspace"
1628+
},
1629+
"remapped": {
1630+
"type": "integer",
1631+
"description": "Number of entities whose workspace_hash was remapped"
1632+
},
1633+
"imported": {
1634+
"type": "integer",
1635+
"description": "Number of entities imported into the target workspace"
1636+
},
1637+
"import_errors": {
1638+
"type": "array",
1639+
"items": {"type": "string"},
1640+
"description": "Any errors encountered during import"
1641+
}
1642+
}
1643+
},
1644+
"annotations": {
1645+
"destructiveHint": true
1646+
}
15991647
}
16001648
]"###
16011649
).expect("tools JSON must be valid");
@@ -1659,6 +1707,7 @@ fn call_tool(name: &str, db: &Database, args: Value, _id: Option<Value>) -> Stri
16591707
"mimir_vault_import" => Ok(tools::handle_vault_import(db, args)),
16601708
"mimir_decay" => Ok(tools::handle_decay(db, args)),
16611709
"mimir_reindex" => Ok(tools::handle_reindex(db, args)),
1710+
"mimir_federate" => tools::handle_federate(db, args).map_err(|e| e.to_string()),
16621711
"mimir_workspace_list" => Ok(tools::handle_workspace_list(db)),
16631712
"mimir_recall_when" => tools::handle_recall_when(db, args).map_err(|e| e.to_string()),
16641713
"mimir_cohere" => tools::handle_cohere(db, args).map_err(|e| e.to_string()),

src/tools.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,8 @@ pub fn handle_context(db: &Database, args: Value) -> String {
738738
#[derive(Debug, Deserialize)]
739739
pub struct VaultExportArgs {
740740
pub vault_dir: String,
741+
#[serde(default)]
742+
pub workspace_hash: Option<String>,
741743
}
742744

743745
pub fn handle_vault_export(db: &Database, args: Value) -> String {
@@ -755,7 +757,7 @@ pub fn handle_vault_export(db: &Database, args: Value) -> String {
755757
} else {
756758
a.vault_dir.clone()
757759
};
758-
match db.vault_export(&dir) {
760+
match db.vault_export(&dir, a.workspace_hash.as_deref()) {
759761
Ok(report) => serde_json::to_string(&report).unwrap_or_else(|e| {
760762
json!({"error": format!("Serialization failed: {}", e)}).to_string()
761763
}),
@@ -933,6 +935,61 @@ pub fn handle_prune(db: &Database, args: Value) -> Result<String, String> {
933935
}
934936
}
935937

938+
pub fn handle_federate(db: &Database, args: Value) -> Result<String, String> {
939+
use serde::Deserialize;
940+
#[derive(Deserialize)]
941+
struct FederateArgs {
942+
from_workspace: String,
943+
to_workspace: String,
944+
#[serde(default)]
945+
vault_dir: String,
946+
}
947+
let a: FederateArgs = serde_json::from_value(args)
948+
.map_err(|e| format!("Invalid federate arguments: {}", e))?;
949+
950+
let vault_dir = if a.vault_dir.is_empty() {
951+
"/tmp/mimir-federate".to_string()
952+
} else {
953+
a.vault_dir
954+
};
955+
956+
// Export from source workspace
957+
let export_report = db.vault_export(&vault_dir, Some(&a.from_workspace))
958+
.map_err(|e| format!("Federate export failed: {}", e))?;
959+
960+
// Remap entities: overwrite workspace_hash to target
961+
let mut remapped = 0i64;
962+
for entry in std::fs::read_dir(&vault_dir).map_err(|e| format!("Read vault dir: {}", e))? {
963+
let entry = entry.map_err(|e| format!("Read entry: {}", e))?;
964+
let path = entry.path();
965+
if path.extension().and_then(|s| s.to_str()) != Some("md") {
966+
continue;
967+
}
968+
let content = std::fs::read_to_string(&path)
969+
.map_err(|e| format!("Read {}: {}", path.display(), e))?;
970+
let remapped_content =
971+
content.replace(&format!("workspace_hash: {}", a.from_workspace),
972+
&format!("workspace_hash: {}", a.to_workspace));
973+
if remapped_content != content {
974+
std::fs::write(&path, remapped_content)
975+
.map_err(|e| format!("Write {}: {}", path.display(), e))?;
976+
remapped += 1;
977+
}
978+
}
979+
980+
// Import into target workspace
981+
let import_report = db.vault_import(&vault_dir)
982+
.map_err(|e| format!("Federate import failed: {}", e))?;
983+
984+
let result = json!({
985+
"exported": export_report.files_created + export_report.files_updated,
986+
"remapped": remapped,
987+
"imported": import_report.files_created + import_report.files_updated,
988+
"import_errors": import_report.errors,
989+
});
990+
Ok(result.to_string())
991+
}
992+
936993
pub fn handle_workspace_list(db: &Database) -> String {
937994
match db.workspace_list_categories() {
938995
Ok(cats) => json!({"categories": cats, "total": cats.len()}).to_string(),

0 commit comments

Comments
 (0)