Skip to content

Commit 161900b

Browse files
committed
perf: fix N+1 queries in memoir operations and dedup inject_claude_hook
- Add batch_memoir_concept_counts() for memoir_list (1 query vs N+1) - Add get_links_for_memoir() for memoir_export (1 query vs N per concept) - Fix memoir_stats label counting to use SQL instead of loading all concepts - Merge inject_claude_hook and inject_claude_pretool_hook into single function
1 parent b752f1d commit 161900b

4 files changed

Lines changed: 110 additions & 94 deletions

File tree

crates/icm-cli/src/main.rs

Lines changed: 45 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1788,23 +1788,46 @@ icm store -t \"topic\" -c \"summary\"
17881788

17891789
// PreToolUse hook: `icm hook pre` (auto-allow icm commands)
17901790
let pre_cmd = format!("{} hook pre", icm_bin_str);
1791-
let pre_status = inject_claude_pretool_hook(&claude_settings_path, &pre_cmd)?;
1791+
let pre_status = inject_claude_hook(
1792+
&claude_settings_path,
1793+
"PreToolUse",
1794+
&pre_cmd,
1795+
Some("Bash"),
1796+
&["icm-pretool", "icm hook pre"],
1797+
)?;
17921798
println!("[hook] Claude Code PreToolUse (auto-allow): {pre_status}");
17931799

17941800
// PostToolUse hook: `icm hook post` (auto-extract context)
17951801
let post_cmd = format!("{} hook post", icm_bin_str);
1796-
let post_status = inject_claude_hook(&claude_settings_path, "PostToolUse", &post_cmd)?;
1802+
let post_status = inject_claude_hook(
1803+
&claude_settings_path,
1804+
"PostToolUse",
1805+
&post_cmd,
1806+
None,
1807+
&["icm hook", "icm-post-tool"],
1808+
)?;
17971809
println!("[hook] Claude Code PostToolUse (auto-extract): {post_status}");
17981810

17991811
// PreCompact hook: `icm hook compact` (extract from transcript before compression)
18001812
let compact_cmd = format!("{} hook compact", icm_bin_str);
1801-
let compact_status = inject_claude_hook(&claude_settings_path, "PreCompact", &compact_cmd)?;
1813+
let compact_status = inject_claude_hook(
1814+
&claude_settings_path,
1815+
"PreCompact",
1816+
&compact_cmd,
1817+
None,
1818+
&["icm hook", "icm-post-tool"],
1819+
)?;
18021820
println!("[hook] Claude Code PreCompact (transcript extract): {compact_status}");
18031821

18041822
// UserPromptSubmit hook: `icm hook prompt` (recall context on each prompt)
18051823
let prompt_cmd = format!("{} hook prompt", icm_bin_str);
1806-
let prompt_status =
1807-
inject_claude_hook(&claude_settings_path, "UserPromptSubmit", &prompt_cmd)?;
1824+
let prompt_status = inject_claude_hook(
1825+
&claude_settings_path,
1826+
"UserPromptSubmit",
1827+
&prompt_cmd,
1828+
None,
1829+
&["icm hook", "icm-post-tool"],
1830+
)?;
18081831
println!("[hook] Claude Code UserPromptSubmit (auto-recall): {prompt_status}");
18091832

18101833
// OpenCode plugin: install JS plugin for tool.execute.after + session.compacting
@@ -1859,10 +1882,14 @@ fn inject_icm_block(path: &PathBuf, block: &str) -> Result<String> {
18591882
}
18601883

