pub(crate) mod backend;
mod redact;
pub(crate) mod spool;
use std::io::Read;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::json;
use backend::Backend;
use redact::redact;
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum HookEvent {
UserPrompt,
Stop,
SessionStart,
PostToolUse,
PreToolUse,
PreCompact,
}
const ACTION_RULES_BUDGET_MS: u64 = 1500;
const USER_PROMPT_BUDGET_MS: u64 = 8_000;
fn user_prompt_budget() -> std::time::Duration {
let ms = std::env::var("MENTEDB_HOOK_RECALL_BUDGET_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|ms| *ms > 0)
.unwrap_or(USER_PROMPT_BUDGET_MS);
std::time::Duration::from_millis(ms)
}
const ACTION_RULES_MAX: usize = 6;
#[derive(Debug, Default, Serialize, Deserialize)]
struct SessionState {
turn_id: u64,
pending_prompt: Option<String>,
#[serde(default)]
last_user_message: Option<String>,
#[serde(default)]
injected: Vec<String>,
#[serde(default)]
last_injected: Vec<String>,
#[serde(default)]
recent_note_hashes: Vec<u64>,
}
fn strip_system_blocks(prompt: &str) -> String {
static SYSTEM_BLOCK: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(
r"(?s)<task-notification>.*?</task-notification>|<system-reminder>.*?</system-reminder>|<local-command-stdout>.*?</local-command-stdout>|<local-command-stderr>.*?</local-command-stderr>",
)
.expect("valid system-block regex")
});
SYSTEM_BLOCK.replace_all(prompt, "").trim().to_string()
}
fn state_path(data_dir: &Path, session_id: &str) -> PathBuf {
let safe: String = session_id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
let name = if safe.is_empty() {
"default".to_string()
} else {
safe
};
data_dir.join("hook_sessions").join(format!("{name}.json"))
}
fn load_state(data_dir: &Path, session_id: &str) -> SessionState {
std::fs::read_to_string(state_path(data_dir, session_id))
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn save_state(data_dir: &Path, session_id: &str, state: &SessionState) {
let path = state_path(data_dir, session_id);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Ok(raw) = serde_json::to_string(state) {
std::fs::write(path, raw).ok();
}
}
pub async fn run(event: HookEvent, data_dir: PathBuf, force_local: bool) -> anyhow::Result<()> {
if let Err(e) = run_inner(event, &data_dir, force_local).await {
tracing::warn!(event = ?event, error = %e, "hook failed, client unaffected");
}
Ok(())
}
fn is_duplicate_fire(data_dir: &Path, event: &HookEvent, raw: &str) -> bool {
use std::hash::{Hash, Hasher};
const WINDOW: std::time::Duration = std::time::Duration::from_secs(5);
let mut hasher = std::collections::hash_map::DefaultHasher::new();
raw.hash(&mut hasher);
let marker = data_dir
.join("hook_dedup")
.join(format!("{event:?}-{:016x}", hasher.finish()));
if let Ok(meta) = std::fs::metadata(&marker)
&& let Ok(modified) = meta.modified()
&& modified.elapsed().map(|age| age < WINDOW).unwrap_or(false)
{
return true;
}
if let Some(dir) = marker.parent() {
std::fs::create_dir_all(dir).ok();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let stale = entry
.metadata()
.and_then(|m| m.modified())
.map(|m| m.elapsed().map(|age| age > WINDOW * 12).unwrap_or(true))
.unwrap_or(true);
if stale {
std::fs::remove_file(entry.path()).ok();
}
}
}
}
std::fs::write(&marker, b"").ok();
false
}
async fn run_inner(event: HookEvent, data_dir: &Path, force_local: bool) -> anyhow::Result<()> {
let mut raw = String::new();
std::io::stdin().read_to_string(&mut raw).ok();
if is_duplicate_fire(data_dir, &event, &raw) {
return Ok(());
}
let payload: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|_| json!({}));
let session_id = payload
.get("session_id")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string();
match event {
HookEvent::UserPrompt => {
let prompt = payload
.get("prompt")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if prompt.is_empty() {
return Ok(());
}
let prompt = strip_system_blocks(&prompt);
if prompt.is_empty() {
return Ok(());
}
let prompt = redact(&prompt);
let mut state = load_state(data_dir, &session_id);
state.pending_prompt = Some(prompt.chars().take(8_000).collect());
save_state(data_dir, &session_id, &state);
let query = match state.last_user_message.as_deref() {
Some(last) if prompt.chars().count() < 200 => {
let last: String = last.chars().take(600).collect();
format!("{prompt}\n\n[previous turn] {last}")
}
_ => prompt.clone(),
};
let recall = async {
let backend = Backend::resolve(data_dir, force_local).await?;
let native = backend
.injection_context(&query, &session_id, &state.injected)
.await;
anyhow::Ok(match native {
Some(ctx) => {
record_auth_state(data_dir, None);
let ids: Vec<String> = ctx["memories"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
(Some(ctx), ids)
}
None => match backend.context(&query).await {
Ok(c) => {
record_auth_state(data_dir, None);
let (filtered, ids) =
filter_for_injection(&c, &state.injected, now_micros());
(Some(filtered), ids)
}
Err(e) => {
record_auth_state(data_dir, Some(&e));
tracing::warn!(error = %e, "context recall failed");
(None, Vec::new())
}
},
})
};
let (ctx, injected_ids) = match tokio::time::timeout(user_prompt_budget(), recall).await
{
Ok(Ok(found)) => found,
Ok(Err(e)) => {
tracing::warn!(error = %e, "context recall failed");
(None, Vec::new())
}
Err(_) => {
tracing::warn!(
budget_ms = user_prompt_budget().as_millis() as u64,
"context recall over budget, prompt sent without recalled context"
);
(None, Vec::new())
}
};
let mut text = String::new();
if let Some(ctx) = &ctx {
if !injected_ids.is_empty() {
let mut state = load_state(data_dir, &session_id);
state.injected.extend(injected_ids.clone());
let excess = state.injected.len().saturating_sub(300);
if excess > 0 {
state.injected.drain(..excess);
}
state.last_injected = injected_ids;
save_state(data_dir, &session_id, &state);
}
text = format_context(ctx).unwrap_or_default();
}
if let Some(notice) = auth_notice(data_dir) {
text = if text.is_empty() {
notice
} else {
format!("{notice}\n\n{text}")
};
}
if let Some(warning) = spool_notice(data_dir) {
text = if text.is_empty() {
warning
} else {
format!("{warning}\n\n{text}")
};
}
if !text.is_empty() {
println!(
"{}",
json!({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": text,
}
})
);
}
}
HookEvent::Stop => {
let assistant: String = payload
.get("last_assistant_message")
.and_then(|v| v.as_str())
.unwrap_or("")
.chars()
.take(2_000)
.collect();
let assistant = redact(&assistant);
let mut state = load_state(data_dir, &session_id);
let Some(user_message) = state.pending_prompt.take() else {
return Ok(());
};
state.turn_id += 1;
let turn_id = state.turn_id;
let store_turn = worth_storing_turn(&user_message);
if store_turn {
state.last_user_message = Some(user_message.chars().take(600).collect());
}
let outcome_ids = std::mem::take(&mut state.last_injected);
save_state(data_dir, &session_id, &state);
let project = payload
.get("cwd")
.and_then(|v| v.as_str())
.and_then(|c| Path::new(c).file_name())
.and_then(|n| n.to_str())
.map(str::to_string);
if store_turn {
spool::push(
data_dir,
&json!({
"kind": "turn",
"user_message": user_message,
"assistant_response": assistant.clone(),
"turn_id": turn_id,
"project": project,
"session_id": session_id,
}),
);
}
if !outcome_ids.is_empty() {
spool::push(
data_dir,
&json!({
"kind": "injection_outcome",
"shown_ids": outcome_ids,
"assistant_text": assistant,
}),
);
}
if let Ok(backend) = Backend::resolve(data_dir, force_local).await {
flush_spool(data_dir, &backend).await;
record_auth_state(data_dir, None);
}
}
HookEvent::SessionStart => {
let hooks_updated = if std::env::var("MENTEDB_HOOK_NO_SELF_UPDATE").is_err() {
crate::ensure_hooks_current(data_dir)
} else {
Vec::new()
};
let fetch = async {
match Backend::resolve(data_dir, force_local).await {
Ok(backend) => match backend.session_context().await {
Ok(c) => {
record_auth_state(data_dir, None);
Some(c)
}
Err(e) => {
record_auth_state(data_dir, Some(&e));
tracing::warn!(error = %e, "session context failed");
None
}
},
Err(e) => {
tracing::warn!(error = %e, "session backend resolve failed");
None
}
}
};
let ctx = tokio::time::timeout(user_prompt_budget(), fetch)
.await
.unwrap_or_else(|_| {
tracing::warn!("session context over budget, session starts without it");
None
});
let mut text = ctx
.as_ref()
.and_then(format_session_context)
.unwrap_or_default();
if let Some(notice) = auth_notice(data_dir) {
text = if text.is_empty() {
notice
} else {
format!("{notice}\n\n{text}")
};
}
if !hooks_updated.is_empty() {
let update_note = format!(
"MenteDB refreshed its Claude Code hooks to match the installed version ({} file{}); new hooks activate on the next session.",
hooks_updated.len(),
if hooks_updated.len() == 1 { "" } else { "s" }
);
text = if text.is_empty() {
update_note
} else {
format!("{update_note}\n\n{text}")
};
}
if !text.is_empty() {
println!("{text}");
}
}
HookEvent::PostToolUse => {
let Some(note) = summarize_tool_action(&payload) else {
return Ok(());
};
let note = redact(¬e);
let mut state = load_state(data_dir, &session_id);
if note_seen_recently(&mut state.recent_note_hashes, ¬e) {
return Ok(());
}
save_state(data_dir, &session_id, &state);
let project = payload
.get("cwd")
.and_then(|v| v.as_str())
.and_then(|c| Path::new(c).file_name())
.and_then(|n| n.to_str())
.map(str::to_string);
let store = async {
let backend = Backend::resolve(data_dir, force_local).await?;
flush_spool(data_dir, &backend).await;
backend.store_note(¬e, project.clone()).await
}
.await;
if let Err(e) = store {
tracing::warn!(error = %e, "store_note failed, spooling for retry");
spool::push(
data_dir,
&json!({ "kind": "note", "content": note, "project": project }),
);
}
}
HookEvent::PreToolUse => {
let tool = payload
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("");
if tool != "Bash" {
return Ok(());
}
let command = payload
.pointer("/tool_input/command")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if command.is_empty() {
return Ok(());
}
let fetch = async {
let backend = Backend::resolve(data_dir, force_local).await.ok()?;
Some(backend.action_rules(command, ACTION_RULES_MAX).await)
};
let rules = tokio::time::timeout(
std::time::Duration::from_millis(ACTION_RULES_BUDGET_MS),
fetch,
)
.await
.ok()
.flatten()
.unwrap_or_default();
if rules.is_empty() {
return Ok(());
}
let mut text = String::from(
"MenteDB rules that bear on what you are about to do (newest first; when two rules conflict, follow the newest):",
);
for r in &rules {
text.push_str("\n- ");
text.push_str(r);
}
println!(
"{}",
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": text,
}
})
);
}
HookEvent::PreCompact => {
let mut state = load_state(data_dir, &session_id);
if !state.injected.is_empty() {
state.injected.clear();
save_state(data_dir, &session_id, &state);
}
let backend = Backend::resolve(data_dir, force_local).await?;
backend.flush().await?;
}
}
Ok(())
}
fn record_auth_state(data_dir: &Path, err: Option<&anyhow::Error>) {
let path = data_dir.join("auth_error");
match err {
Some(e) if e.to_string().contains("authentication failed") => {
std::fs::write(&path, "revoked").ok();
}
Some(_) => {}
None => {
std::fs::remove_file(&path).ok();
}
}
}
fn auth_notice(data_dir: &Path) -> Option<String> {
data_dir.join("auth_error").exists().then(|| {
"MenteDB: the cloud session is no longer valid (token expired or revoked). \
Tell the user to run `npx mentedb-mcp@latest login` to reconnect. \
New memories are spooling locally and will sync automatically after login."
.to_string()
})
}
pub(crate) const SPOOL_WARN_THRESHOLD: usize = 10;
fn spool_notice(data_dir: &Path) -> Option<String> {
let queued = spool::depth(data_dir);
(queued >= SPOOL_WARN_THRESHOLD).then(|| {
format!(
"MenteDB sync warning: {queued} memories are queued locally and have not synced \
to the cloud, so recent turns from this session may not be remembered yet. The \
hook keeps retrying automatically; if this persists, check \
~/.mentedb/mentedb-hook.log."
)
})
}
async fn flush_spool(data_dir: &Path, backend: &Backend) {
let entries = spool::take_all(data_dir);
if entries.is_empty() {
return;
}
let batch = spool::batch_size(data_dir, NOTE_BATCH, NOTE_BATCH);
let (failed, next_batch) = flush_entries(entries, backend, batch).await;
if next_batch != batch {
tracing::info!(from = batch, to = next_batch, "spool batch size adjusted");
spool::set_batch_size(data_dir, next_batch);
}
if failed.is_empty() {
tracing::info!("offline spool fully flushed");
} else {
tracing::warn!(retained = failed.len(), "spool flush incomplete");
spool::restore(data_dir, &failed);
}
}
const MAX_FLUSH_CALLS: usize = 10;
const FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(6);
const NOTE_BATCH: usize = 50;
const NOTE_BATCH_TIMEOUT_MS: u64 = 8_000;
fn note_batch_timeout() -> std::time::Duration {
let ms = std::env::var("MENTEDB_HOOK_BATCH_TIMEOUT_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|ms| *ms > 0)
.unwrap_or(NOTE_BATCH_TIMEOUT_MS);
std::time::Duration::from_millis(ms)
}
fn shrink_batch(size: usize) -> usize {
(size / 2).max(1)
}
fn grow_batch(size: usize) -> usize {
(size + (size / 4).max(1)).min(NOTE_BATCH)
}
const SINGLE_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
async fn flush_entries(
entries: Vec<serde_json::Value>,
backend: &Backend,
mut batch_size: usize,
) -> (Vec<serde_json::Value>, usize) {
let start = std::time::Instant::now();
let mut calls = 0usize;
let mut rest = std::collections::VecDeque::from(entries);
while let Some(e) = rest.pop_front() {
if calls >= MAX_FLUSH_CALLS || start.elapsed() >= FLUSH_BUDGET {
rest.push_front(e);
break;
}
let kind = e.get("kind").and_then(|v| v.as_str()).unwrap_or("");
if kind == "note" {
let mut batch = vec![e];
while batch.len() < batch_size
&& rest
.front()
.and_then(|n| n.get("kind"))
.and_then(|k| k.as_str())
== Some("note")
{
batch.push(rest.pop_front().expect("front checked"));
}
let notes: Vec<(String, Option<String>)> = batch
.iter()
.filter_map(|n| {
let content = n.get("content").and_then(|v| v.as_str())?;
(!content.is_empty()).then(|| {
(
content.to_string(),
n.get("project")
.and_then(|v| v.as_str())
.map(str::to_string),
)
})
})
.collect();
if notes.is_empty() {
continue; }
calls += 1;
let sent =
tokio::time::timeout(note_batch_timeout(), backend.store_notes(¬es)).await;
match sent {
Ok(Ok(_)) => batch_size = grow_batch(batch_size),
Err(_) => {
batch.extend(rest);
return (batch, shrink_batch(batch_size));
}
Ok(Err(_)) => {
batch.extend(rest);
return (batch, batch_size);
}
}
continue;
}
calls += 1;
let ok = match kind {
"turn" => {
let user = e.get("user_message").and_then(|v| v.as_str()).unwrap_or("");
if user.is_empty() {
true } else {
tokio::time::timeout(
SINGLE_CALL_TIMEOUT,
backend.store_turn(
user,
e.get("assistant_response")
.and_then(|v| v.as_str())
.unwrap_or(""),
e.get("turn_id").and_then(|v| v.as_u64()).unwrap_or(1),
e.get("project")
.and_then(|v| v.as_str())
.map(str::to_string),
e.get("session_id").and_then(|v| v.as_str()).unwrap_or(""),
),
)
.await
.is_ok_and(|r| r.is_ok())
}
}
"injection_outcome" => {
let shown: Vec<String> = e
.get("shown_ids")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let text = e
.get("assistant_text")
.and_then(|v| v.as_str())
.unwrap_or("");
let _ = tokio::time::timeout(
SINGLE_CALL_TIMEOUT,
backend.record_injection_outcome(&shown, text),
)
.await;
true
}
_ => true,
};
if !ok {
rest.push_front(e);
break;
}
}
(rest.into_iter().collect(), batch_size)
}
const READ_ONLY_BASH: &[&str] = &[
"ls",
"cat",
"grep",
"rg",
"find",
"pwd",
"echo",
"which",
"head",
"tail",
"less",
"more",
"git status",
"git diff",
"git log",
"git show",
"git branch",
"cd",
"wc",
"tree",
"stat",
"env",
"printenv",
"date",
"whoami",
"ps",
"top",
"df",
"du",
];
const LOW_INFO_BASH: &[&str] = &[
"cargo fmt",
"cargo clippy",
"cargo check",
"cargo build",
"npm run lint",
"npm run build",
"npx prettier",
"prettier",
"npx eslint",
"eslint",
];
const NOTE_DEDUPE_WINDOW: usize = 8;
fn note_seen_recently(recent_hashes: &mut Vec<u64>, note: &str) -> bool {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
note.hash(&mut hasher);
let hash = hasher.finish();
if recent_hashes.contains(&hash) {
return true;
}
recent_hashes.push(hash);
let excess = recent_hashes.len().saturating_sub(NOTE_DEDUPE_WINDOW);
if excess > 0 {
recent_hashes.drain(..excess);
}
false
}
const MIN_STORED_PROMPT_CHARS: usize = 12;
fn worth_storing_turn(user_message: &str) -> bool {
user_message.chars().count() >= MIN_STORED_PROMPT_CHARS
}
fn summarize_tool_action(payload: &serde_json::Value) -> Option<String> {
let tool = payload.get("tool_name").and_then(|v| v.as_str())?;
let input = payload.get("tool_input");
let field = |k: &str| input.and_then(|i| i.get(k)).and_then(|v| v.as_str());
match tool {
"Write" | "Edit" | "MultiEdit" | "NotebookEdit" | "Update" => {
let path = field("file_path").or_else(|| field("notebook_path"))?;
Some(format!("Edited file: {path}"))
}
"Bash" => {
let cmd = field("command")?.trim();
if cmd.is_empty() {
return None;
}
let lower = cmd.to_lowercase();
if READ_ONLY_BASH.iter().any(|p| lower.starts_with(p)) {
return None;
}
if LOW_INFO_BASH.iter().any(|p| lower.starts_with(p)) {
return None;
}
let compact: String = cmd.chars().take(200).collect();
Some(format!("Ran command: {compact}"))
}
_ => None,
}
}
const CONTEXT_CHAR_BUDGET: usize = 4_000;
const FRESH_TURN_HORIZON_US: u64 = 12 * 3600 * 1_000_000;
const RELATIVE_SCORE_FLOOR: f64 = 0.4;
const MAX_EPISODIC_INJECTED: usize = 2;
const MAX_INJECTED: usize = 6;
const NEAR_DUP_JACCARD: f64 = 0.7;
fn type_rank(memory_type: &str) -> usize {
match memory_type.to_lowercase().as_str() {
"anti_pattern" | "antipattern" => 0,
"correction" => 1,
"semantic" => 2,
"procedural" => 3,
"reasoning" => 4,
_ => 5, }
}
fn word_set(text: &str) -> std::collections::HashSet<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() > 2)
.map(str::to_string)
.collect()
}
fn jaccard(a: &std::collections::HashSet<String>, b: &std::collections::HashSet<String>) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let inter = a.intersection(b).count() as f64;
let union = (a.len() + b.len()) as f64 - inter;
inter / union
}
fn has_tag(m: &serde_json::Value, tag: &str) -> bool {
m.get("tags")
.and_then(|v| v.as_array())
.is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some(tag)))
}
fn filter_for_injection(
ctx: &serde_json::Value,
already_injected: &[String],
now_us: u64,
) -> (serde_json::Value, Vec<String>) {
let Some(memories) = ctx.get("memories").and_then(|v| v.as_array()) else {
return (ctx.clone(), Vec::new());
};
let top_score = memories
.iter()
.filter_map(|m| m.get("score").and_then(|s| s.as_f64()))
.fold(f64::NEG_INFINITY, f64::max);
let not_yet_injected = |m: &serde_json::Value| {
let id = m.get("id").and_then(|v| v.as_str()).unwrap_or("");
!id.is_empty() && !already_injected.iter().any(|i| i == id)
};
let pinned: Vec<&serde_json::Value> = memories
.iter()
.filter(|m| has_tag(m, "scope:always") && not_yet_injected(m))
.collect();
let mut candidates: Vec<&serde_json::Value> = memories
.iter()
.filter(|m| !has_tag(m, "scope:always") && not_yet_injected(m))
.filter(|m| {
if has_tag(m, "action") {
return false;
}
if has_tag(m, "turn") {
let created = m
.get("created_at")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<u64>().ok());
if let Some(created) = created
&& now_us.saturating_sub(created) < FRESH_TURN_HORIZON_US
{
return false;
}
}
true
})
.filter(|m| {
match (
m.get("score").and_then(|s| s.as_f64()),
top_score.is_finite(),
) {
(Some(s), true) => s >= top_score * RELATIVE_SCORE_FLOOR,
_ => true, }
})
.collect();
candidates
.sort_by_key(|m| type_rank(m.get("memory_type").and_then(|v| v.as_str()).unwrap_or("")));
let mut selected: Vec<&serde_json::Value> = pinned;
let mut selected_words: Vec<std::collections::HashSet<String>> = Vec::new();
let mut episodic_count = 0usize;
let budget = MAX_INJECTED + selected.len();
for m in candidates {
if selected.len() >= budget {
break;
}
let mtype = m.get("memory_type").and_then(|v| v.as_str()).unwrap_or("");
let is_episodic = mtype.eq_ignore_ascii_case("episodic");
if is_episodic && episodic_count >= MAX_EPISODIC_INJECTED {
continue;
}
let content = m.get("content").and_then(|v| v.as_str()).unwrap_or("");
let words = word_set(content);
if selected_words
.iter()
.any(|w| jaccard(w, &words) > NEAR_DUP_JACCARD)
{
continue;
}
if is_episodic {
episodic_count += 1;
}
selected_words.push(words);
selected.push(m);
}
let ids: Vec<String> = selected
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
.collect();
let mut filtered = ctx.clone();
filtered["memories"] = serde_json::json!(selected);
(filtered, ids)
}
fn now_micros() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_micros() as u64
}
fn provenance_suffix(m: &serde_json::Value) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(project) = m.get("project").and_then(|v| v.as_str())
&& !project.is_empty()
{
parts.push(project.to_string());
}
if let Some(reason) = m.get("reason").and_then(|v| v.as_str())
&& !reason.is_empty()
{
parts.push(reason.to_string());
}
if let Some(score) = m.get("score").and_then(|v| v.as_f64()) {
parts.push(format!("{score:.2}"));
}
if parts.is_empty() {
String::new()
} else {
format!(" [{}]", parts.join(", "))
}
}
fn format_context(ctx: &serde_json::Value) -> Option<String> {
let memories = ctx.get("memories").and_then(|v| v.as_array());
let pains = ctx.get("pain").and_then(|v| v.as_array());
let mut out = String::new();
if let Some(memories) = memories {
for m in memories {
let content = m.get("content").and_then(|v| v.as_str()).unwrap_or("");
if content.is_empty() {
continue;
}
let mtype = m
.get("memory_type")
.and_then(|v| v.as_str())
.unwrap_or("memory");
let line = format!(
"- [{}] {}{}\n",
mtype.to_lowercase(),
content,
provenance_suffix(m)
);
if out.len() + line.len() > CONTEXT_CHAR_BUDGET {
break;
}
out.push_str(&line);
}
}
let mut warnings = String::new();
if let Some(pains) = pains {
for p in pains {
let desc = p.get("description").and_then(|v| v.as_str()).unwrap_or("");
if desc.is_empty() {
continue;
}
let line = format!("- WARNING (past failure): {desc}\n");
if out.len() + warnings.len() + line.len() > CONTEXT_CHAR_BUDGET + 800 {
break;
}
warnings.push_str(&line);
}
}
let limit_notice = ctx.get("limit_notice").and_then(|v| v.as_str());
if out.is_empty() && warnings.is_empty() && limit_notice.is_none() {
return None;
}
let mut text = String::new();
if let Some(notice) = limit_notice {
text.push_str(&format!(
"[MenteDB] {notice} Tell the user if they ask about memory.\n\n"
));
}
text.push_str("Relevant memories from MenteDB (persistent memory):\n");
text.push_str(&out);
if !warnings.is_empty() {
text.push_str(&warnings);
}
Some(text)
}
fn format_session_context(ctx: &serde_json::Value) -> Option<String> {
let profile = ctx.get("profile").and_then(|v| v.as_str()).unwrap_or("");
let always: Vec<&str> = ctx
.get("always")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let update_msg = ctx
.get("client_update")
.and_then(|u| u.get("message"))
.and_then(|v| v.as_str());
if profile.is_empty() && always.is_empty() && update_msg.is_none() {
return None;
}
let mut text = String::new();
if let Some(msg) = update_msg {
text.push_str(&format!("[MenteDB] {msg}\n\n"));
}
text.push_str("MenteDB persistent memory for this user:\n");
if !profile.is_empty() {
text.push_str("## User profile\n");
text.push_str(profile);
text.push('\n');
}
if !always.is_empty() {
text.push_str("## Standing rules (always apply)\n");
for a in always.iter().take(30) {
text.push_str(&format!("- {a}\n"));
}
}
Some(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_context_renders_memories_and_pain() {
let ctx = json!({
"memories": [
{ "content": "User prefers Rust", "memory_type": "Semantic", "scope": "contextual" },
],
"pain": [ { "description": "deploy without tests broke prod", "intensity": 0.9 } ],
});
let text = format_context(&ctx).unwrap();
assert!(text.contains("[semantic] User prefers Rust"));
assert!(text.contains("WARNING (past failure): deploy without tests broke prod"));
}
#[test]
fn batch_size_halves_on_timeout_and_grows_back_slowly() {
assert_eq!(shrink_batch(50), 25);
assert_eq!(shrink_batch(1), 1, "never below one");
assert_eq!(grow_batch(25), 31);
assert_eq!(grow_batch(1), 2);
assert_eq!(grow_batch(48), NOTE_BATCH, "never above the cap");
}
#[test]
fn format_context_empty_returns_none() {
assert!(format_context(&json!({ "memories": [], "pain": [] })).is_none());
assert!(format_context(&json!({})).is_none());
}
#[test]
fn format_context_respects_budget() {
let big = "x".repeat(1_000);
let memories: Vec<serde_json::Value> = (0..20)
.map(|_| json!({ "content": big, "memory_type": "Semantic" }))
.collect();
let text = format_context(&json!({ "memories": memories })).unwrap();
assert!(text.len() < CONTEXT_CHAR_BUDGET + 1_200);
}
#[test]
fn session_state_roundtrip_and_turn_counter() {
let dir = tempfile::tempdir().unwrap();
let mut state = load_state(dir.path(), "abc-123");
assert_eq!(state.turn_id, 0);
state.turn_id += 1;
state.pending_prompt = Some("hello".into());
save_state(dir.path(), "abc-123", &state);
let loaded = load_state(dir.path(), "abc-123");
assert_eq!(loaded.turn_id, 1);
assert_eq!(loaded.pending_prompt.as_deref(), Some("hello"));
}
#[test]
fn state_path_sanitizes_session_id() {
let dir = tempfile::tempdir().unwrap();
let path = state_path(dir.path(), "../../etc/passwd");
assert!(path.starts_with(dir.path().join("hook_sessions")));
assert!(!path.to_string_lossy().contains(".."));
}
#[test]
fn summarize_tool_action_captures_edits_and_commands() {
let edit = json!({
"tool_name": "Edit",
"tool_input": { "file_path": "src/main.rs" },
});
assert_eq!(
summarize_tool_action(&edit).as_deref(),
Some("Edited file: src/main.rs")
);
let bash = json!({
"tool_name": "Bash",
"tool_input": { "command": "cargo test --workspace" },
});
assert_eq!(
summarize_tool_action(&bash).as_deref(),
Some("Ran command: cargo test --workspace")
);
}
#[test]
fn format_context_appends_show_why_suffix() {
let ctx = json!({ "memories": [
{
"content": "User prefers Rust",
"memory_type": "Semantic",
"project": "apex",
"reason": "pinned",
"score": 0.8234,
},
{ "content": "plain memory", "memory_type": "Semantic" },
]});
let text = format_context(&ctx).unwrap();
assert!(text.contains("- [semantic] User prefers Rust [apex, pinned, 0.82]"));
assert!(text.contains("- [semantic] plain memory\n"));
}
#[test]
fn provenance_suffix_renders_partial_fields() {
assert_eq!(provenance_suffix(&json!({})), "");
assert_eq!(provenance_suffix(&json!({ "project": "apex" })), " [apex]");
assert_eq!(
provenance_suffix(&json!({ "reason": "pinned" })),
" [pinned]"
);
assert_eq!(
provenance_suffix(&json!({ "project": "apex", "score": 0.5 })),
" [apex, 0.50]"
);
assert_eq!(
provenance_suffix(&json!({ "project": "", "score": "hi" })),
""
);
}
#[test]
fn note_dedupe_window_skips_repeats_within_window() {
let mut recent = Vec::new();
assert!(!note_seen_recently(&mut recent, "Edited file: a.rs"));
assert!(!note_seen_recently(&mut recent, "Ran command: cargo test"));
assert!(note_seen_recently(&mut recent, "Edited file: a.rs"));
for i in 0..NOTE_DEDUPE_WINDOW {
assert!(!note_seen_recently(&mut recent, &format!("note {i}")));
}
assert!(recent.len() <= NOTE_DEDUPE_WINDOW);
assert!(!note_seen_recently(&mut recent, "Edited file: a.rs"));
}
#[test]
fn summarize_tool_action_skips_low_information_commands() {
for cmd in [
"cargo fmt --all",
"cargo clippy -- -D warnings",
"cargo build --release",
"npm run lint",
"npx prettier --write .",
"prettier --check src",
"eslint src/",
] {
let p = json!({ "tool_name": "Bash", "tool_input": { "command": cmd } });
assert!(summarize_tool_action(&p).is_none(), "should skip: {cmd}");
}
for cmd in [
"cargo test --workspace",
"npm install left-pad",
"git commit -m x",
] {
let p = json!({ "tool_name": "Bash", "tool_input": { "command": cmd } });
assert!(summarize_tool_action(&p).is_some(), "should keep: {cmd}");
}
}
#[test]
fn short_prompts_are_not_worth_storing() {
for p in ["", "ok", "yes", "do it", "lgtm"] {
assert!(!worth_storing_turn(p), "should skip: {p:?}");
}
for p in [
"fix the bug!",
"fix the login bug",
"why does the daemon restart",
] {
assert!(worth_storing_turn(p), "should keep: {p:?}");
}
}
#[test]
fn summarize_tool_action_skips_reads_and_unknown() {
for cmd in [
"ls -la",
"git status",
"cat foo",
"grep x .",
"pwd",
"cd /tmp",
] {
let p = json!({ "tool_name": "Bash", "tool_input": { "command": cmd } });
assert!(summarize_tool_action(&p).is_none(), "should skip: {cmd}");
}
for tool in ["Read", "Grep", "Glob", "WebFetch"] {
let p = json!({ "tool_name": tool, "tool_input": {} });
assert!(summarize_tool_action(&p).is_none(), "should skip: {tool}");
}
assert!(summarize_tool_action(&json!({})).is_none());
assert!(summarize_tool_action(&json!({ "tool_name": "Bash" })).is_none());
}
fn mem(
id: &str,
mtype: &str,
content: &str,
tags: &[&str],
age_us: u64,
score: f64,
) -> serde_json::Value {
let created = 2_000_000_000_000_000u64 - age_us;
json!({
"id": id,
"memory_type": mtype,
"content": content,
"tags": tags,
"created_at": created.to_string(),
"score": score,
})
}
const NOW: u64 = 2_000_000_000_000_000;
#[test]
fn injection_skips_already_injected_and_actions() {
let ctx = json!({ "memories": [
mem("a", "semantic", "user prefers rust", &[], 100, 1.0),
mem("b", "episodic", "Edited file: src/main.rs", &["action"], 100, 0.9),
mem("c", "semantic", "deploys use v tags", &[], 100, 0.9),
], "pain": [] });
let (filtered, ids) = filter_for_injection(&ctx, &["a".to_string()], NOW);
assert_eq!(ids, vec!["c".to_string()]);
assert_eq!(filtered["memories"].as_array().unwrap().len(), 1);
}
#[test]
fn injection_drops_fresh_turns_keeps_old_ones() {
let hour = 3_600_000_000u64;
let ctx = json!({ "memories": [
mem("fresh", "episodic", "User: hi there Assistant: hello friend", &["turn"], hour, 1.0),
mem("old", "episodic", "User: deploy plan? Assistant: use tags", &["turn"], 48 * hour, 0.9),
], "pain": [] });
let (_, ids) = filter_for_injection(&ctx, &[], NOW);
assert_eq!(ids, vec!["old".to_string()]);
}
#[test]
fn injection_applies_relative_score_floor_and_caps() {
let mut memories: Vec<serde_json::Value> = vec![mem(
"weak",
"semantic",
"barely related trivia entry",
&[],
100,
0.1,
)];
let facts = [
"prefers rust for backend services",
"uses postgres with logical replication",
"deploys ride fargate behind cloudfront",
"billing runs through stripe webhooks",
"embeddings come from titan on bedrock",
"frontend built with vite and tailwind",
"auth handled by cognito user pools",
"monitoring lives in cloudwatch dashboards",
"queue processing goes through sqs lambda",
"dns zones managed inside route fiftythree",
];
for (i, fact) in facts.iter().enumerate() {
memories.push(mem(&format!("s{i}"), "semantic", fact, &[], 100, 1.0));
}
let ctx = json!({ "memories": memories, "pain": [] });
let (_, ids) = filter_for_injection(&ctx, &[], NOW);
assert!(
!ids.contains(&"weak".to_string()),
"sub-floor item injected"
);
assert_eq!(ids.len(), MAX_INJECTED);
}
#[test]
fn injection_prefers_semantic_and_dedups_near_duplicates() {
let ctx = json!({ "memories": [
mem("turn1", "episodic", "User: remember the deploy uses v tags on the platform repo", &["turn"], 100_000_000_000, 1.0),
mem("fact1", "semantic", "the deploy uses v tags on the platform repo", &[], 100, 0.95),
], "pain": [] });
let (_, ids) = filter_for_injection(&ctx, &[], NOW);
assert_eq!(ids, vec!["fact1".to_string()]);
}
#[test]
fn pinned_always_memories_bypass_all_quality_filters() {
let hour = 3_600_000_000u64;
let ctx = json!({ "memories": [
mem("pin", "episodic", "never deploy on fridays", &["scope:always", "turn"], hour, 0.01),
mem("top", "semantic", "prefers rust services", &[], 100, 1.0),
], "pain": [] });
let (_, ids) = filter_for_injection(&ctx, &[], NOW);
assert!(
ids.contains(&"pin".to_string()),
"pinned memory must inject"
);
assert!(ids.contains(&"top".to_string()));
let (_, ids2) = filter_for_injection(&ctx, &ids, NOW);
assert!(ids2.is_empty());
}
#[test]
fn format_session_context_renders_profile_and_rules() {
let ctx = json!({
"profile": "Works on trading systems",
"always": ["never commit secrets"],
});
let text = format_session_context(&ctx).unwrap();
assert!(text.contains("Works on trading systems"));
assert!(text.contains("never commit secrets"));
assert!(format_session_context(&json!({})).is_none());
}
#[test]
fn strips_system_noise_and_keeps_real_prompts() {
assert_eq!(
strip_system_blocks(
"<task-notification>\n<task-id>abc</task-id>\ndone\n</task-notification>"
),
""
);
let mixed = "fix the login bug\n<system-reminder>be concise</system-reminder>";
assert_eq!(strip_system_blocks(mixed), "fix the login bug");
assert_eq!(
strip_system_blocks("just a normal question"),
"just a normal question"
);
}
}