use anyhow::Result;
use crate::cli::daemon::{daemon_result, mati_root_for, DaemonResult};
async fn hook_fire_v2(cmd: mati_core::mcp::protocol::Command) -> Result<()> {
let kind = cmd.kind();
let cwd = std::env::current_dir()?;
let root = mati_root_for(&cwd)?;
match super::daemon::daemon_v2(&root, cmd).await {
DaemonResult::Ok(_) => {}
_ => tracing::debug!("mati {kind}: daemon unreachable — dropping event"),
}
Ok(())
}
async fn hook_query_bool(cmd: &str, args: serde_json::Value) -> Result<()> {
let cwd = std::env::current_dir()?;
let root = mati_root_for(&cwd)?;
match daemon_result(&root, cmd, args).await {
DaemonResult::Ok(resp) => {
if resp.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
let value = resp.get("data").and_then(|v| v.as_bool()).unwrap_or(false);
println!("{value}");
} else {
tracing::debug!("mati {cmd}: daemon error — consultation state unknown");
println!("unknown");
}
}
_ => {
tracing::debug!("mati {cmd}: daemon unreachable — false");
println!("false");
}
}
Ok(())
}
pub async fn run_get(key: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
let root = mati_root_for(&cwd)?;
match daemon_result(&root, "get", serde_json::json!({ "key": key })).await {
DaemonResult::Ok(resp) => {
let json = match resp.get("data") {
Some(d) if d.is_null() => "null".to_string(),
Some(d) => d.to_string(),
None => "null".to_string(),
};
println!("{json}");
}
_ => {
tracing::debug!("mati get: daemon unreachable — fail-open (null)");
println!("null");
}
}
Ok(())
}
pub async fn run_log_miss(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::Miss,
key: key.to_string(),
session_id: None,
}))
.await
}
pub async fn run_log_hit(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::ConsultationHit(p::ConsultationHitInput {
key: key.to_string(),
actor: None,
session_id: None,
agent_id: None,
}))
.await
}
pub async fn run_log_compliance_miss(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceMiss,
key: key.to_string(),
session_id: None,
}))
.await
}
pub async fn run_log_compliance_hit(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceHit,
key: key.to_string(),
session_id: None,
}))
.await
}
pub async fn run_log_codex_shell_miss(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::CodexShellMiss,
key: key.to_string(),
session_id: None,
}))
.await
}
pub async fn run_log_bootstrap(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::Bootstrap,
key: key.to_string(),
session_id: None,
}))
.await
}
pub async fn run_log_prompt_nudge(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::PromptNudge,
key: key.to_string(),
session_id: None,
}))
.await
}
pub async fn run_session_flush() -> Result<()> {
hook_fire_v2(mati_core::mcp::protocol::Command::SessionFlush).await
}
pub async fn run_session_harvest() -> Result<()> {
hook_fire_v2(mati_core::mcp::protocol::Command::SessionHarvest).await
}
pub async fn run_session_clear_consults() -> Result<()> {
hook_fire_v2(mati_core::mcp::protocol::Command::SessionClearConsults).await
}
pub async fn run_edit_hook(path: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
let rel = std::path::Path::new(path)
.strip_prefix(&cwd)
.map(|r| r.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string());
hook_fire_v2(mati_core::mcp::protocol::Command::FileEditHook(
mati_core::mcp::protocol::FileEditHookInput { path: rel },
))
.await
}
pub async fn run_doc_capture(path: &str) -> Result<()> {
use std::io::Read as _;
let _ = std::io::stdin().read_to_end(&mut Vec::new());
hook_fire_v2(mati_core::mcp::protocol::Command::DocCapture(
mati_core::mcp::protocol::DocCaptureInput {
path: path.to_string(),
},
))
.await
}
pub async fn run_session_check_consulted(key: &str) -> Result<()> {
hook_query_bool("session_check_consulted", serde_json::json!({ "key": key })).await
}
pub async fn run_session_check_consulted_recent(key: &str, ttl_secs: u64) -> Result<()> {
hook_query_bool(
"session_check_consulted_recent",
serde_json::json!({ "key": key, "ttl_secs": ttl_secs }),
)
.await
}
pub async fn run_prompt_context(files: &[String]) -> Result<()> {
let cwd = std::env::current_dir()?;
let root = crate::cli::daemon::mati_root_for(&cwd)?;
let cmd = mati_core::mcp::protocol::Command::MemBootstrap(
mati_core::mcp::protocol::MemBootstrapInput {
context_files: files.to_vec(),
},
);
match crate::cli::daemon::daemon_v2(&root, cmd).await {
crate::cli::daemon::DaemonResult::Ok(resp)
if resp.get("ok") == Some(&serde_json::Value::Bool(true)) =>
{
if let Some(data) = resp.get("data") {
let text = data.as_str().unwrap_or("");
print!("{text}");
}
}
_ => {
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use mati_core::store::session::*;
use mati_core::store::*;
use tempfile::TempDir;
fn extract_confirmed(record: &Record) -> bool {
if record.category != Category::Gotcha {
return false;
}
record
.payload_as::<GotchaRecord>()
.map(|g| g.confirmed)
.unwrap_or(false)
}
async fn temp_store() -> (TempDir, Store) {
let dir = TempDir::new().expect("tempdir");
let store = Store::open(dir.path()).await.expect("open store");
(dir, store)
}
#[tokio::test]
async fn extract_confirmed_returns_true_for_confirmed_gotcha() {
let mut record = Record {
key: "gotcha:test".to_string(),
value: "test".to_string(),
category: Category::Gotcha,
priority: Priority::Normal,
tags: vec![],
created_at: 0,
updated_at: 0,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 0,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: Some(serde_json::json!({
"rule": "test rule",
"reason": "test reason",
"severity": "normal",
"affected_files": [],
"confirmed": true
})),
};
assert!(extract_confirmed(&record));
record.category = Category::File;
assert!(!extract_confirmed(&record));
}
#[tokio::test]
async fn upsert_daily_agg_caps_keys_at_100() {
let (_dir, store) = temp_store().await;
let agg_key = today_key("analytics:test_cap_");
for i in 0..120 {
upsert_daily_agg(&store, &agg_key, &format!("key_{i}"))
.await
.unwrap();
}
let record = store.get(&agg_key).await.unwrap().unwrap();
let agg = record.payload_as::<DailyAgg>().unwrap();
assert_eq!(agg.count, 120);
assert_eq!(agg.keys.len(), MAX_AGG_KEYS);
store.close().await.unwrap();
}
#[tokio::test]
async fn promote_gotcha_candidates_confirms_above_threshold() {
let (_dir, store) = temp_store().await;
let record = Record {
key: "gotcha:promote-test".to_string(),
value: "test".to_string(),
category: Category::Gotcha,
priority: Priority::Normal,
tags: vec![],
created_at: 0,
updated_at: 0,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 0,
},
quality: QualityScore::layer0_default(),
access_count: GOTCHA_PROMOTION_ACCESS_THRESHOLD,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: Some(serde_json::json!({
"rule": "test rule",
"reason": "test reason",
"severity": "normal",
"affected_files": [],
"confirmed": false
})),
};
store.put(&record.key, &record).await.unwrap();
let promoted = mati_core::store::session::promote_gotcha_candidates(&store)
.await
.unwrap();
assert_eq!(promoted, 1);
let updated = store.get("gotcha:promote-test").await.unwrap().unwrap();
let gotcha = updated.payload_as::<GotchaRecord>().unwrap();
assert!(gotcha.confirmed);
store.close().await.unwrap();
}
#[tokio::test]
async fn stale_review_truncates_to_max() {
let (_dir, store) = temp_store().await;
for i in 0..30 {
let key = format!("file:test_{i}.rs");
let record = Record {
key: key.clone(),
value: format!("test file {i}"),
category: Category::File,
priority: Priority::Normal,
tags: vec![],
created_at: 0,
updated_at: 0,
ref_url: None,
staleness: StalenessScore {
value: 0.5,
tier: StalenessTier::Stale,
signals: vec![],
computed_at: 0,
last_record_sha: String::new(),
},
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 0,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: None,
};
store.put(&key, &record).await.unwrap();
}
let keys: Vec<String> = (0..30).map(|i| format!("file:test_{i}.rs")).collect();
let entries = collect_stale_entries(&store, &keys).await.unwrap();
assert!(entries.len() <= MAX_STALE_REVIEW_ENTRIES);
store.close().await.unwrap();
}
}