Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ agentflare-artifacts = { path = "crates/agentflare-artifacts" }
agentflare-backend = { package = "agentflare-backend", path = "crates/agentflare-backend" }
db_kit = { package = "agentflare-db-kit", path = "crates/agentflare-db-kit" }
agent-detector = "0.2.1"
flare-search-kit = { path = "crates/flare-search-kit" }

[target.'cfg(unix)'.dependencies]
libc = "0.2"
Expand Down
7 changes: 7 additions & 0 deletions src/cli/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ pub enum HookEvent {
#[arg(long, value_enum)]
agent: Option<agent_registry::Agent>,
},
/// Fires before context compaction (Claude Code). Hook can read the
/// transcript and persist state before the conversation is compacted.
PreCompact {
#[arg(long, value_enum)]
agent: Option<agent_registry::Agent>,
},
}

#[derive(Args)]
Expand All @@ -47,6 +53,7 @@ impl HookArgs {
HookEvent::PromptSubmit { agent } => crate::hook::prompt_submit(&resolve_agent(agent)),
HookEvent::PreToolUse { agent } => crate::hook::pre_tool_use(&resolve_agent(agent)),
HookEvent::SessionEnd { agent } => crate::hook::session_end(&resolve_agent(agent)),
HookEvent::PreCompact { agent } => crate::hook::pre_compact(&resolve_agent(agent)),
}
}
}
188 changes: 188 additions & 0 deletions src/compact.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! Ephemeral FTS5/BM25 line scorer for session transcript compaction.
//! Creates an in-memory SQLite FTS5 table per call and ranks lines by
//! relevance to a query. Used by the PreCompact hook (Phase 2) to
//! identify which transcript lines to keep during compaction.
use flare_search_kit::{Bm25Weights, MatchMode, fts_query};
use rusqlite::Connection;

/// A single line from a session transcript.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct LineEntry {
pub index: usize,
pub text: String,
}

/// A transcript line with its BM25 relevance score.
/// Lower scores = more relevant (0 = perfect match).
#[derive(Debug, Clone, serde::Serialize)]
#[allow(dead_code)]
pub struct ScoredLine {
pub index: usize,
pub text: String,
pub score: f64,
}

/// Score transcript lines by BM25 relevance to a query.
///
/// Creates an ephemeral in-memory FTS5 table, inserts each line as a row,
/// and returns matched lines ranked by relevance (most relevant first).
/// Returns empty vec when:
/// - `lines` is empty
/// - `query` sanitizes to nothing (whitespace, punctuation-only)
/// - no lines match the query
pub fn score_lines(lines: &[LineEntry], query: &str) -> Vec<ScoredLine> {
if lines.is_empty() {
return vec![];
}

// OR mode for broader recall — compaction keeps all potentially relevant lines.
let Some(safe_query) = fts_query(query, MatchMode::Any) else {
return vec![];
};

let conn = Connection::open_in_memory().expect("in-memory SQLite connection");

conn.execute_batch("CREATE VIRTUAL TABLE transcript_fts USING fts5(\"ix\" UNINDEXED, text);")
.expect("create FTS5 table");

{
let tx = conn.unchecked_transaction().expect("start transaction");
{
let mut stmt = tx
.prepare("INSERT INTO transcript_fts(\"ix\", text) VALUES(?1, ?2);")
.expect("prepare insert");
for line in lines {
stmt.execute(rusqlite::params![line.index as i64, &line.text])
.expect("insert line");
}
}
tx.commit().expect("commit transcript insert batch");
}

let weights = Bm25Weights::new(vec![]);
let sql = format!(
"SELECT \"ix\", text, bm25(transcript_fts{}) AS score \
FROM transcript_fts \
WHERE transcript_fts MATCH ?1 \
ORDER BY score, \"ix\"",
weights.sql_args()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);

let mut stmt = conn.prepare(&sql).expect("prepare query");

let results = stmt
.query_map(rusqlite::params![safe_query], |row| {
Ok(ScoredLine {
index: row.get::<_, i64>("ix")? as usize,
text: row.get("text")?,
score: row.get("score")?,
})
})
.expect("query lines");

results.filter_map(|r| r.ok()).collect()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn score_lines_matches_in_or_mode() {
let lines = vec![
LineEntry {
index: 0,
text: "Let me check the database schema.".to_string(),
},
LineEntry {
index: 1,
text: "The weather is nice today.".to_string(),
},
LineEntry {
index: 2,
text: "I need to query the users table.".to_string(),
},
];
let scored = score_lines(&lines, "database query");
assert!(!scored.is_empty(), "should match at least one line");
let matched: Vec<usize> = scored.iter().map(|s| s.index).collect();
assert!(matched.contains(&0), "line with 'database' should match");
assert!(matched.contains(&2), "line with 'query' should match");
assert!(!matched.contains(&1), "unrelated line should not match");
// BM25 lower score = more relevant; the line with more matching
// terms should rank higher (index 2 has 'query', index 0 has 'database')
// Both match one term each, so scores should be comparable.
}

#[test]
fn score_lines_returns_empty_for_no_match() {
let lines = vec![LineEntry {
index: 0,
text: "Fix the login button color.".to_string(),
}];
let scored = score_lines(&lines, "quantum physics");
assert!(scored.is_empty());
}

#[test]
fn score_lines_returns_empty_for_empty_lines() {
let scored = score_lines(&[], "anything");
assert!(scored.is_empty());
}

#[test]
fn score_lines_returns_empty_for_empty_query() {
let lines = vec![LineEntry {
index: 0,
text: "Hello world.".to_string(),
}];
let scored = score_lines(&lines, "");
assert!(scored.is_empty());
}

#[test]
fn score_lines_returns_empty_for_punctuation_only_query() {
let lines = vec![LineEntry {
index: 0,
text: "Hello world.".to_string(),
}];
let scored = score_lines(&lines, "***");
assert!(scored.is_empty());
}

#[test]
fn score_lines_handles_special_characters() {
let lines = vec![
LineEntry {
index: 0,
text: "Run npm install && cargo build --release.".to_string(),
},
LineEntry {
index: 1,
text: "The price is $19.99 + tax (10%).".to_string(),
},
];
let scored = score_lines(&lines, "cargo build");
assert_eq!(scored.len(), 1);
assert_eq!(scored[0].index, 0);
}

#[test]
fn score_lines_preserves_line_order_within_same_score() {
let lines = vec![
LineEntry {
index: 0,
text: "database schema".to_string(),
},
LineEntry {
index: 1,
text: "database schema".to_string(),
},
];
let scored = score_lines(&lines, "database");
assert_eq!(scored.len(), 2);
assert_eq!(scored[0].index, 0);
assert_eq!(scored[1].index, 1);
}
}
130 changes: 130 additions & 0 deletions src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,82 @@ pub fn pre_tool_use(_agent: &str) {
}
}

