use core_api::repograph::{
identifier_terms, impact, path_excluded, recall_digest, sanitize, stale_concepts, FileImpact,
ImpactOptions, ImpactReport, DEFAULT_EXCLUDES, HINT, MAX_OUTPUT_BYTES, UNTRUSTED_FRAMING,
};
use core_api::{GraphDb, OpenOptions, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
const MAX_NUDGE_LINES: usize = 8;
const MAX_NUDGE_PARTNERS: usize = 3;
const MAX_NUDGE_IMPORTERS: usize = 3;
const MAX_NUDGE_FILES: usize = 50;
fn prompt_from_payload(raw: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(raw).ok()?;
for k in ["prompt", "user_prompt", "user_input"] {
if let Some(s) = v.get(k).and_then(|x| x.as_str()) {
let s = s.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
}
None
}
fn cwd_from_payload(raw: &str) -> Option<PathBuf> {
let v: serde_json::Value = serde_json::from_str(raw).ok()?;
let s = v.get("cwd").and_then(|x| x.as_str())?.trim();
(!s.is_empty()).then(|| PathBuf::from(s))
}
pub fn run_recall(db_dir: &Path, hook_stdin: &str) -> String {
let Some(prompt) = prompt_from_payload(hook_stdin) else {
return String::new();
};
if identifier_terms(&prompt).is_empty() {
return String::new();
}
if !db_dir.exists() {
return String::new();
}
let Ok(db) = GraphDb::open_with_options(
db_dir,
OpenOptions {
auto_migrate: false,
repair_wal: false,
read_only: true,
},
) else {
return String::new();
};
if let Some(nudge) = diff_nudge(
&db,
hook_stdin,
std::env::var_os("CLAUDE_PROJECT_DIR").as_deref(),
) {
return nudge;
}
recall_digest(
&db,
&prompt,
&db_dir.display().to_string(),
MAX_OUTPUT_BYTES,
)
}
fn diff_nudge(
db: &crate::structure::Db,
hook_stdin: &str,
project_dir: Option<&OsStr>,
) -> Option<String> {
let root = nudge_root(db, hook_stdin, project_dir)?;
let changed = changed_paths(&root);
if changed.is_empty() {
return None;
}
let modified: BTreeSet<String> = changed.iter().cloned().collect();
let asked: Vec<String> = changed.iter().take(MAX_NUDGE_FILES).cloned().collect();
let report = impact(db, &asked, &modified, &ImpactOptions::default());
render_nudge(db, &report, &modified, &changed)
}
fn nudge_root(
db: &crate::structure::Db,
hook_stdin: &str,
project_dir: Option<&OsStr>,
) -> Option<PathBuf> {
if let Some(cwd) = cwd_from_payload(hook_stdin) {
return repo_root(&cwd);
}
if let Some(dir) = project_dir.filter(|d| !d.is_empty()) {
if let Some(root) = repo_root(Path::new(dir)) {
return Some(root);
}
}
let repo = match db
.node_ref(crate::ingest_git::SYNC_KEY)
.and_then(|n| n.prop("repo"))
{
Some(Value::Str(s)) => s,
_ => return None,
};
repo_root(Path::new(&repo))
}
fn repo_root(dir: &Path) -> Option<PathBuf> {
if !dir.is_dir() {
return None;
}
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!root.is_empty()).then(|| PathBuf::from(root))
}
fn changed_paths(root: &Path) -> Vec<String> {
const LISTS: [&[&str]; 2] = [
&["diff", "--name-only", "-z", "HEAD"],
&["ls-files", "--others", "--exclude-standard", "-z"],
];
let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|p| (*p).to_string()).collect();
let mut out: BTreeSet<String> = BTreeSet::new();
for args in LISTS {
let Ok(output) = Command::new("git").arg("-C").arg(root).args(args).output() else {
return Vec::new();
};
if !output.status.success() {
continue;
}
for path in String::from_utf8_lossy(&output.stdout).split('\0') {
if !path.is_empty() && !path_excluded(path, &excludes) {
out.insert(path.to_string());
}
}
}
out.into_iter().collect()
}
fn rank_key(score: f64, shared: Option<usize>) -> (bool, usize, f64) {
(shared.is_none(), shared.unwrap_or(0), score)
}
fn render_nudge(
db: &crate::structure::Db,
report: &ImpactReport,
modified: &BTreeSet<String>,
changed: &[String],
) -> Option<String> {
if report.files.is_empty() {
return None;
}
let first = changed.first()?;
let mut lines: Vec<String> = Vec::new();
let more = changed.len() - 1;
lines.push(match more {
0 => format!("mushroomdb: you are editing {}", sanitize(first)),
n => format!(
"mushroomdb: you are editing {} (+{n} more)",
sanitize(first)
),
});
let mut partners: BTreeMap<String, (f64, Option<usize>)> = BTreeMap::new();
let mut importers: BTreeSet<String> = BTreeSet::new();
for f in &report.files {
for p in f.partners.iter().filter(|p| !p.modified) {
let slot = partners
.entry(p.path.clone())
.or_insert((p.score, p.shared_commits));
if rank_key(p.score, p.shared_commits) > rank_key(slot.0, slot.1) {
*slot = (p.score, p.shared_commits);
}
}
for p in f.importers.iter().filter(|p| !p.modified) {
importers.insert(p.path.clone());
}
}
let mut ranked: Vec<(String, (f64, Option<usize>))> = partners.into_iter().collect();
ranked.sort_by(|a, b| {
rank_key(b.1 .0, b.1 .1)
.partial_cmp(&rank_key(a.1 .0, a.1 .1))
.unwrap_or(std::cmp::Ordering::Equal)
});
if !ranked.is_empty() {
let items: Vec<String> = ranked
.iter()
.take(MAX_NUDGE_PARTNERS)
.map(|(path, (score, shared))| match shared {
Some(n) => format!("{path} ({n} shared commits, not modified)"),
None => format!("{path} ({score:.2}, not modified)"),
})
.collect();
lines.push(format!(" usually changes with: {}", items.join(", ")));
}
if !importers.is_empty() {
let items: Vec<String> = importers
.iter()
.take(MAX_NUDGE_IMPORTERS)
.map(|path| format!("{path} (not modified)"))
.collect();
lines.push(format!(" imported by: {}", items.join(", ")));
}
if let Some(owner) = owner_of(&report.files) {
lines.push(format!(" owner: {owner}"));
}
let stale = stale_concepts_describing(db, modified);
if stale > 0 {
lines.push(format!(
" {stale} concept(s) describe files you changed — say \"re-learn\" to refresh"
));
}
lines.truncate(MAX_NUDGE_LINES - 1);
loop {
let mut out = String::from(UNTRUSTED_FRAMING);
for l in &lines {
let _ = writeln!(out, "{l}");
}
out.push_str(HINT);
if out.len() <= MAX_OUTPUT_BYTES {
return Some(out);
}
if lines.len() <= 1 {
return None;
}
lines.pop();
}
}
fn owner_of(files: &[FileImpact]) -> Option<String> {
let mut counts: BTreeMap<&String, usize> = BTreeMap::new();
for owner in files.iter().filter_map(|f| f.owner.as_ref()) {
*counts.entry(owner).or_default() += 1;
}
counts
.into_iter()
.max_by_key(|(name, count)| (*count, std::cmp::Reverse(*name)))
.map(|(name, _)| name.clone())
}
fn stale_concepts_describing(db: &crate::structure::Db, modified: &BTreeSet<String>) -> usize {
stale_concepts(db)
.iter()
.filter(
|(key, _)| match db.node_ref(key).and_then(|n| n.prop("source_files")) {
Some(Value::List(sources)) => sources.iter().any(|v| match v {
Value::Str(s) => modified.contains(s),
_ => false,
}),
_ => false,
},
)
.count()
}
#[cfg(test)]
mod tests {
use super::prompt_from_payload;
use core_api::repograph::identifier_terms;
#[test]
fn prompt_is_read_from_any_of_the_three_documented_fields() {
for field in ["prompt", "user_prompt", "user_input"] {
let payload = format!(r#"{{"{field}":" hello "}}"#);
assert_eq!(prompt_from_payload(&payload).as_deref(), Some("hello"));
}
assert_eq!(prompt_from_payload(r#"{"prompt":" "}"#), None);
assert_eq!(prompt_from_payload(r#"{"other":"hi"}"#), None);
assert_eq!(prompt_from_payload("not json"), None);
}
#[test]
fn a_payload_is_searched_for_the_identifiers_in_its_prompt() {
let prompt = prompt_from_payload(
r#"{"prompt":"why does install.rs change with tests/install.rs?"}"#,
)
.expect("prompt");
assert_eq!(
identifier_terms(&prompt),
vec!["install.rs", "tests/install.rs"]
);
for glue in [
"the",
"is it done",
"ok thanks",
"can you do that please",
"what do you think about it",
"which file has the code",
"what is the weather today?",
] {
let payload = format!(r#"{{"prompt":{}}}"#, json_string(glue));
let prompt = prompt_from_payload(&payload).expect("prompt");
assert!(identifier_terms(&prompt).is_empty(), "{glue:?}");
}
}
fn json_string(s: &str) -> String {
serde_json::to_string(s).expect("string")
}
}