1use super::display::{print_tool_call, print_tool_output};
16use std::sync::Mutex;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Experience {
21 pub task: String,
22 pub outcome: String,
23 pub lesson: String,
24}
25
26pub const EXPERIENCE_TOP_K: usize = 5;
29pub(crate) const MIN_LESSON_CHARS: usize = 12;
32pub(crate) const EXPERIENCE_FIELD_CAP: usize = 240;
34pub(crate) const EXPERIENCE_LESSON_CAP: usize = 600;
35pub(crate) const EXPERIENCE_TOTAL_CAP: usize = 4_000;
37
38pub(crate) fn passes_write_gate(task: &str, outcome: &str, lesson: &str) -> bool {
42 !task.trim().is_empty()
43 && !outcome.trim().is_empty()
44 && lesson.trim().chars().count() >= MIN_LESSON_CHARS
45}
46
47fn terms(s: &str) -> Vec<String> {
50 s.split(|c: char| !c.is_alphanumeric())
51 .filter(|t| t.chars().count() >= 3)
52 .map(str::to_lowercase)
53 .collect()
54}
55
56fn overlap(query_terms: &[String], text: &str) -> usize {
58 let tterms = terms(text);
59 query_terms
60 .iter()
61 .filter(|q| tterms.iter().any(|t| t == *q))
62 .count()
63}
64
65pub trait ExperienceStore: Send + Sync {
69 fn record(&self, task: &str, outcome: &str, lesson: &str) -> bool;
71 fn relevant(&self, query: &str, top_k: usize) -> Vec<Experience>;
74 fn count(&self) -> u64;
76 fn total_chars(&self) -> u64;
78 fn clear(&self);
80}
81
82#[derive(Default)]
86pub struct SessionExperienceStore {
87 entries: Mutex<Vec<Experience>>,
88}
89
90impl ExperienceStore for SessionExperienceStore {
91 fn record(&self, task: &str, outcome: &str, lesson: &str) -> bool {
92 if !passes_write_gate(task, outcome, lesson) {
93 return false;
94 }
95 self.entries.lock().unwrap().push(Experience {
96 task: task.trim().to_string(),
97 outcome: outcome.trim().to_string(),
98 lesson: lesson.trim().to_string(),
99 });
100 true
101 }
102
103 fn relevant(&self, query: &str, top_k: usize) -> Vec<Experience> {
104 let qterms = terms(query);
105 if qterms.is_empty() || top_k == 0 {
106 return Vec::new();
107 }
108 let entries = self.entries.lock().unwrap();
109 let mut scored: Vec<(usize, usize, &Experience)> = entries
111 .iter()
112 .enumerate()
113 .map(|(i, e)| (overlap(&qterms, &format!("{} {}", e.task, e.lesson)), i, e))
114 .filter(|(s, _, _)| *s > 0)
115 .collect();
116 scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
118 scored
119 .into_iter()
120 .take(top_k)
121 .map(|(_, _, e)| e.clone())
122 .collect()
123 }
124
125 fn count(&self) -> u64 {
126 self.entries.lock().unwrap().len() as u64
127 }
128
129 fn total_chars(&self) -> u64 {
130 self.entries
131 .lock()
132 .unwrap()
133 .iter()
134 .map(|e| {
135 (e.task.chars().count() + e.outcome.chars().count() + e.lesson.chars().count())
136 as u64
137 })
138 .sum()
139 }
140
141 fn clear(&self) {
142 self.entries.lock().unwrap().clear();
143 }
144}
145
146fn truncate(s: &str, cap: usize) -> String {
147 if s.chars().count() <= cap {
148 s.to_string()
149 } else {
150 let head: String = s.chars().take(cap).collect();
151 format!("{head}[…]")
152 }
153}
154
155pub(crate) fn build_experience_block(
160 store: &dyn ExperienceStore,
161 query: &str,
162 top_k: usize,
163 total_cap: usize,
164) -> Option<String> {
165 let hits = store.relevant(query, top_k);
166 if hits.is_empty() {
167 return None;
168 }
169 let mut body = String::from("<experience>\n");
170 for e in &hits {
171 let piece = format!(
172 "- task: {}\n outcome: {}\n lesson: {}\n",
173 truncate(&e.task, EXPERIENCE_FIELD_CAP),
174 truncate(&e.outcome, EXPERIENCE_FIELD_CAP),
175 truncate(&e.lesson, EXPERIENCE_LESSON_CAP),
176 );
177 if body.chars().count() + piece.chars().count() + "</experience>".len() > total_cap {
178 body.push_str("[… more experience omitted to fit the budget …]\n");
179 break;
180 }
181 body.push_str(&piece);
182 }
183 body.push_str("</experience>");
184 Some(body)
185}
186
187pub fn experience_block(store: &dyn ExperienceStore, query: &str, top_k: usize) -> Option<String> {
190 build_experience_block(store, query, top_k, EXPERIENCE_TOTAL_CAP)
191}
192
193pub fn experience_record_tool_definition() -> serde_json::Value {
199 serde_json::json!({
200 "type": "function",
201 "function": {
202 "name": "experience_record",
203 "description": "Record a reusable lesson from a finished task — a repair recipe \
204 that worked, a dead end to avoid, a gotcha. It persists ACROSS \
205 tasks this session (it survives /new) and the relevant ones are \
206 shown to you automatically on similar future tasks. Only \
207 substantive lessons are kept (a real outcome + a lesson of a \
208 sentence or so).",
209 "parameters": {
210 "type": "object",
211 "properties": {
212 "task": { "type": "string", "description": "What was being attempted (a short phrase)." },
213 "outcome": { "type": "string", "description": "How it turned out, e.g. 'fixed' / 'failed' / 'worked but slow'." },
214 "lesson": { "type": "string", "description": "The reusable takeaway — specific enough to act on next time." }
215 },
216 "required": ["task", "outcome", "lesson"]
217 }
218 }
219 })
220}
221
222pub fn experience_recall_tool_definition() -> serde_json::Value {
224 serde_json::json!({
225 "type": "function",
226 "function": {
227 "name": "experience_recall",
228 "description": "Search your recorded experiences for ones relevant to a topic \
229 (by keyword) — use it to check 'have I dealt with this before?' \
230 when the auto-injected block doesn't already cover it.",
231 "parameters": {
232 "type": "object",
233 "properties": {
234 "query": { "type": "string", "description": "The topic / task to recall experience for." }
235 },
236 "required": ["query"]
237 }
238 }
239 })
240}
241
242pub(crate) fn execute_experience_record(
248 args: &serde_json::Value,
249 store: &dyn ExperienceStore,
250 color: bool,
251 tool_output_lines: usize,
252) -> String {
253 let task = args["task"].as_str().unwrap_or("").trim();
254 let outcome = args["outcome"].as_str().unwrap_or("").trim();
255 let lesson = args["lesson"].as_str().unwrap_or("").trim();
256 print_tool_call("experience_record", task, color);
257 let out = if store.record(task, outcome, lesson) {
258 "recorded experience".to_string()
259 } else {
260 format!(
261 "not recorded — needs a non-empty task + outcome and a lesson of ≥{MIN_LESSON_CHARS} chars"
262 )
263 };
264 print_tool_output(&out, tool_output_lines, color);
265 out
266}
267
268pub(crate) fn execute_experience_recall(
270 args: &serde_json::Value,
271 store: &dyn ExperienceStore,
272 top_k: usize,
273 color: bool,
274 tool_output_lines: usize,
275) -> String {
276 let query = args["query"].as_str().unwrap_or("").trim();
277 print_tool_call("experience_recall", query, color);
278 if query.is_empty() {
279 return "error: experience_recall requires a non-empty `query`".to_string();
280 }
281 let out = build_experience_block(store, query, top_k, EXPERIENCE_TOTAL_CAP)
282 .unwrap_or_else(|| "no relevant experience recorded yet".to_string());
283 print_tool_output(&out, tool_output_lines, color);
284 out
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn write_gate_filters_trivial_records() {
293 assert!(passes_write_gate(
294 "fix lints",
295 "fixed",
296 "run cargo fmt before clippy"
297 ));
298 assert!(
299 !passes_write_gate("", "fixed", "a real lesson here"),
300 "empty task"
301 );
302 assert!(
303 !passes_write_gate("t", "", "a real lesson here"),
304 "empty outcome"
305 );
306 assert!(
307 !passes_write_gate("t", "ok", "too short"),
308 "lesson < 12 chars"
309 );
310 }
311
312 #[test]
313 fn store_records_only_gated_and_tracks_stats() {
314 let s = SessionExperienceStore::default();
315 let (task, outcome, lesson) = ("parser", "fixed", "memoize the tokenizer here");
316 assert!(s.record(task, outcome, lesson));
317 assert!(!s.record("x", "y", "short"), "trivial rejected");
318 assert_eq!(s.count(), 1, "only the gated record stored");
319 let expected =
321 (task.chars().count() + outcome.chars().count() + lesson.chars().count()) as u64;
322 assert_eq!(s.total_chars(), expected);
323 s.clear();
324 assert_eq!(s.count(), 0);
325 assert_eq!(s.total_chars(), 0);
326 }
327
328 #[test]
329 fn relevant_ranks_by_overlap_then_recency() {
330 let s = SessionExperienceStore::default();
331 s.record(
332 "tokenizer perf",
333 "fixed",
334 "memoize tokenizer lookups for speed",
335 );
336 s.record(
337 "network retry",
338 "fixed",
339 "exponential backoff avoids thundering herd",
340 );
341 s.record(
342 "tokenizer crash",
343 "fixed",
344 "guard the tokenizer against empty input",
345 );
346 let hits = s.relevant("tokenizer problems", 5);
348 assert_eq!(hits.len(), 2, "only the two overlapping entries");
349 assert_eq!(
350 hits[0].task, "tokenizer crash",
351 "recency breaks the overlap tie"
352 );
353 assert!(hits.iter().all(|e| e.task != "network retry"));
354 assert!(s.relevant("kubernetes yaml", 5).is_empty());
356 assert!(s.relevant("tokenizer", 0).is_empty());
357 }
358
359 #[test]
360 fn build_block_none_when_irrelevant_and_caps() {
361 let s = SessionExperienceStore::default();
362 assert_eq!(
363 build_experience_block(&s, "anything", 5, 4000),
364 None,
365 "empty store"
366 );
367 s.record(
368 "indexing",
369 "ok",
370 "build the index lazily on first use, then cache it",
371 );
372 let block = build_experience_block(&s, "indexing strategy", 5, 4000).unwrap();
374 assert!(block.starts_with("<experience>\n") && block.ends_with("</experience>"));
375 assert!(block.contains("task: indexing") && block.contains("lesson: build the index"));
376 assert_eq!(build_experience_block(&s, "zzz", 5, 4000), None);
378 for i in 0..40 {
380 s.record(
381 &format!("indexing task {i}"),
382 "ok",
383 &"index lessons ".repeat(20),
384 );
385 }
386 let capped = build_experience_block(&s, "indexing", 50, 300).unwrap();
387 assert!(
388 capped.chars().count() <= 300 + 80,
389 "total cap bounds the block"
390 );
391 assert!(capped.contains("omitted to fit"));
392 }
393
394 #[test]
395 fn executors_record_gate_and_recall() {
396 let s = SessionExperienceStore::default();
397 assert_eq!(
399 execute_experience_record(
400 &serde_json::json!({"task": "ci flake", "outcome": "fixed", "lesson": "pin the seed for the fuzz test"}),
401 &s,
402 false,
403 20
404 ),
405 "recorded experience"
406 );
407 assert!(execute_experience_record(
408 &serde_json::json!({"task": "x", "outcome": "y", "lesson": "short"}),
409 &s,
410 false,
411 20
412 )
413 .starts_with("not recorded"));
414 assert!(execute_experience_recall(
416 &serde_json::json!({"query": "ci flake seed"}),
417 &s,
418 5,
419 false,
420 20
421 )
422 .contains("<experience>"));
423 assert!(execute_experience_recall(
424 &serde_json::json!({"query": "unrelated topic"}),
425 &s,
426 5,
427 false,
428 20
429 )
430 .contains("no relevant experience"));
431 assert!(
432 execute_experience_recall(&serde_json::json!({}), &s, 5, false, 20)
433 .starts_with("error:")
434 );
435 }
436
437 #[test]
438 fn tool_definitions_shape() {
439 assert_eq!(
440 experience_record_tool_definition()["function"]["name"],
441 "experience_record"
442 );
443 assert_eq!(
444 experience_recall_tool_definition()["function"]["name"],
445 "experience_recall"
446 );
447 }
448}