use super::display::{print_tool_call, print_tool_output};
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Experience {
pub task: String,
pub outcome: String,
pub lesson: String,
}
pub const EXPERIENCE_TOP_K: usize = 5;
pub(crate) const MIN_LESSON_CHARS: usize = 12;
pub(crate) const EXPERIENCE_FIELD_CAP: usize = 240;
pub(crate) const EXPERIENCE_LESSON_CAP: usize = 600;
pub(crate) const EXPERIENCE_TOTAL_CAP: usize = 4_000;
pub(crate) fn passes_write_gate(task: &str, outcome: &str, lesson: &str) -> bool {
!task.trim().is_empty()
&& !outcome.trim().is_empty()
&& lesson.trim().chars().count() >= MIN_LESSON_CHARS
}
fn terms(s: &str) -> Vec<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|t| t.chars().count() >= 3)
.map(str::to_lowercase)
.collect()
}
fn overlap(query_terms: &[String], text: &str) -> usize {
let tterms = terms(text);
query_terms
.iter()
.filter(|q| tterms.iter().any(|t| t == *q))
.count()
}
pub trait ExperienceStore: Send + Sync {
fn record(&self, task: &str, outcome: &str, lesson: &str) -> bool;
fn relevant(&self, query: &str, top_k: usize) -> Vec<Experience>;
fn count(&self) -> u64;
fn total_chars(&self) -> u64;
fn clear(&self);
}
#[derive(Default)]
pub struct SessionExperienceStore {
entries: Mutex<Vec<Experience>>,
}
impl ExperienceStore for SessionExperienceStore {
fn record(&self, task: &str, outcome: &str, lesson: &str) -> bool {
if !passes_write_gate(task, outcome, lesson) {
return false;
}
self.entries.lock().unwrap().push(Experience {
task: task.trim().to_string(),
outcome: outcome.trim().to_string(),
lesson: lesson.trim().to_string(),
});
true
}
fn relevant(&self, query: &str, top_k: usize) -> Vec<Experience> {
let qterms = terms(query);
if qterms.is_empty() || top_k == 0 {
return Vec::new();
}
let entries = self.entries.lock().unwrap();
let mut scored: Vec<(usize, usize, &Experience)> = entries
.iter()
.enumerate()
.map(|(i, e)| (overlap(&qterms, &format!("{} {}", e.task, e.lesson)), i, e))
.filter(|(s, _, _)| *s > 0)
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
scored
.into_iter()
.take(top_k)
.map(|(_, _, e)| e.clone())
.collect()
}
fn count(&self) -> u64 {
self.entries.lock().unwrap().len() as u64
}
fn total_chars(&self) -> u64 {
self.entries
.lock()
.unwrap()
.iter()
.map(|e| {
(e.task.chars().count() + e.outcome.chars().count() + e.lesson.chars().count())
as u64
})
.sum()
}
fn clear(&self) {
self.entries.lock().unwrap().clear();
}
}
fn truncate(s: &str, cap: usize) -> String {
if s.chars().count() <= cap {
s.to_string()
} else {
let head: String = s.chars().take(cap).collect();
format!("{head}[…]")
}
}
pub(crate) fn build_experience_block(
store: &dyn ExperienceStore,
query: &str,
top_k: usize,
total_cap: usize,
) -> Option<String> {
let hits = store.relevant(query, top_k);
if hits.is_empty() {
return None;
}
let mut body = String::from("<experience>\n");
for e in &hits {
let piece = format!(
"- task: {}\n outcome: {}\n lesson: {}\n",
truncate(&e.task, EXPERIENCE_FIELD_CAP),
truncate(&e.outcome, EXPERIENCE_FIELD_CAP),
truncate(&e.lesson, EXPERIENCE_LESSON_CAP),
);
if body.chars().count() + piece.chars().count() + "</experience>".len() > total_cap {
body.push_str("[… more experience omitted to fit the budget …]\n");
break;
}
body.push_str(&piece);
}
body.push_str("</experience>");
Some(body)
}
pub fn experience_block(store: &dyn ExperienceStore, query: &str, top_k: usize) -> Option<String> {
build_experience_block(store, query, top_k, EXPERIENCE_TOTAL_CAP)
}
pub fn experience_record_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "experience_record",
"description": "Record a reusable lesson from a finished task — a repair recipe \
that worked, a dead end to avoid, a gotcha. It persists ACROSS \
tasks this session (it survives /new) and the relevant ones are \
shown to you automatically on similar future tasks. Only \
substantive lessons are kept (a real outcome + a lesson of a \
sentence or so).",
"parameters": {
"type": "object",
"properties": {
"task": { "type": "string", "description": "What was being attempted (a short phrase)." },
"outcome": { "type": "string", "description": "How it turned out, e.g. 'fixed' / 'failed' / 'worked but slow'." },
"lesson": { "type": "string", "description": "The reusable takeaway — specific enough to act on next time." }
},
"required": ["task", "outcome", "lesson"]
}
}
})
}
pub fn experience_recall_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "experience_recall",
"description": "Search your recorded experiences for ones relevant to a topic \
(by keyword) — use it to check 'have I dealt with this before?' \
when the auto-injected block doesn't already cover it.",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "The topic / task to recall experience for." }
},
"required": ["query"]
}
}
})
}
pub(crate) fn execute_experience_record(
args: &serde_json::Value,
store: &dyn ExperienceStore,
color: bool,
tool_output_lines: usize,
) -> String {
let task = args["task"].as_str().unwrap_or("").trim();
let outcome = args["outcome"].as_str().unwrap_or("").trim();
let lesson = args["lesson"].as_str().unwrap_or("").trim();
print_tool_call("experience_record", task, color);
let out = if store.record(task, outcome, lesson) {
"recorded experience".to_string()
} else {
format!(
"not recorded — needs a non-empty task + outcome and a lesson of ≥{MIN_LESSON_CHARS} chars"
)
};
print_tool_output(&out, tool_output_lines, color);
out
}
pub(crate) fn execute_experience_recall(
args: &serde_json::Value,
store: &dyn ExperienceStore,
top_k: usize,
color: bool,
tool_output_lines: usize,
) -> String {
let query = args["query"].as_str().unwrap_or("").trim();
print_tool_call("experience_recall", query, color);
if query.is_empty() {
return "error: experience_recall requires a non-empty `query`".to_string();
}
let out = build_experience_block(store, query, top_k, EXPERIENCE_TOTAL_CAP)
.unwrap_or_else(|| "no relevant experience recorded yet".to_string());
print_tool_output(&out, tool_output_lines, color);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_gate_filters_trivial_records() {
assert!(passes_write_gate(
"fix lints",
"fixed",
"run cargo fmt before clippy"
));
assert!(
!passes_write_gate("", "fixed", "a real lesson here"),
"empty task"
);
assert!(
!passes_write_gate("t", "", "a real lesson here"),
"empty outcome"
);
assert!(
!passes_write_gate("t", "ok", "too short"),
"lesson < 12 chars"
);
}
#[test]
fn store_records_only_gated_and_tracks_stats() {
let s = SessionExperienceStore::default();
let (task, outcome, lesson) = ("parser", "fixed", "memoize the tokenizer here");
assert!(s.record(task, outcome, lesson));
assert!(!s.record("x", "y", "short"), "trivial rejected");
assert_eq!(s.count(), 1, "only the gated record stored");
let expected =
(task.chars().count() + outcome.chars().count() + lesson.chars().count()) as u64;
assert_eq!(s.total_chars(), expected);
s.clear();
assert_eq!(s.count(), 0);
assert_eq!(s.total_chars(), 0);
}
#[test]
fn relevant_ranks_by_overlap_then_recency() {
let s = SessionExperienceStore::default();
s.record(
"tokenizer perf",
"fixed",
"memoize tokenizer lookups for speed",
);
s.record(
"network retry",
"fixed",
"exponential backoff avoids thundering herd",
);
s.record(
"tokenizer crash",
"fixed",
"guard the tokenizer against empty input",
);
let hits = s.relevant("tokenizer problems", 5);
assert_eq!(hits.len(), 2, "only the two overlapping entries");
assert_eq!(
hits[0].task, "tokenizer crash",
"recency breaks the overlap tie"
);
assert!(hits.iter().all(|e| e.task != "network retry"));
assert!(s.relevant("kubernetes yaml", 5).is_empty());
assert!(s.relevant("tokenizer", 0).is_empty());
}
#[test]
fn build_block_none_when_irrelevant_and_caps() {
let s = SessionExperienceStore::default();
assert_eq!(
build_experience_block(&s, "anything", 5, 4000),
None,
"empty store"
);
s.record(
"indexing",
"ok",
"build the index lazily on first use, then cache it",
);
let block = build_experience_block(&s, "indexing strategy", 5, 4000).unwrap();
assert!(block.starts_with("<experience>\n") && block.ends_with("</experience>"));
assert!(block.contains("task: indexing") && block.contains("lesson: build the index"));
assert_eq!(build_experience_block(&s, "zzz", 5, 4000), None);
for i in 0..40 {
s.record(
&format!("indexing task {i}"),
"ok",
&"index lessons ".repeat(20),
);
}
let capped = build_experience_block(&s, "indexing", 50, 300).unwrap();
assert!(
capped.chars().count() <= 300 + 80,
"total cap bounds the block"
);
assert!(capped.contains("omitted to fit"));
}
#[test]
fn executors_record_gate_and_recall() {
let s = SessionExperienceStore::default();
assert_eq!(
execute_experience_record(
&serde_json::json!({"task": "ci flake", "outcome": "fixed", "lesson": "pin the seed for the fuzz test"}),
&s,
false,
20
),
"recorded experience"
);
assert!(execute_experience_record(
&serde_json::json!({"task": "x", "outcome": "y", "lesson": "short"}),
&s,
false,
20
)
.starts_with("not recorded"));
assert!(execute_experience_recall(
&serde_json::json!({"query": "ci flake seed"}),
&s,
5,
false,
20
)
.contains("<experience>"));
assert!(execute_experience_recall(
&serde_json::json!({"query": "unrelated topic"}),
&s,
5,
false,
20
)
.contains("no relevant experience"));
assert!(
execute_experience_recall(&serde_json::json!({}), &s, 5, false, 20)
.starts_with("error:")
);
}
#[test]
fn tool_definitions_shape() {
assert_eq!(
experience_record_tool_definition()["function"]["name"],
"experience_record"
);
assert_eq!(
experience_recall_tool_definition()["function"]["name"],
"experience_recall"
);
}
}