use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::io::Read;
pub mod settings;
pub const NOISE_PATH_SEGMENTS: &[&str] = &[
"/node_modules/",
"/target/",
"/dist/",
"/build/",
"/.git/",
"/.next/",
"/.nuxt/",
"/.svelte-kit/",
"/.cache/",
"/.venv/",
"/venv/",
"/__pycache__/",
"/coverage/",
"/.turbo/",
"/.parcel-cache/",
"/.pytest_cache/",
"/.mypy_cache/",
"/.gradle/",
"/.idea/",
"/.vscode/",
];
pub fn is_noise_path(p: &str) -> bool {
NOISE_PATH_SEGMENTS.iter().any(|seg| p.contains(seg))
}
fn extract_tool_file_path(raw: &serde_json::Value, cwd: Option<&str>) -> Option<String> {
let input = raw.get("tool_input")?;
let candidate = input
.get("file_path")
.and_then(|v| v.as_str())
.or_else(|| input.get("notebook_path").and_then(|v| v.as_str()))
.or_else(|| {
input
.get("file_paths")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
})?;
let p = std::path::Path::new(candidate);
if p.is_absolute() {
Some(candidate.to_string())
} else if let Some(c) = cwd {
Some(
std::path::Path::new(c)
.join(p)
.to_string_lossy()
.into_owned(),
)
} else {
Some(candidate.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
UserPromptSubmit,
Stop,
SessionStart,
SessionEnd,
}
impl HookEvent {
pub fn parse(s: &str) -> Option<Self> {
match s {
"pre-tool-use" | "PreToolUse" => Some(Self::PreToolUse),
"post-tool-use" | "PostToolUse" => Some(Self::PostToolUse),
"user-prompt-submit" | "UserPromptSubmit" => Some(Self::UserPromptSubmit),
"stop" | "Stop" => Some(Self::Stop),
"session-start" | "SessionStart" => Some(Self::SessionStart),
"session-end" | "SessionEnd" => Some(Self::SessionEnd),
_ => None,
}
}
pub fn as_event_type(self) -> &'static str {
match self {
Self::PreToolUse => "hook.tool_pre",
Self::PostToolUse => "hook.tool_post",
Self::UserPromptSubmit => "hook.user_prompt",
Self::Stop => "hook.stop",
Self::SessionStart => "hook.session_start",
Self::SessionEnd => "hook.session_end",
}
}
pub fn as_settings_key(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::UserPromptSubmit => "UserPromptSubmit",
Self::Stop => "Stop",
Self::SessionStart => "SessionStart",
Self::SessionEnd => "SessionEnd",
}
}
pub fn as_cli_arg(self) -> &'static str {
match self {
Self::PreToolUse => "pre-tool-use",
Self::PostToolUse => "post-tool-use",
Self::UserPromptSubmit => "user-prompt-submit",
Self::Stop => "stop",
Self::SessionStart => "session-start",
Self::SessionEnd => "session-end",
}
}
}
#[cfg(test)]
mod noise_path_tests {
use super::*;
#[test]
fn flags_dependency_dirs() {
assert!(is_noise_path("/Users/x/proj/node_modules/react/index.js"));
assert!(is_noise_path("/proj/foo/node_modules/lib/a.ts"));
}
#[test]
fn flags_build_outputs_and_caches() {
assert!(is_noise_path("/proj/target/debug/foo"));
assert!(is_noise_path("/proj/dist/bundle.js"));
assert!(is_noise_path("/proj/.git/HEAD"));
assert!(is_noise_path("/proj/.next/cache/x"));
assert!(is_noise_path("/proj/__pycache__/foo.pyc"));
}
#[test]
fn does_not_flag_normal_paths() {
assert!(!is_noise_path("/proj/src/main.rs"));
assert!(!is_noise_path("/proj/website/src/App.tsx"));
assert!(!is_noise_path("/proj/target_arch/x"));
assert!(!is_noise_path("/proj/.gitlab-ci.yml"));
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct HookPayload {
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub tool_name: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct HookDecision {
#[serde(skip_serializing_if = "Option::is_none")]
pub decision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_context: Option<String>,
}
pub fn cmd_check(event: HookEvent) -> Result<()> {
let mut buf = String::new();
let read_ok = std::io::stdin().read_to_string(&mut buf).is_ok();
let raw_value: serde_json::Value =
serde_json::from_str(&buf).unwrap_or_else(|_| serde_json::json!({}));
let payload: HookPayload = if read_ok && !buf.trim().is_empty() {
serde_json::from_str(&buf).unwrap_or_default()
} else {
HookPayload::default()
};
if let Err(e) = record_event(event, &payload, &buf) {
tracing::warn!("[hook] record failed: {}", e);
}
let decision = match event {
HookEvent::PreToolUse => evaluate_gate(&payload, &raw_value).unwrap_or_default(),
HookEvent::UserPromptSubmit => build_prompt_context(&payload).unwrap_or_default(),
_ => HookDecision::default(),
};
println!(
"{}",
serde_json::to_string(&decision).unwrap_or_else(|_| "{}".into())
);
Ok(())
}
fn build_prompt_context(payload: &HookPayload) -> Result<HookDecision> {
use crate::adapter::limits::ADDITIONAL_CONTEXT_MAX_LINES;
let prompt = match payload.prompt.as_deref() {
Some(p) if !p.trim().is_empty() => p,
_ => return Ok(HookDecision::default()),
};
let cfg = match crate::config::Config::load(&crate::paths::config_file()?) {
Ok(c) => c,
Err(_) => return Ok(HookDecision::default()),
};
let conn = crate::db::open(&crate::paths::brain_db()?)?;
let signature = crate::signals::task_signature(prompt);
let project = payload
.cwd
.as_deref()
.and_then(|cwd| {
crate::db::project::resolve_canonical_name(&conn, &cfg.user.id, cwd)
.ok()
.flatten()
.or_else(|| {
std::path::Path::new(cwd)
.file_name()
.and_then(|s| s.to_str())
.map(String::from)
})
})
.unwrap_or_else(|| "_".into());
let mut lines: Vec<String> = Vec::new();
let matched = crate::db::pattern::list_by_signature(&conn, &cfg.user.id, signature)?;
if !matched.is_empty() {
lines.push("**[Asurada 메모리]** 누적 패턴 매칭 — 이전에 처리한 적 있음:".into());
for h in matched.iter().take(3) {
lines.push(format!(
"- 하네스 *{}* (project={}, used {}x): {}",
h.title, h.project, h.usage_count, h.description
));
for fp in &h.file_paths {
lines.push(format!(" ↳ 워크플로우: `{}`", fp));
}
}
for h in matched.iter().take(3) {
let _ = crate::db::pattern::record_use(&conn, &h.id);
let _ = crate::db::event::insert(
&conn,
crate::db::event::EventInput {
user_id: cfg.user.id.clone(),
project: project.clone(),
event_type: "signal.harness_use".into(),
path: None,
payload: serde_json::json!({
"harness_id": h.id,
"slug": h.slug,
"trigger": "signature_match",
"signature": signature,
}),
},
);
}
}
if matched.is_empty() {
let (count_inclusive, _) = crate::signals::count_prior_with_signature(
&conn,
&cfg.user.id,
&project,
signature,
30,
)?;
let prior = count_inclusive.saturating_sub(1);
if prior >= 5 {
lines.push(format!(
"**[Asurada]** 이 작업을 {}회 반복하고 있어요. 하네스로 묶어두면 \
다음에 더 일관되게 처리됩니다 — `asurada pattern propose --threshold {} --days 7`",
prior,
prior.min(5)
));
} else if prior >= 2 {
lines.push(format!(
"**[Asurada 메모리]** 사용자가 이 작업을 {}회 반복했음. \
이전 접근을 회상해 일관성 유지.",
prior
));
}
}
if let Ok(Some(bridge)) = detect_project_bridge(&conn, &cfg.user.id, &project) {
lines.push(bridge);
}
if let Ok(Some(recall)) = recall_recent_brief(&conn, &cfg.user.id, &project) {
lines.push(recall);
}
if lines.is_empty() {
return Ok(HookDecision::default());
}
let truncated: Vec<String> = lines
.into_iter()
.take(ADDITIONAL_CONTEXT_MAX_LINES)
.collect();
let context = truncated.join("\n");
let _ = crate::db::event::insert(
&conn,
crate::db::event::EventInput {
user_id: cfg.user.id,
project,
event_type: "signal.context_injection".into(),
path: None,
payload: serde_json::json!({
"signature": signature,
"lines": truncated.len(),
}),
},
);
Ok(HookDecision {
decision: None,
reason: None,
additional_context: Some(context),
})
}
fn evaluate_gate(payload: &HookPayload, raw: &serde_json::Value) -> Result<HookDecision> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home"))?;
let gates_path = home.join(".asurada/gates.json");
let rules = crate::adapter::claude_code::load_gate_rules(&gates_path)?;
if rules.is_empty() {
return Ok(HookDecision::default());
}
let tool_input = raw
.get("tool_input")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let Some(rule) =
crate::adapter::claude_code::match_gate(&rules, payload.tool_name.as_deref(), &tool_input)
else {
return Ok(HookDecision::default());
};
Ok(HookDecision {
decision: Some(rule.decision),
reason: Some(format!("[Asurada] {}", rule.reason)),
additional_context: None,
})
}
fn detect_project_bridge(
conn: &rusqlite::Connection,
user_id: &str,
current_project: &str,
) -> Result<Option<String>> {
let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
let mut stmt = conn.prepare(
r#"SELECT project, json_extract(payload, '$.prompt')
FROM events
WHERE user_id = ?1 AND event_type = 'hook.user_prompt'
AND created_at > ?2
AND project != ?3
ORDER BY created_at DESC LIMIT 1"#,
)?;
let row: Option<(String, Option<String>)> = stmt
.query_row(rusqlite::params![user_id, cutoff, current_project], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.ok();
let Some((prev_project, prev_prompt)) = row else {
return Ok(None);
};
let preview: String = prev_prompt.unwrap_or_default().chars().take(70).collect();
if preview.is_empty() {
return Ok(None);
}
Ok(Some(format!(
"**[Asurada — 프로젝트 전환]** 직전 30분 내 *{}* 에서 \"{}\" 작업 중. \
그쪽 미해결 사항이 있으면 잊지 말 것.",
prev_project, preview
)))
}
fn recall_recent_brief(
conn: &rusqlite::Connection,
user_id: &str,
project: &str,
) -> Result<Option<String>> {
let cutoff = (chrono::Utc::now() - chrono::Duration::hours(36)).to_rfc3339();
let text: Option<String> = conn
.query_row(
r#"SELECT text FROM memories
WHERE user_id = ?1 AND project = ?2
AND source = 'asurada'
AND json_extract(metadata, '$.kind') = 'brief'
AND created_at > ?3
AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1"#,
rusqlite::params![user_id, project, cutoff],
|r| r.get(0),
)
.ok();
let Some(t) = text else {
return Ok(None);
};
let first_sentence = t
.lines()
.find(|l| !l.trim().is_empty() && !l.starts_with('['))
.unwrap_or("");
let preview: String = first_sentence.chars().take(180).collect();
if preview.is_empty() {
return Ok(None);
}
Ok(Some(format!("**[Asurada — 어제 brief]** {}", preview)))
}
fn record_event(event: HookEvent, payload: &HookPayload, raw: &str) -> Result<()> {
let cfg_path = crate::paths::config_file()?;
let cfg = match crate::config::Config::load(&cfg_path) {
Ok(c) => c,
Err(_) => {
return Ok(());
}
};
let raw_value: serde_json::Value =
serde_json::from_str(raw).unwrap_or_else(|_| serde_json::json!({}));
if matches!(event, HookEvent::PreToolUse | HookEvent::PostToolUse) {
let file_path = raw_value
.get("tool_input")
.and_then(|t| t.get("file_path"))
.and_then(|v| v.as_str());
if let Some(fp) = file_path {
if is_noise_path(fp) {
return Ok(());
}
}
}
let conn = crate::db::open(&crate::paths::brain_db()?)?;
let tool_file_path: Option<String> =
if matches!(event, HookEvent::PreToolUse | HookEvent::PostToolUse) {
extract_tool_file_path(&raw_value, payload.cwd.as_deref())
} else {
None
};
let project = tool_file_path
.as_deref()
.and_then(|fp| {
crate::db::project::resolve_canonical_name(&conn, &cfg.user.id, fp)
.ok()
.flatten()
})
.or_else(|| {
payload.cwd.as_deref().and_then(|cwd| {
crate::db::project::resolve_canonical_name(&conn, &cfg.user.id, cwd)
.ok()
.flatten()
.or_else(|| {
std::path::Path::new(cwd)
.file_name()
.and_then(|s| s.to_str())
.map(String::from)
})
})
})
.unwrap_or_else(|| "_".into());
let signature = if event == HookEvent::UserPromptSubmit {
payload
.prompt
.as_deref()
.map(crate::signals::task_signature)
} else {
None
};
let payload_json = serde_json::json!({
"session_id": payload.session_id,
"tool_name": payload.tool_name,
"prompt": payload.prompt,
"cwd": payload.cwd,
"signature": signature,
"raw": raw_value,
});
crate::db::event::insert(
&conn,
crate::db::event::EventInput {
user_id: cfg.user.id.clone(),
project: project.clone(),
event_type: event.as_event_type().to_string(),
path: None,
payload: payload_json,
},
)?;
if event == HookEvent::UserPromptSubmit {
if let Some(prompt) = payload.prompt.as_deref() {
crate::signals::detect_and_record(&conn, &cfg.user.id, &project, prompt, signature)?;
}
}
if event == HookEvent::PreToolUse {
let file_path = raw_value
.get("tool_input")
.and_then(|t| t.get("file_path"))
.and_then(|v| v.as_str());
if let Ok(Some(harness_id)) = crate::pattern::detect_skill_read(
&conn,
&cfg.user.id,
&project,
payload.tool_name.as_deref(),
file_path,
) {
let _ = crate::db::pattern::record_use(&conn, &harness_id);
let _ = crate::db::event::insert(
&conn,
crate::db::event::EventInput {
user_id: cfg.user.id.clone(),
project: project.clone(),
event_type: "signal.harness_use".into(),
path: file_path.map(String::from),
payload: serde_json::json!({
"harness_id": harness_id,
"trigger": "skill_read",
}),
},
);
}
}
Ok(())
}