1use super::display::{print_tool_call, print_tool_output};
35
36#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum MemAddr {
41 Note { id: String },
44 Turn { conversation: String, seq: i64 },
48 Compaction { id: String },
52 Spill { id: String },
55}
56
57impl MemAddr {
58 pub fn parse(raw: &str) -> Option<Self> {
62 let raw = raw.trim();
63 if let Some(id) = raw.strip_prefix("note:") {
64 let id = id.trim();
65 if id.is_empty() {
66 return None;
67 }
68 return Some(Self::Note { id: id.to_string() });
69 }
70 if let Some(rest) = raw.strip_prefix("turn:") {
71 let (conv, seq) = rest.rsplit_once('#')?;
74 let conv = conv.trim();
75 let seq: i64 = seq.trim().parse().ok()?;
76 if conv.is_empty() {
77 return None;
78 }
79 return Some(Self::Turn {
80 conversation: conv.to_string(),
81 seq,
82 });
83 }
84 if let Some(id) = raw.strip_prefix("compaction:") {
85 let id = id.trim();
86 if id.is_empty() {
87 return None;
88 }
89 return Some(Self::Compaction { id: id.to_string() });
90 }
91 if let Some(id) = raw.strip_prefix("spill:") {
92 let id = id.trim();
93 if id.is_empty() {
94 return None;
95 }
96 return Some(Self::Spill { id: id.to_string() });
97 }
98 None
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum MemPayload {
107 Found(String),
109 NotFound { reason: String },
114}
115
116pub trait MemorySource: Send + Sync {
124 fn fetch(&self, addr: &MemAddr) -> anyhow::Result<MemPayload>;
130}
131
132pub struct StoreMemorySource<'a> {
140 notes: &'a crate::notes::NoteStore,
141 store: &'a crate::store::ConversationStore,
142 spill: Option<&'a dyn super::spill::SpillStore>,
146 compaction: Option<&'a dyn super::spill::SpillStore>,
152}
153
154impl<'a> StoreMemorySource<'a> {
155 pub fn new(
156 notes: &'a crate::notes::NoteStore,
157 store: &'a crate::store::ConversationStore,
158 ) -> Self {
159 Self {
160 notes,
161 store,
162 spill: None,
163 compaction: None,
164 }
165 }
166
167 #[must_use]
169 pub fn with_spill_store(mut self, spill: &'a dyn super::spill::SpillStore) -> Self {
170 self.spill = Some(spill);
171 self
172 }
173
174 #[must_use]
177 pub fn with_compaction_store(mut self, compaction: &'a dyn super::spill::SpillStore) -> Self {
178 self.compaction = Some(compaction);
179 self
180 }
181}
182
183impl MemorySource for StoreMemorySource<'_> {
184 fn fetch(&self, addr: &MemAddr) -> anyhow::Result<MemPayload> {
185 match addr {
186 MemAddr::Note { id } => match self.notes.body_by_id(id) {
187 Some(body) => Ok(MemPayload::Found(body.to_string())),
188 None => Ok(MemPayload::NotFound {
189 reason: format!(
190 "no note with id {id:?} — copy a `note:<id>` from the memory index"
191 ),
192 }),
193 },
194 MemAddr::Turn { conversation, seq } => {
195 match self.store.load_turn(conversation, *seq)? {
199 Some(turn) => Ok(MemPayload::Found(render_turn(&turn))),
200 None => Ok(MemPayload::NotFound {
201 reason: format!(
202 "no turn at seq {seq} in conversation {conversation:?} \
203 (or it is in another workspace)"
204 ),
205 }),
206 }
207 }
208 MemAddr::Compaction { id } => match self.compaction.and_then(|s| s.fetch(id)) {
212 Some(body) => Ok(MemPayload::Found(body)),
213 None => Ok(MemPayload::NotFound {
214 reason: format!(
215 "no compaction span with id {id:?} — spans are session-scoped \
216 and may have expired; re-read the file the breadcrumb names, \
217 or `recall` the topic"
218 ),
219 }),
220 },
221 MemAddr::Spill { id } => match self.spill.and_then(|s| s.fetch(id)) {
224 Some(body) => Ok(MemPayload::Found(body)),
225 None => Ok(MemPayload::NotFound {
226 reason: format!(
227 "no spilled payload with id {id:?} — spills are session-scoped \
228 and may have expired (re-run the tool if you still need it)"
229 ),
230 }),
231 },
232 }
233 }
234}
235
236fn render_turn(turn: &crate::ConversationTurn) -> String {
239 let mut out = String::new();
240 if !turn.user.is_empty() {
241 out.push_str("user: ");
242 out.push_str(&turn.user);
243 }
244 if !turn.assistant.is_empty() {
245 if !out.is_empty() {
246 out.push('\n');
247 }
248 out.push_str("assistant: ");
249 out.push_str(&turn.assistant);
250 }
251 out
252}
253
254const MEMORY_FETCH_DESCRIPTION: &str =
263 "Fetch the FULL verbatim body of one memory item by its address — a note, \
264 or one past turn. Use this to pull back an exact body you only have an \
265 INDEX line or a recall snippet for, instead of guessing its content. \
266 Addresses look like `note:3` (a numbered note from the memory index) or \
267 `turn:<conversation-id>#<seq>` (one past turn, e.g. \
268 `turn:174856320012#7` — copy the id and `seq N` from a recall hit), \
269 or `spill:<id>` (the full secret-redacted body of a tool output that was \
270 truncated for length — the `[… truncated …]` marker carries the id; add \
271 `grep` to return matching lines instead of the whole payload), \
272 or `compaction:<id>` (the full secret-redacted text of an earlier \
273 conversation span the compressor summarized away — the compaction summary \
274 names the id; use it to recover an exact detail the summary dropped). \
275 Reach for memory_fetch when you have an address but not the body; \
276 re-read the file with read_file if the content is a file still on disk; \
277 use recall to SEARCH past conversations when you don't have an address \
278 yet. One item per call; copy the address exactly as it was shown to you.";
279
280pub fn memory_fetch_tool_definition() -> serde_json::Value {
285 serde_json::json!({
286 "type": "function",
287 "function": {
288 "name": "memory_fetch",
289 "description": MEMORY_FETCH_DESCRIPTION,
290 "parameters": {
291 "type": "object",
292 "properties": {
293 "address": {
294 "type": "string",
295 "description": "The tagged address to fetch, e.g. \
296 'note:3', 'turn:174856320012#7', 'spill:s3', \
297 or 'compaction:s1' — copy it exactly as the \
298 index, a recall hit, a truncation marker, or \
299 a compaction summary showed it"
300 },
301 "grep": {
302 "type": "string",
303 "description": "Optional substring to search inside the fetched body. \
304 Useful for spill:<id> command output; returns matching \
305 lines with a small amount of context instead of the full \
306 payload."
307 }
308 },
309 "required": ["address"]
310 }
311 }
312 })
313}
314
315pub(crate) fn execute_memory_fetch(
332 args: &serde_json::Value,
333 source: &dyn MemorySource,
334 color: bool,
335 tool_output_lines: usize,
336) -> String {
337 let address = args["address"].as_str().unwrap_or("").trim();
338 let grep = args
339 .get("grep")
340 .and_then(serde_json::Value::as_str)
341 .map(str::trim)
342 .filter(|s| !s.is_empty());
343
344 print_tool_call("memory_fetch", address, color);
345
346 if address.is_empty() {
347 return "error: memory_fetch requires `address` — e.g. `note:3` or \
348 `turn:<conversation-id>#<seq>`"
349 .to_string();
350 }
351
352 let Some(addr) = MemAddr::parse(address) else {
353 let out = format!(
354 "{address:?} is not a memory address — they look like `note:3` \
355 (a numbered note) or `turn:174856320012#7` (a conversation id \
356 and `seq` from a recall hit). Copy one exactly as it was shown."
357 );
358 print_tool_output(&out, tool_output_lines, color);
359 return out;
360 };
361
362 let payload = match source.fetch(&addr) {
363 Ok(p) => p,
364 Err(e) => return format!("error: {e}"),
365 };
366
367 let out = match payload {
368 MemPayload::Found(body) => match grep {
369 Some(pattern) => grep_payload(&body, pattern),
370 None => body,
371 },
372 MemPayload::NotFound { reason } => format!("no such memory item: {reason}"),
373 };
374 print_tool_output(&out, tool_output_lines, color);
375 out
376}
377
378fn grep_payload(body: &str, pattern: &str) -> String {
379 const CONTEXT: usize = 2;
380 const MAX_MATCHES: usize = 40;
381 let lines: Vec<&str> = body.lines().collect();
382 let mut ranges: Vec<(usize, usize)> = Vec::new();
383 for (idx, line) in lines.iter().enumerate() {
384 if line.contains(pattern) {
385 if ranges.len() >= MAX_MATCHES {
386 break;
387 }
388 let start = idx.saturating_sub(CONTEXT);
389 let end = (idx + CONTEXT + 1).min(lines.len());
390 if let Some((_, prev_end)) = ranges.last_mut() {
391 if start <= *prev_end {
392 *prev_end = end.max(*prev_end);
393 continue;
394 }
395 }
396 ranges.push((start, end));
397 }
398 }
399 if ranges.is_empty() {
400 return format!("no matches for {pattern:?} in fetched payload");
401 }
402 let mut out = format!("matches for {pattern:?} in fetched payload:");
403 for (range_idx, (start, end)) in ranges.iter().enumerate() {
404 if range_idx > 0 {
405 out.push_str("\n--");
406 }
407 for (line_idx, line) in lines.iter().enumerate().take(*end).skip(*start) {
408 use std::fmt::Write as _;
409 let _ = write!(out, "\n{:>6}: {}", line_idx + 1, line);
410 }
411 }
412 if lines.iter().filter(|line| line.contains(pattern)).count() > MAX_MATCHES {
413 out.push_str("\n[additional matches omitted; use a narrower grep pattern]");
414 }
415 out
416}
417
418#[cfg(test)]
423pub(crate) mod tests {
424 use super::*;
425 use std::sync::Mutex;
426
427 fn test_store(dir: &tempfile::TempDir) -> crate::store::ConversationStore {
431 let ws = dir.path().join("ws");
432 std::fs::create_dir_all(&ws).unwrap();
433 crate::store::ConversationStore::new(dir.path().join("root"), &ws, 10).unwrap()
434 }
435
436 #[derive(Default)]
440 pub(crate) struct MockSource {
441 pub calls: Mutex<Vec<MemAddr>>,
442 pub body: Option<String>,
444 pub fail_with: Option<String>,
445 }
446
447 impl MemorySource for MockSource {
448 fn fetch(&self, addr: &MemAddr) -> anyhow::Result<MemPayload> {
449 self.calls.lock().unwrap().push(addr.clone());
450 if let Some(e) = &self.fail_with {
451 return Err(anyhow::anyhow!("{e}"));
452 }
453 match &self.body {
454 Some(b) => Ok(MemPayload::Found(b.clone())),
455 None => Ok(MemPayload::NotFound {
456 reason: "mock has no such item".to_string(),
457 }),
458 }
459 }
460 }
461
462 #[test]
465 fn parse_note_turn_and_compaction_forms() {
466 assert_eq!(
467 MemAddr::parse("note:3"),
468 Some(MemAddr::Note { id: "3".into() })
469 );
470 assert_eq!(
471 MemAddr::parse(" turn:174856320012#7 "),
472 Some(MemAddr::Turn {
473 conversation: "174856320012".into(),
474 seq: 7
475 })
476 );
477 assert_eq!(
478 MemAddr::parse("compaction:abc"),
479 Some(MemAddr::Compaction { id: "abc".into() })
480 );
481 assert_eq!(
483 MemAddr::parse(" spill:s3 "),
484 Some(MemAddr::Spill { id: "s3".into() })
485 );
486 assert_eq!(MemAddr::parse("spill:"), None);
487 }
488
489 #[test]
490 fn parse_rejects_malformed_addresses() {
491 assert_eq!(MemAddr::parse("just some text"), None);
493 assert_eq!(MemAddr::parse("note:"), None);
494 assert_eq!(MemAddr::parse("turn:174856320012"), None);
495 assert_eq!(MemAddr::parse("turn:174856320012#notanumber"), None);
496 assert_eq!(MemAddr::parse("turn:#7"), None);
497 assert_eq!(MemAddr::parse("compaction:"), None);
498 }
499
500 #[test]
501 fn parse_turn_takes_last_hash_so_conversation_ids_may_contain_hash() {
502 assert_eq!(
504 MemAddr::parse("turn:conv#with#hash#42"),
505 Some(MemAddr::Turn {
506 conversation: "conv#with#hash".into(),
507 seq: 42
508 })
509 );
510 }
511
512 #[test]
515 fn schema_shows_the_address_forms_by_example() {
516 let def = memory_fetch_tool_definition();
517 let desc = def["function"]["description"].as_str().unwrap();
518 assert!(desc.contains("note:3"), "got: {desc}");
519 assert!(desc.contains("turn:174856320012#7"), "got: {desc}");
520 }
521
522 #[test]
523 fn schema_distinguishes_fetch_from_reread_and_recall() {
524 let def = memory_fetch_tool_definition();
525 let desc = def["function"]["description"].as_str().unwrap();
526 assert!(desc.contains("re-read the file"), "got: {desc}");
527 assert!(desc.contains("read_file"), "got: {desc}");
528 assert!(desc.contains("use recall to SEARCH"), "got: {desc}");
529 }
530
531 #[test]
532 fn schema_shape_address_required() {
533 let def = memory_fetch_tool_definition();
534 assert_eq!(def["function"]["name"], "memory_fetch");
535 let required: Vec<&str> = def["function"]["parameters"]["required"]
536 .as_array()
537 .unwrap()
538 .iter()
539 .filter_map(|v| v.as_str())
540 .collect();
541 assert_eq!(required, vec!["address"]);
542 assert!(def["function"]["parameters"]["properties"]["address"].is_object());
543 }
544
545 #[test]
548 fn found_returns_the_verbatim_body() {
549 let source = MockSource {
550 body: Some("the exact bytes".to_string()),
551 ..Default::default()
552 };
553 let out = execute_memory_fetch(
554 &serde_json::json!({"address": "note:2"}),
555 &source,
556 false,
557 20,
558 );
559 assert_eq!(out, "the exact bytes");
560 assert_eq!(
561 *source.calls.lock().unwrap(),
562 vec![MemAddr::Note { id: "2".into() }]
563 );
564 }
565
566 #[test]
567 fn grep_returns_matching_lines_with_context() {
568 let source = MockSource {
569 body: Some("first\nbefore\nneedle here\nafter\nlast\n".to_string()),
570 ..Default::default()
571 };
572 let out = execute_memory_fetch(
573 &serde_json::json!({"address": "spill:s0", "grep": "needle"}),
574 &source,
575 false,
576 20,
577 );
578 assert!(out.contains("matches for \"needle\""), "{out}");
579 assert!(out.contains(" 2: before"), "{out}");
580 assert!(out.contains(" 3: needle here"), "{out}");
581 assert!(out.contains(" 4: after"), "{out}");
582 assert!(
583 !out.contains("first\nbefore"),
584 "should render numbered lines"
585 );
586 }
587
588 #[test]
589 fn grep_reports_no_matches_without_returning_full_payload() {
590 let source = MockSource {
591 body: Some("alpha\nbeta\ngamma\n".to_string()),
592 ..Default::default()
593 };
594 let out = execute_memory_fetch(
595 &serde_json::json!({"address": "spill:s0", "grep": "delta"}),
596 &source,
597 false,
598 20,
599 );
600 assert!(out.contains("no matches for \"delta\""), "{out}");
601 assert!(!out.contains("alpha\nbeta"), "{out}");
602 }
603
604 #[test]
605 fn not_found_is_labelled_absence_never_empty() {
606 let source = MockSource::default(); let out = execute_memory_fetch(
608 &serde_json::json!({"address": "note:99"}),
609 &source,
610 false,
611 20,
612 );
613 assert!(out.starts_with("no such memory item:"), "got: {out}");
614 assert!(!out.is_empty());
615 assert!(!out.starts_with("error:"), "must not abort-shape: {out}");
616 }
617
618 #[test]
619 fn malformed_address_is_coaching_not_error_and_never_hits_backend() {
620 let source = MockSource::default();
621 let out = execute_memory_fetch(
622 &serde_json::json!({"address": "give me the api signatures"}),
623 &source,
624 false,
625 20,
626 );
627 assert!(out.contains("is not a memory address"), "got: {out}");
628 assert!(out.contains("note:3"), "coaching shows the forms: {out}");
629 assert!(!out.starts_with("error:"), "must not abort-shape: {out}");
630 assert!(
631 source.calls.lock().unwrap().is_empty(),
632 "a malformed address must never reach the backend"
633 );
634 }
635
636 #[test]
637 fn missing_address_is_a_clear_error() {
638 let source = MockSource::default();
639 let out = execute_memory_fetch(&serde_json::json!({}), &source, false, 20);
640 assert!(out.contains("requires `address`"), "got: {out}");
641 assert!(source.calls.lock().unwrap().is_empty());
642 }
643
644 #[test]
645 fn backend_failure_surfaces_as_tool_error_text() {
646 let source = MockSource {
647 fail_with: Some("store is on fire".to_string()),
648 ..Default::default()
649 };
650 let out = execute_memory_fetch(
651 &serde_json::json!({"address": "turn:abc#1"}),
652 &source,
653 false,
654 20,
655 );
656 assert_eq!(out, "error: store is on fire");
657 }
658
659 #[test]
660 fn compaction_address_resolves_via_the_session_compaction_store() {
661 use crate::agentic::spill::{SessionSpillStore, SpillStore};
662 let dir = tempfile::tempdir().unwrap();
666 let notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
667 let store = test_store(&dir);
668 let compaction = SessionSpillStore::default();
669 let id = compaction.store("the verbatim evicted middle".to_string());
670
671 let source = StoreMemorySource::new(¬es, &store).with_compaction_store(&compaction);
672 let hit = execute_memory_fetch(
673 &serde_json::json!({ "address": format!("compaction:{id}") }),
674 &source,
675 false,
676 20,
677 );
678 assert_eq!(hit, "the verbatim evicted middle");
679
680 let miss = execute_memory_fetch(
681 &serde_json::json!({"address": "compaction:s99"}),
682 &source,
683 false,
684 20,
685 );
686 assert!(miss.contains("session-scoped"), "got: {miss}");
687
688 let spill = SessionSpillStore::default();
691 spill.store("a tool payload".to_string()); let wrong = StoreMemorySource::new(¬es, &store).with_spill_store(&spill);
693 let none = execute_memory_fetch(
694 &serde_json::json!({"address": "compaction:s0"}),
695 &wrong,
696 false,
697 20,
698 );
699 assert!(none.starts_with("no such memory item:"), "got: {none}");
700 }
701
702 #[test]
703 fn spill_address_resolves_via_the_session_spill_store() {
704 use crate::agentic::spill::{SessionSpillStore, SpillStore};
705 let dir = tempfile::tempdir().unwrap();
708 let notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
709 let store = test_store(&dir);
710 let spill = SessionSpillStore::default();
711 let id = spill.store("the full redacted payload".to_string());
712
713 let source = StoreMemorySource::new(¬es, &store).with_spill_store(&spill);
714 let hit = execute_memory_fetch(
715 &serde_json::json!({ "address": format!("spill:{id}") }),
716 &source,
717 false,
718 20,
719 );
720 assert_eq!(hit, "the full redacted payload");
721
722 let miss = execute_memory_fetch(
723 &serde_json::json!({"address": "spill:s99"}),
724 &source,
725 false,
726 20,
727 );
728 assert!(miss.contains("session-scoped"), "got: {miss}");
729
730 let no_store = StoreMemorySource::new(¬es, &store);
732 let none = execute_memory_fetch(
733 &serde_json::json!({"address": "spill:s0"}),
734 &no_store,
735 false,
736 20,
737 );
738 assert!(none.starts_with("no such memory item:"), "got: {none}");
739 }
740
741 #[tokio::test]
744 async fn resolves_a_real_note_body() {
745 use crate::memory::{MemoryProvider, SessionContext};
746 let dir = tempfile::tempdir().unwrap();
747 let mut notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
748 notes
749 .initialize(&SessionContext {
750 workspace: dir.path().to_string_lossy().into(),
751 session_id: "s".into(),
752 })
753 .await
754 .unwrap();
755 notes.add("first note body").unwrap();
756 notes.add("second\nmulti-line note").unwrap();
757 let store = test_store(&dir);
758 let source = StoreMemorySource::new(¬es, &store);
759
760 let out = execute_memory_fetch(
762 &serde_json::json!({"address": "note:2"}),
763 &source,
764 false,
765 20,
766 );
767 assert_eq!(out, "second\nmulti-line note");
768 let miss = execute_memory_fetch(
770 &serde_json::json!({"address": "note:9"}),
771 &source,
772 false,
773 20,
774 );
775 assert!(miss.starts_with("no such memory item:"), "got: {miss}");
776 }
777
778 #[test]
779 fn resolves_a_real_turn_by_conv_and_seq() {
780 let dir = tempfile::tempdir().unwrap();
781 let notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
782 let store = test_store(&dir);
783 let conv = store.create("a conversation", None).unwrap();
784 store
785 .append_turn(
786 &conv,
787 "what is the connect signature?",
788 "fn connect(addr: &str)",
789 )
790 .unwrap();
791 let hits = store.search("connect", 5).unwrap();
794 assert!(!hits.is_empty(), "the turn must be searchable");
795 let seq = hits[0].seq;
796
797 let source = StoreMemorySource::new(¬es, &store);
798 let out = execute_memory_fetch(
799 &serde_json::json!({"address": format!("turn:{conv}#{seq}")}),
800 &source,
801 false,
802 20,
803 );
804 assert!(
805 out.contains("user: what is the connect signature?"),
806 "got: {out}"
807 );
808 assert!(
809 out.contains("assistant: fn connect(addr: &str)"),
810 "got: {out}"
811 );
812
813 let miss = execute_memory_fetch(
815 &serde_json::json!({"address": format!("turn:{conv}#999999")}),
816 &source,
817 false,
818 20,
819 );
820 assert!(miss.starts_with("no such memory item:"), "got: {miss}");
821 assert!(!miss.starts_with("error:"), "got: {miss}");
822 }
823
824 #[test]
825 fn turn_from_another_conversation_id_is_absence_not_leak() {
826 let dir = tempfile::tempdir().unwrap();
827 let notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
828 let store = test_store(&dir);
829 let source = StoreMemorySource::new(¬es, &store);
830 let out = execute_memory_fetch(
832 &serde_json::json!({"address": "turn:nonexistent-conv#1"}),
833 &source,
834 false,
835 20,
836 );
837 assert!(out.starts_with("no such memory item:"), "got: {out}");
838 }
839}