use crate::state::{ActiveDirective, AphroditeState};
pub const SHIPPED_SESSION_INJECT: &str = "\
[APHRODITE] v{VERSION} active.
This session is running with CCR compression. Tool outputs larger than a
few hundred bytes are replaced with markers like <<<CCR:hash|type|size>>>.
The marker IS the content — retrieve it before acting on it:
aphrodite_retrieve(hash) → full original content (sub-ms, local).
After EVERY tool call: scan for <<<CCR: and retrieve ALL markers first.
NEVER re-read a file you already have a marker for. Use aphrodite_catalog
to see stored entries, aphrodite_prefetch for background file loads, and
aphrodite_directive(\"list\") for active behavioral directives.
Layer 2: per-turn catalog injected below each turn.
Layer 3: load the aphrodite-tool-guide skill for full tool reference.";
pub fn build_first_turn_injection(state: &AphroditeState) -> String {
if state.session_inject.is_empty() || state.turn_counter > 0 {
return String::new();
}
state.session_inject.replace("{VERSION}", env!("CARGO_PKG_VERSION"))
}
pub fn build_turn_context(state: &mut AphroditeState, est_request_bytes: Option<usize>) -> String {
let _ = est_request_bytes;
let budget = state.flow_budget_chars;
let session_inject = build_first_turn_injection(state);
let directives = crate::directives::build_directive_context(&state.directives, &state.active_directives);
let nudges = render_nudges(state);
let recall = if state.navigation_enabled {
#[cfg(feature = "navigation")]
{
crate::navigate::build_navigable_context(state)
}
#[cfg(not(feature = "navigation"))]
{
crate::session::catalog_summary(state)
}
} else {
crate::session::catalog_summary(state)
};
let bg_status = if state.poll_worker_enabled {
crate::poll_worker::render_bg_task_status(state)
} else {
String::new()
};
let mut sections: Vec<String> = Vec::new();
if !session_inject.is_empty() {
sections.push(format!("[aphrodite: first-turn orientation]\n{}", session_inject.trim_end()));
}
if !directives.is_empty() {
sections.push(directives.trim_end().to_string());
}
if !nudges.is_empty() {
sections.push(nudges);
}
if !bg_status.is_empty() {
sections.push(bg_status);
}
if !recall.is_empty() {
sections.push(format!("[recall]\n{}\n", recall.trim_end()));
}
let always_survive =
session_inject.is_empty() as usize + directives.is_empty() as usize + nudges_present(state) as usize;
let always_survive = always_survive.min(sections.len());
while join_sections(§ions).len() > budget && sections.len() > always_survive {
sections.pop();
}
join_sections(§ions)
}
fn nudges_present(state: &AphroditeState) -> bool {
state
.ephemeral_directives
.iter()
.any(|e| e.inline.is_some() && e.expires_after_turn.is_none_or(|exp| exp >= state.turn_counter))
}
fn join_sections(sections: &[String]) -> String {
sections.join("\n")
}
pub fn render_nudges(state: &AphroditeState) -> String {
let mut lines: Vec<String> = state
.ephemeral_directives
.iter()
.rev() .filter(|e| e.inline.is_some())
.filter(|e| e.expires_after_turn.is_none_or(|exp| exp >= state.turn_counter))
.take(2)
.filter_map(|e| e.inline.as_ref().map(|t| format!("[nudge: {t}]")))
.collect();
lines.reverse();
lines.join("\n")
}
pub fn push_nudge(state: &mut AphroditeState, text: &str, ttl_turns: usize) {
let expires = Some(state.turn_counter + ttl_turns);
state.ephemeral_directives.push(ActiveDirective {
name: String::new(),
inline: Some(text.to_string()),
expires_after_turn: expires,
});
while state.ephemeral_directives.len() > 4 {
state.ephemeral_directives.remove(0);
}
}
pub fn purge_expired_nudges(state: &mut AphroditeState) {
let counter = state.turn_counter;
state
.ephemeral_directives
.retain(|e| e.expires_after_turn.is_none_or(|exp| exp >= counter));
}
const FNV_OFFSET: u64 = 0xCBF2_9CE4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01B3;
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = FNV_OFFSET;
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
const VOLATILE_KEYS: &[&str] = &["timeout", "timestamp", "session_id", "tool_call_id"];
pub fn normalize_args_sig(tool: &str, args: Option<&serde_json::Value>) -> u64 {
let mut buf = String::new();
buf.push_str(tool);
buf.push('\u{1}');
if let Some(v) = args {
if let Some(cmd) = v.get("command").and_then(|c| c.as_str()) {
buf.push_str(cmd.trim_end());
} else if let Some(obj) = v.as_object() {
let mut keys: Vec<&String> = obj.keys().filter(|k| !VOLATILE_KEYS.contains(&k.as_str())).collect();
keys.sort();
for k in keys {
buf.push_str(k);
buf.push('=');
buf.push_str(&obj[k].to_string());
buf.push('\u{1f}');
}
} else {
buf.push_str(&v.to_string());
}
}
fnv1a(buf.as_bytes())
}
pub fn error_sig(error_type: Option<&str>, error_message: Option<&str>) -> u64 {
let mut buf = String::new();
buf.push_str(error_type.unwrap_or(""));
buf.push('\u{1}');
if let Some(msg) = error_message {
buf.push_str(msg.lines().next().unwrap_or("").trim());
}
fnv1a(buf.as_bytes())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WindowStats {
pub reads: usize,
pub writes: usize,
pub searches: usize,
pub errors: usize,
pub distinct_error_sigs: usize,
pub new_files: usize,
pub total_calls: usize,
}
pub fn turn_window(state: &AphroditeState, n: usize) -> WindowStats {
let floor = state.turn_counter.saturating_sub(n);
let mut stats = WindowStats::default();
let mut error_sigs = std::collections::HashSet::new();
let mut seen_paths = std::collections::HashSet::new();
for ev in state.tool_events.iter().filter(|e| e.turn > floor) {
stats.total_calls += 1;
if !ev.ok {
stats.errors += 1;
if let Some(sig) = ev.error_sig {
error_sigs.insert(sig);
}
}
if let Some(path) = &ev.wrote_path {
stats.writes += 1;
if seen_paths.insert(path.clone()) {
stats.new_files += 1;
}
} else {
match ev.tool.as_str() {
"read_file" => stats.reads += 1,
"search_files" => stats.searches += 1,
_ => {},
}
}
}
stats.distinct_error_sigs = error_sigs.len();
stats
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
directives::Directive,
state::{MarkerEntry, ToolEvent},
};
fn state_with_directive(name: &str, body: &str) -> AphroditeState {
let mut s = AphroditeState::default();
s.directives
.insert(name.into(), Directive { name: name.into(), content: body.into() });
s.active_directives = vec![name.into()];
s
}
fn add_marker(s: &mut AphroditeState, hash: &str, turn: usize) {
s.record_marker(MarkerEntry {
hash: hash.into(),
ccr_type: "text".into(),
size: 100,
preview: "[text] some preview content here".into(),
turn,
center: None,
meta: None,
});
}
#[test]
fn test_budget_drops_catalog_before_directives() {
let mut s = state_with_directive("focus", "stay targeted, minimal tool usage");
for i in 0..50 {
add_marker(&mut s, &format!("hash{i:040}"), i);
}
s.flow_budget_chars = 90;
let ctx = build_turn_context(&mut s, None);
assert!(ctx.contains("[directives:"), "directives must survive: {ctx}");
assert!(
!ctx.contains("[recall]"),
"catalog must be dropped first under budget pressure: {ctx}"
);
}
#[test]
fn test_retrieve_hint_not_in_per_turn_context() {
let mut s = AphroditeState::default();
add_marker(&mut s, &"a".repeat(40), 0);
s.flow_budget_chars = 4000;
let ctx = build_turn_context(&mut s, None);
assert_eq!(
ctx.matches("retrieve: aphrodite_retrieve").count(),
0,
"retrieve hint must NOT appear in per-turn context (04-F2): {ctx}"
);
}
#[test]
fn test_empty_state_yields_empty_context() {
let mut s = AphroditeState::default();
assert_eq!(build_turn_context(&mut s, None), "");
}
#[test]
fn test_normalize_args_sig_stable_across_volatile_keys() {
let a = serde_json::json!({"path": "src/x.rs", "timeout": 30, "session_id": "abc"});
let b = serde_json::json!({"path": "src/x.rs", "timeout": 99, "session_id": "zzz"});
assert_eq!(
normalize_args_sig("read_file", Some(&a)),
normalize_args_sig("read_file", Some(&b)),
"volatile keys must not affect the signature"
);
let c = serde_json::json!({"path": "src/y.rs"});
assert_ne!(
normalize_args_sig("read_file", Some(&a)),
normalize_args_sig("read_file", Some(&c))
);
let cmd1 = serde_json::json!({"command": "cargo test "});
let cmd2 = serde_json::json!({"command": "cargo test"});
assert_eq!(
normalize_args_sig("terminal", Some(&cmd1)),
normalize_args_sig("terminal", Some(&cmd2))
);
}
#[test]
fn test_turn_window_counts_reads_writes_errors() {
let mut s = AphroditeState::default();
s.turn_counter = 10;
s.record_tool_event(ToolEvent {
turn: 10,
tool: "read_file".into(),
sig: 1,
ok: true,
error_sig: None,
bytes: 100,
wrote_path: None,
});
s.record_tool_event(ToolEvent {
turn: 10,
tool: "write_file".into(),
sig: 2,
ok: true,
error_sig: None,
bytes: 50,
wrote_path: Some("src/a.rs".into()),
});
s.record_tool_event(ToolEvent {
turn: 9,
tool: "terminal".into(),
sig: 3,
ok: false,
error_sig: Some(42),
bytes: 20,
wrote_path: None,
});
s.record_tool_event(ToolEvent {
turn: 2,
tool: "read_file".into(),
sig: 4,
ok: true,
error_sig: None,
bytes: 0,
wrote_path: None,
});
let w = turn_window(&s, 5);
assert_eq!(w.reads, 1);
assert_eq!(w.writes, 1);
assert_eq!(w.new_files, 1);
assert_eq!(w.errors, 1);
assert_eq!(w.distinct_error_sigs, 1);
assert_eq!(w.total_calls, 3, "the turn-2 event is outside the 5-turn window");
}
#[test]
fn test_build_turn_context_omits_poll_status_when_disabled() {
let mut s = AphroditeState::default();
s.poll_worker_enabled = false;
s.turn_counter = 5;
crate::poll_worker::insert_bg_task(&mut s, "t1".into(), "terminal".into(), "cargo build".into(), 1);
let ctx = build_turn_context(&mut s, None);
assert!(
!ctx.contains("[poll workers]"),
"disabled flag must omit poll worker status: {ctx}"
);
}
#[test]
fn test_build_turn_context_includes_poll_status_when_enabled() {
let mut s = AphroditeState::default();
s.poll_worker_enabled = true;
s.turn_counter = 5;
crate::poll_worker::insert_bg_task(&mut s, "t1".into(), "terminal".into(), "cargo build".into(), 1);
let ctx = build_turn_context(&mut s, None);
assert!(
ctx.contains("[poll workers]"),
"enabled flag must include poll worker status: {ctx}"
);
}
#[test]
fn test_first_turn_injection_renders_on_turn_zero() {
let mut s = AphroditeState::default();
s.session_inject = SHIPPED_SESSION_INJECT.to_string();
s.turn_counter = 0;
let ctx = build_turn_context(&mut s, None);
assert!(
ctx.contains("[aphrodite: first-turn orientation]"),
"session inject must appear on turn 0: {ctx}"
);
assert!(
ctx.contains("<<<CCR:hash|type|size>>>"),
"session inject must contain CCR marker example: {ctx}"
);
}
#[test]
fn test_first_turn_injection_suppressed_after_turn_zero() {
let mut s = AphroditeState::default();
s.session_inject = SHIPPED_SESSION_INJECT.to_string();
s.turn_counter = 1;
let ctx = build_turn_context(&mut s, None);
assert!(
!ctx.contains("[aphrodite: first-turn orientation]"),
"session inject must NOT appear after turn 0: {ctx}"
);
}
#[test]
fn test_first_turn_injection_disabled_when_empty() {
let mut s = AphroditeState::default();
s.session_inject = String::new();
s.turn_counter = 0;
let ctx = build_turn_context(&mut s, None);
assert!(
!ctx.contains("[aphrodite: first-turn orientation]"),
"empty session_inject must produce no injection: {ctx}"
);
}
#[test]
fn test_first_turn_injection_replaces_version_placeholder() {
let mut s = AphroditeState::default();
s.session_inject = "aphrodite v{VERSION} ready".to_string();
s.turn_counter = 0;
let ctx = build_turn_context(&mut s, None);
assert!(
ctx.contains(&format!("aphrodite v{}", env!("CARGO_PKG_VERSION"))),
"{{VERSION}} must be replaced: {ctx}"
);
assert!(!ctx.contains("{VERSION}"), "unreplaced {{VERSION}} placeholder: {ctx}");
}
}