#[allow(dead_code)]
struct PreCompactInput {
session_id: String,
transcript_path: Option<String>,
trigger: String,
}

fn parse_pre_compact(input: &str) -> Option<PreCompactInput> {
let v: serde_json::Value = serde_json::from_str(input).ok()?;
let session_id = v.get("session_id")?.as_str()?.to_string();
let transcript_path = v
.get("transcript_path")
.and_then(|t| t.as_str())
.map(String::from);
let trigger = v
.get("trigger")
.and_then(|t| t.as_str())
.unwrap_or("auto")
.to_string();
Some(PreCompactInput {
session_id,
transcript_path,
trigger,
})
}

/// `session_id` is a UUID, not natural-language text -- FTS5-matching it
/// against transcript content would essentially never hit, making
/// relevance scoring a no-op. Use the transcript's own last non-empty
/// line (the most recent turn) as a proxy for what the user is currently
/// focused on, so older lines get ranked by relevance to that instead of
/// to an opaque session identifier. Falls back to `session_id` only when
/// the whole transcript is empty.
fn relevance_query<'a>(content: &'a str, session_id: &'a str) -> &'a str {
content
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or(session_id)
}

/// Handles PreCompact hook. Reads transcript from `transcript_path` and
/// scores lines by relevance using FTS5/BM25. Outputs scored lines as JSON
/// so Claude Code's compaction can prioritise keeping relevant context.
pub fn pre_compact(_agent: &str) {
let Some(input) = read_stdin_or_skip("PreCompact") else {
return;
};
let Some(parsed) = parse_pre_compact(&input) else {
return;
};
let Some(path) = parsed.transcript_path else {
return;
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("[agentflare] PreCompact: cannot read transcript {path}: {e}");
return;
}
};
let entries: Vec<crate::compact::LineEntry> = content
.lines()
.enumerate()
.map(|(i, text)| crate::compact::LineEntry {
index: i,
text: text.to_string(),
})
.collect();
let query = relevance_query(&content, &parsed.session_id);
let scored = crate::compact::score_lines(&entries, query);
if let Ok(json) = serde_json::to_string(&scored) {
println!("{json}");
}
}

/// No-op, kept only so a `settings.json` entry written by an older agentflare
/// version (which fired an `engram-cli` handoff here — removed along with the
/// rest of the engram integration) doesn't start erroring on every session
Expand Down Expand Up @@ -372,6 +448,60 @@ mod tests {
assert_eq!(parsed.delay_seconds, Some(280));
}

#[test]
fn parse_pre_compact_reads_session_and_trigger() {
let input = r#"{"session_id": "abc", "transcript_path": "/x.jsonl", "hook_event_name": "PreCompact", "trigger": "auto"}"#;
let parsed = parse_pre_compact(input).unwrap();
assert_eq!(parsed.session_id, "abc");
assert_eq!(parsed.transcript_path.as_deref(), Some("/x.jsonl"));
assert_eq!(parsed.trigger, "auto");
}

#[test]
fn parse_pre_compact_defaults_trigger_when_missing() {
let input = r#"{"session_id": "abc", "hook_event_name": "PreCompact"}"#;
let parsed = parse_pre_compact(input).unwrap();
assert_eq!(parsed.trigger, "auto");
assert!(parsed.transcript_path.is_none());
}

#[test]
fn parse_pre_compact_rejects_invalid_json() {
assert!(parse_pre_compact("not json").is_none());
}

#[test]
fn parse_pre_compact_rejects_missing_session() {
let input = r#"{"trigger": "manual"}"#;
assert!(parse_pre_compact(input).is_none());
}

#[test]
fn relevance_query_uses_last_non_empty_transcript_line() {
let content = "first line
second line

";
assert_eq!(relevance_query(content, "some-session-uuid"), "second line");
}

#[test]
fn relevance_query_falls_back_to_session_id_when_transcript_empty() {
assert_eq!(
relevance_query("", "some-session-uuid"),
"some-session-uuid"
);
assert_eq!(
relevance_query(
"

",
"some-session-uuid"
),
"some-session-uuid"
);
}

#[test]
fn parse_pre_tool_use_returns_none_on_invalid_json() {
assert!(parse_pre_tool_use("not json").is_none());
Expand Down
Loading
Loading