18611884
/// Inject ICM hook into Claude Code settings.json for a given event name.
1885+
/// `matcher` is optional — if set (e.g. "Bash"), adds a matcher field to the hook entry.
1886+
/// `detect_patterns` lists substrings to detect if the hook is already present.
18621887
fn inject_claude_hook(
18631888
settings_path: &PathBuf,
18641889
event_name: &str,
18651890
hook_command: &str,
1891+
matcher: Option<&str>,
1892+
detect_patterns: &[&str],
18661893
) -> Result<String> {
18671894
let mut config: Value = if settings_path.exists() {
18681895
let content = std::fs::read_to_string(settings_path)
@@ -1898,7 +1925,7 @@ fn inject_claude_hook(
18981925
hooks.iter().any(|h| {
18991926
h.get("command")
19001927
.and_then(|c| c.as_str())
1901-
.map(|c| c.contains("icm hook") || c.contains("icm-post-tool"))
1928+
.map(|c| detect_patterns.iter().any(|p| c.contains(p)))
19021929
.unwrap_or(false)
19031930
})
19041931
})
@@ -1910,76 +1937,19 @@ fn inject_claude_hook(
19101937
}
19111938

19121939
// Add ICM hook entry
1913-
event_arr.push(serde_json::json!({
1940+
let mut entry = serde_json::json!({
19141941
"hooks": [{
19151942
"type": "command",
19161943
"command": hook_command
19171944
}]
1918-
}));
1919-
1920-
let output = serde_json::to_string_pretty(&config)?;
1921-
std::fs::write(settings_path, output)
1922-
.with_context(|| format!("cannot write {}", settings_path.display()))?;
1923-
1924-
Ok("configured".into())
1925-
}
1926-
1927-
/// Inject ICM PreToolUse hook into Claude Code settings.json
1928-
/// This hook auto-allows `icm` CLI commands (no permission prompt).
1929-
fn inject_claude_pretool_hook(settings_path: &PathBuf, hook_command: &str) -> Result<String> {
1930-
let mut config: Value = if settings_path.exists() {
1931-
let content = std::fs::read_to_string(settings_path)
1932-
.with_context(|| format!("cannot read {}", settings_path.display()))?;
1933-
serde_json::from_str(&content)
1934-
.with_context(|| format!("cannot parse {}", settings_path.display()))?
1935-
} else {
1936-
serde_json::json!({})
1937-
};
1938-
1939-
let hooks = config
1940-
.as_object_mut()
1941-
.context("settings is not a JSON object")?
1942-
.entry("hooks")
1943-
.or_insert_with(|| serde_json::json!({}));
1944-
1945-
let pre_tool = hooks
1946-
.as_object_mut()
1947-
.context("hooks is not a JSON object")?
1948-
.entry("PreToolUse")
1949-
.or_insert_with(|| serde_json::json!([]));
1950-
1951-
let pre_tool_arr = pre_tool
1952-
.as_array_mut()
1953-
.context("PreToolUse is not an array")?;
1954-
1955-
// Check if ICM pretool hook already exists
1956-
let already = pre_tool_arr.iter().any(|entry| {
1957-
entry
1958-
.get("hooks")
1959-
.and_then(|h| h.as_array())
1960-
.map(|hooks| {
1961-
hooks.iter().any(|h| {
1962-
h.get("command")
1963-
.and_then(|c| c.as_str())
1964-
.map(|c| c.contains("icm-pretool") || c.contains("icm hook pre"))
1965-
.unwrap_or(false)
1966-
})
1967-
})
1968-
.unwrap_or(false)
19691945
});
1970-
1971-
if already {
1972-
return Ok("already configured".into());
1946+
if let Some(m) = matcher {
1947+
entry
1948+
.as_object_mut()
1949+
.unwrap()
1950+
.insert("matcher".into(), serde_json::json!(m));
19731951
}
1974-
1975-
// Add ICM PreToolUse hook entry (matcher: Bash — auto-allow icm commands)
1976-
pre_tool_arr.push(serde_json::json!({
1977-
"matcher": "Bash",
1978-
"hooks": [{
1979-
"type": "command",
1980-
"command": hook_command
1981-
}]
1982-
}));
1952+
event_arr.push(entry);
19831953

19841954
let output = serde_json::to_string_pretty(&config)?;
19851955
std::fs::write(settings_path, output)
@@ -3502,14 +3472,15 @@ fn cmd_memoir_list(store: &SqliteStore) -> Result<()> {
35023472
return Ok(());
35033473
}
35043474

3475+
let counts = store.batch_memoir_concept_counts().unwrap_or_default();
35053476
println!("{:<25} {:<8} Description", "Name", "Concepts");
35063477
println!("{}", "-".repeat(60));
35073478
for m in &memoirs {
3508-
let stats = store.memoir_stats(&m.id)?;
3479+
let concept_count = counts.get(&m.id).copied().unwrap_or(0);
35093480
println!(
35103481
"{:<25} {:<8} {}",
35113482
m.name,
3512-
stats.total_concepts,
3483+
concept_count,
35133484
truncate(&m.description, 40)
35143485
);
35153486
}
@@ -3746,11 +3717,8 @@ fn cmd_memoir_export(store: &SqliteStore, memoir_name: &str, format: &str) -> Re
37463717
let memoir = resolve_memoir(store, memoir_name)?;
37473718
let concepts = store.list_concepts(&memoir.id)?;
37483719

3749-
// Collect all outgoing links
3750-
let mut links = Vec::new();
3751-
for c in &concepts {
3752-
links.extend(store.get_links_from(&c.id)?);
3753-
}
3720+
// Batch load all links for this memoir (single query)
3721+
let links = store.get_links_for_memoir(&memoir.id)?;
37543722

37553723
// Name lookup for links
37563724
let id_to_name: std::collections::HashMap<&str, &str> = concepts

crates/icm-core/src/memoir_store.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ pub trait MemoirStore {
5959
depth: usize,
6060
) -> IcmResult<(Vec<Concept>, Vec<ConceptLink>)>;
6161

62+
/// Get all links for concepts belonging to a memoir (batch, avoids N+1).
63+
fn get_links_for_memoir(&self, memoir_id: &str) -> IcmResult<Vec<ConceptLink>>;
64+
6265
// --- Stats ---
6366
fn memoir_stats(&self, memoir_id: &str) -> IcmResult<MemoirStats>;
67+
68+
/// Get concept counts for all memoirs in a single query.
69+
fn batch_memoir_concept_counts(&self) -> IcmResult<std::collections::HashMap<String, usize>>;
6470
}

crates/icm-mcp/src/tools.rs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,10 +1147,10 @@ fn tool_memoir_list(store: &SqliteStore) -> ToolResult {
11471147
return ToolResult::text("No memoirs yet.".into());
11481148
}
11491149

1150+
let counts = store.batch_memoir_concept_counts().unwrap_or_default();
11501151
let mut output = String::from("Memoirs:\n");
11511152
for m in &memoirs {
1152-
let stats = store.memoir_stats(&m.id).ok();
1153-
let concept_count = stats.map(|s| s.total_concepts).unwrap_or(0);
1153+
let concept_count = counts.get(&m.id).copied().unwrap_or(0);
11541154
output.push_str(&format!(
11551155
" {} ({} concepts) — {}\n",
11561156
m.name, concept_count, m.description
@@ -1515,14 +1515,11 @@ fn tool_memoir_export(store: &SqliteStore, args: &Value) -> ToolResult {
15151515
Err(e) => return ToolResult::error(format!("db error: {e}")),
15161516
};
15171517

1518-
// Collect all outgoing links
1519-
let mut links = Vec::new();
1520-
for c in &concepts {
1521-
match store.get_links_from(&c.id) {
1522-
Ok(l) => links.extend(l),
1523-
Err(e) => return ToolResult::error(format!("db error: {e}")),
1524-
}
1525-
}
1518+
// Batch load all links for this memoir (single query)
1519+
let links = match store.get_links_for_memoir(&memoir.id) {
1520+
Ok(l) => l,
1521+
Err(e) => return ToolResult::error(format!("db error: {e}")),
1522+
};
15261523

15271524
let id_to_name: std::collections::HashMap<&str, &str> = concepts
15281525
.iter()

crates/icm-store/src/store.rs

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,13 +1416,21 @@ impl MemoirStore for SqliteStore {
14161416
0.0
14171417
};
14181418

1419-
// Count distinct label namespace:value pairs
1420-
let concepts = self.list_concepts(memoir_id)?;
1421-
let mut label_map: std::collections::HashMap<String, usize> =
1422-
std::collections::HashMap::new();
1423-
for c in &concepts {
1424-
for l in &c.labels {
1425-
*label_map.entry(l.to_string()).or_insert(0) += 1;
1419+
// Count labels via SQL — avoids loading all concepts into memory
1420+
let mut label_stmt = self
1421+
.conn
1422+
.prepare("SELECT labels FROM concepts WHERE memoir_id = ?1 AND labels != '[]'")
1423+
.map_err(db_err)?;
1424+
let label_rows = label_stmt
1425+
.query_map(params![memoir_id], |row| row.get::<_, String>(0))
1426+
.map_err(db_err)?;
1427+
let mut label_map: HashMap<String, usize> = HashMap::new();
1428+
for row in label_rows {
1429+
let raw = row.map_err(db_err)?;
1430+
if let Ok(labels) = serde_json::from_str::<Vec<Label>>(&raw) {
1431+
for l in labels {
1432+
*label_map.entry(l.to_string()).or_insert(0) += 1;
1433+
}
14261434
}
14271435
}
14281436
let mut label_counts: Vec<(String, usize)> = label_map.into_iter().collect();
@@ -1435,6 +1443,43 @@ impl MemoirStore for SqliteStore {
14351443
label_counts,
14361444
})
14371445
}
1446+
1447+
fn get_links_for_memoir(&self, memoir_id: &str) -> IcmResult<Vec<ConceptLink>> {
1448+
let mut stmt = self
1449+
.conn
1450+
.prepare(&format!(
1451+
"SELECT {LINK_COLS} FROM concept_links
1452+
WHERE source_id IN (SELECT id FROM concepts WHERE memoir_id = ?1)
1453+
LIMIT 5000"
1454+
))
1455+
.map_err(db_err)?;
1456+
1457+
let rows = stmt
1458+
.query_map(params![memoir_id], row_to_link)
1459+
.map_err(db_err)?;
1460+
1461+
collect_rows(rows)
1462+
}
1463+
1464+
fn batch_memoir_concept_counts(&self) -> IcmResult<HashMap<String, usize>> {
1465+
let mut stmt = self
1466+
.conn
1467+
.prepare("SELECT memoir_id, COUNT(*) FROM concepts GROUP BY memoir_id")
1468+
.map_err(db_err)?;
1469+
1470+
let rows = stmt
1471+
.query_map([], |row| {
1472+
Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
1473+
})
1474+
.map_err(db_err)?;
1475+
1476+
let mut map = HashMap::new();
1477+
for row in rows {
1478+
let (id, count) = row.map_err(db_err)?;
1479+
map.insert(id, count);
1480+
}
1481+
Ok(map)
1482+
}
14381483
}
14391484

14401485
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)