Skip to main content

mnemo_core/
retrieval.rs

1//! v0.4.4 — `RetrievalMode` typed enum + 5 starter `HarnessAware`
2//! adapters.
3//!
4//! # What this module is
5//!
6//! A typed superset of the existing
7//! [`RecallRequest::strategy: Option<String>`][crate::query::recall::RecallRequest]
8//! field, plus a new `HarnessAware` variant that lets the recall
9//! response envelope be reshaped per agent harness (Claude Code,
10//! Codex, Gemini CLI, Chronos, generic) per the framing in arXiv
11//! 2605.15184: *"overall scores still depend strongly on which
12//! harness and tool-calling style is used, even when the underlying
13//! conversation data are the same."*
14//!
15//! # Backwards-compatible introduction
16//!
17//! [`RecallRequest`][crate::query::recall::RecallRequest] gains an
18//! optional `mode: Option<RetrievalMode>` field in this release. The
19//! legacy `strategy: Option<String>` field stays in place; if `mode`
20//! is set it takes precedence, otherwise the engine continues to
21//! parse `strategy` exactly as before. Existing SDK callers
22//! (Python `mnemo-db`, TypeScript `@mndfreek/mnemo-sdk`, Go
23//! `mnemo.Recall`) continue to work unchanged because they all
24//! marshal through the string-typed field.
25//!
26//! # `HarnessAware` semantics
27//!
28//! `HarnessAware { harness, format }` does NOT change which records
29//! are retrieved — under the hood it delegates to the default
30//! `HybridRrf` retrieval path. What it changes is how the
31//! [`crate::query::recall::ScoredMemory`] hits are *shaped* into a
32//! string envelope that a specific agent harness prefers (inline
33//! fenced blocks, file-based side-channel pointers with line
34//! numbers, generic line-numbered list, …). The
35//! [`HarnessEnvelope::shape`] method returns the rendered envelope
36//! string; the recall response continues to carry the typed
37//! `ScoredMemory` hits so downstream consumers that want the typed
38//! payload are not blocked.
39//!
40//! # Not in scope for v0.4.4
41//!
42//! - **No SDK ripple.** The Python / TypeScript / Go SDKs are NOT
43//!   updated in this release. They continue to use the string-typed
44//!   `strategy` field. SDK migration to a typed `mode` field is a
45//!   follow-up tracked separately.
46//! - **No REST / gRPC / pgwire schema bump.** The new `mode` field
47//!   serialises through the same `RecallRequest` Serde definition;
48//!   inbound JSON that omits `mode` continues to work.
49//! - **No envelope-trait stabilisation.** The
50//!   [`HarnessEnvelope`] trait + the five adapter structs are
51//!   intentionally minimal — each adapter produces a deterministic
52//!   string with the shape the corresponding harness expects, but
53//!   the *contents* of those strings are not a stability surface in
54//!   v0.4.4. Operators relying on a specific envelope shape should
55//!   pin the mnemo minor version.
56
57use std::path::PathBuf;
58
59use serde::{Deserialize, Serialize};
60
61use crate::query::recall::ScoredMemory;
62
63/// Typed recall strategy. Superset of the legacy
64/// `RecallRequest.strategy: Option<String>` API — the variant ↔ string
65/// mapping is documented on each variant.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum RetrievalMode {
69    /// Maps to legacy `strategy = "semantic"` — vector-only path.
70    VectorOnly,
71    /// Maps to legacy `strategy = "lexical"` — Tantivy BM25-only
72    /// path.
73    Bm25Only,
74    /// Maps to legacy `strategy = "auto"` — default RRF fusion across
75    /// vector + BM25 + recency + decay. Weight overrides continue to
76    /// be carried on [`RecallRequest.hybrid_weights`][crate::query::recall::RecallRequest::hybrid_weights]
77    /// and [`RecallRequest.rrf_k`][crate::query::recall::RecallRequest::rrf_k]
78    /// to keep wire compatibility with v0.4.3 SDK clients.
79    HybridRrf,
80    /// Maps to legacy `strategy = "graph"` — vector-seeded +
81    /// graph-expanded path.
82    Graph,
83    /// New in v0.4.4 — harness-aware envelope reshaping. Inside the
84    /// recall path this delegates to [`RetrievalMode::HybridRrf`];
85    /// the difference is post-processing: a
86    /// [`HarnessEnvelope`] adapter renders the typed
87    /// [`ScoredMemory`] hits into a string envelope shaped for the
88    /// nominated agent harness.
89    HarnessAware {
90        harness: HarnessKind,
91        format: EnvelopeFormat,
92    },
93}
94
95impl RetrievalMode {
96    /// Map the typed variant back to the legacy strategy string the
97    /// engine dispatcher understands. `HarnessAware` delegates to
98    /// `"auto"` (HybridRrf) for the underlying retrieval; the envelope
99    /// adapter handles the post-processing separately.
100    pub fn to_strategy_str(&self) -> &'static str {
101        match self {
102            Self::VectorOnly => "semantic",
103            Self::Bm25Only => "lexical",
104            Self::HybridRrf | Self::HarnessAware { .. } => "auto",
105            Self::Graph => "graph",
106        }
107    }
108
109    /// Optional envelope adapter for `HarnessAware`; returns `None`
110    /// for every other variant. Each adapter is a unit struct (or
111    /// a small config struct); call
112    /// [`HarnessEnvelope::shape`] to render the envelope string.
113    pub fn envelope_adapter(&self) -> Option<Box<dyn HarnessEnvelope>> {
114        let Self::HarnessAware { harness, format } = self else {
115            return None;
116        };
117        Some(adapter_for(*harness, format.clone()))
118    }
119}
120
121/// Which agent harness the response envelope should be shaped for.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum HarnessKind {
125    ClaudeCode,
126    Codex,
127    GeminiCli,
128    Chronos,
129    Generic,
130}
131
132/// Where the envelope payload lives — inline in the response, written
133/// to a file the harness reads via a side-channel pointer, or written
134/// to a side-channel out-of-band stream.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum EnvelopeFormat {
138    Inline,
139    FileBased { path_root: PathBuf },
140    SideChannel,
141}
142
143/// Trait implemented by each per-harness adapter. The contract is
144/// minimal: take a slice of typed [`ScoredMemory`] hits and return a
145/// rendered string envelope shaped for the harness.
146pub trait HarnessEnvelope {
147    fn shape(&self, hits: &[ScoredMemory]) -> String;
148}
149
150fn adapter_for(kind: HarnessKind, format: EnvelopeFormat) -> Box<dyn HarnessEnvelope> {
151    match kind {
152        HarnessKind::ClaudeCode => Box::new(ClaudeCodeEnvelope {
153            inline: matches!(format, EnvelopeFormat::Inline),
154        }),
155        HarnessKind::Codex => Box::new(CodexEnvelope {
156            file_based: matches!(format, EnvelopeFormat::FileBased { .. }),
157        }),
158        HarnessKind::GeminiCli => Box::new(GeminiCliEnvelope),
159        HarnessKind::Chronos => Box::new(ChronosEnvelope),
160        HarnessKind::Generic => Box::new(GenericEnvelope),
161    }
162}
163
164/// Claude Code envelope — fenced markdown blocks with `recall://<id>`
165/// anchors for inline; line-numbered file-pointer summary for the
166/// non-inline branch.
167#[derive(Debug, Clone, Copy)]
168pub struct ClaudeCodeEnvelope {
169    pub inline: bool,
170}
171
172impl HarnessEnvelope for ClaudeCodeEnvelope {
173    fn shape(&self, hits: &[ScoredMemory]) -> String {
174        let mut out = String::new();
175        out.push_str("# mnemo.recall (Claude Code envelope)\n\n");
176        for (i, m) in hits.iter().enumerate() {
177            if self.inline {
178                out.push_str(&format!(
179                    "## hit {} (recall://{} • score {:.3})\n```\n{}\n```\n\n",
180                    i + 1,
181                    m.id,
182                    m.score,
183                    m.content
184                ));
185            } else {
186                let first_line = m.content.lines().next().unwrap_or("").trim();
187                out.push_str(&format!(
188                    "- hit {} → `recall://{}` (score {:.3}): {}\n",
189                    i + 1,
190                    m.id,
191                    m.score,
192                    first_line
193                ));
194            }
195        }
196        out
197    }
198}
199
200/// Codex envelope — file-based by default (writes hits to a path-root
201/// the caller chose), with an inline JSON pointer summary in the
202/// response. The Inline branch keeps the raw content in the response.
203#[derive(Debug, Clone, Copy)]
204pub struct CodexEnvelope {
205    pub file_based: bool,
206}
207
208impl HarnessEnvelope for CodexEnvelope {
209    fn shape(&self, hits: &[ScoredMemory]) -> String {
210        if self.file_based {
211            let pointers: Vec<String> = hits
212                .iter()
213                .map(|m| format!("{{\"id\":\"{}\",\"score\":{:.3}}}", m.id, m.score))
214                .collect();
215            format!(
216                "{{\"envelope\":\"codex_file_based\",\"hits\":[{}]}}",
217                pointers.join(",")
218            )
219        } else {
220            let blocks: Vec<String> = hits
221                .iter()
222                .map(|m| {
223                    format!(
224                        "{{\"id\":\"{}\",\"score\":{:.3},\"content\":{}}}",
225                        m.id,
226                        m.score,
227                        serde_json::to_string(&m.content).unwrap_or_default()
228                    )
229                })
230                .collect();
231            format!(
232                "{{\"envelope\":\"codex_inline\",\"hits\":[{}]}}",
233                blocks.join(",")
234            )
235        }
236    }
237}
238
239/// Gemini CLI envelope — plain numbered list with `[N]` markers + the
240/// hit content; tool-call-style framing the Gemini CLI surfaces well.
241#[derive(Debug, Clone, Copy)]
242pub struct GeminiCliEnvelope;
243
244impl HarnessEnvelope for GeminiCliEnvelope {
245    fn shape(&self, hits: &[ScoredMemory]) -> String {
246        let mut out = String::new();
247        out.push_str("mnemo recall (Gemini CLI envelope)\n");
248        for (i, m) in hits.iter().enumerate() {
249            out.push_str(&format!(
250                "[{}] score={:.3} id={} — {}\n",
251                i + 1,
252                m.score,
253                m.id,
254                m.content
255            ));
256        }
257        out
258    }
259}
260
261/// Chronos envelope — timeline-shaped: one line per hit with the hit
262/// `id`, score, and the first line of content. Chronos prefers
263/// temporally-anchored single-line summaries.
264#[derive(Debug, Clone, Copy)]
265pub struct ChronosEnvelope;
266
267impl HarnessEnvelope for ChronosEnvelope {
268    fn shape(&self, hits: &[ScoredMemory]) -> String {
269        let mut out = String::new();
270        out.push_str("chronos recall envelope\n");
271        for m in hits {
272            let first_line = m.content.lines().next().unwrap_or("").trim();
273            out.push_str(&format!("t={:.3} id={} :: {}\n", m.score, m.id, first_line));
274        }
275        out
276    }
277}
278
279/// Generic envelope — minimal `id\tscore\tcontent` TSV one line per
280/// hit. The fallback when no harness-specific adapter applies.
281#[derive(Debug, Clone, Copy)]
282pub struct GenericEnvelope;
283
284impl HarnessEnvelope for GenericEnvelope {
285    fn shape(&self, hits: &[ScoredMemory]) -> String {
286        let mut out = String::new();
287        for m in hits {
288            // TSV-safe: replace tabs/newlines in content so the
289            // generic envelope stays parseable.
290            let content_safe = m.content.replace(['\t', '\n', '\r'], " ");
291            out.push_str(&format!("{}\t{:.3}\t{}\n", m.id, m.score, content_safe));
292        }
293        out
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::model::memory::{MemoryType, Scope};
301    use uuid::Uuid;
302
303    fn make_hit(content: &str, score: f32) -> ScoredMemory {
304        ScoredMemory {
305            id: Uuid::now_v7(),
306            content: content.to_string(),
307            agent_id: "test-agent".to_string(),
308            memory_type: MemoryType::Episodic,
309            scope: Scope::Private,
310            importance: 0.5,
311            tags: vec![],
312            metadata: serde_json::Value::Null,
313            score,
314            access_count: 0,
315            created_at: "2026-05-17T00:00:00Z".to_string(),
316            updated_at: "2026-05-17T00:00:00Z".to_string(),
317            score_breakdown: None,
318        }
319    }
320
321    #[test]
322    fn retrieval_mode_round_trip_strategy_string() {
323        assert_eq!(RetrievalMode::VectorOnly.to_strategy_str(), "semantic");
324        assert_eq!(RetrievalMode::Bm25Only.to_strategy_str(), "lexical");
325        assert_eq!(RetrievalMode::HybridRrf.to_strategy_str(), "auto");
326        assert_eq!(RetrievalMode::Graph.to_strategy_str(), "graph");
327        let harness = RetrievalMode::HarnessAware {
328            harness: HarnessKind::ClaudeCode,
329            format: EnvelopeFormat::Inline,
330        };
331        // HarnessAware delegates to "auto" for the underlying
332        // retrieval — the adapter handles envelope post-processing.
333        assert_eq!(harness.to_strategy_str(), "auto");
334    }
335
336    #[test]
337    fn retrieval_mode_serde_round_trip() {
338        for mode in [
339            RetrievalMode::VectorOnly,
340            RetrievalMode::Bm25Only,
341            RetrievalMode::HybridRrf,
342            RetrievalMode::Graph,
343            RetrievalMode::HarnessAware {
344                harness: HarnessKind::ClaudeCode,
345                format: EnvelopeFormat::Inline,
346            },
347            RetrievalMode::HarnessAware {
348                harness: HarnessKind::Codex,
349                format: EnvelopeFormat::FileBased {
350                    path_root: PathBuf::from("/tmp/codex"),
351                },
352            },
353            RetrievalMode::HarnessAware {
354                harness: HarnessKind::Generic,
355                format: EnvelopeFormat::SideChannel,
356            },
357        ] {
358            let s = serde_json::to_string(&mode).unwrap();
359            let back: RetrievalMode = serde_json::from_str(&s).unwrap();
360            assert_eq!(mode, back, "round-trip failed for {mode:?} via {s}");
361        }
362    }
363
364    #[test]
365    fn harness_aware_returns_envelope_adapter() {
366        let mode = RetrievalMode::HarnessAware {
367            harness: HarnessKind::ClaudeCode,
368            format: EnvelopeFormat::Inline,
369        };
370        assert!(mode.envelope_adapter().is_some());
371        assert!(RetrievalMode::HybridRrf.envelope_adapter().is_none());
372    }
373
374    #[test]
375    fn five_adapters_produce_distinct_envelope_shapes() {
376        let hits = vec![
377            make_hit("first hit content line\nsecond line", 0.91),
378            make_hit("another hit", 0.42),
379        ];
380        let cc = ClaudeCodeEnvelope { inline: true }.shape(&hits);
381        let codex = CodexEnvelope { file_based: true }.shape(&hits);
382        let gemini = GeminiCliEnvelope.shape(&hits);
383        let chronos = ChronosEnvelope.shape(&hits);
384        let generic = GenericEnvelope.shape(&hits);
385        // Each adapter must produce a distinct shape — the whole
386        // point of HarnessAware is per-harness reshaping.
387        let shapes = [&cc, &codex, &gemini, &chronos, &generic];
388        for (i, a) in shapes.iter().enumerate() {
389            for (j, b) in shapes.iter().enumerate() {
390                if i != j {
391                    assert_ne!(
392                        a, b,
393                        "adapter shapes {} and {} collided (both produced:\n{a})",
394                        i, j
395                    );
396                }
397            }
398        }
399    }
400
401    #[test]
402    fn claude_code_envelope_inline_vs_non_inline_differ() {
403        let hits = vec![make_hit("hello world", 0.5)];
404        let inline = ClaudeCodeEnvelope { inline: true }.shape(&hits);
405        let non_inline = ClaudeCodeEnvelope { inline: false }.shape(&hits);
406        assert!(inline.contains("```"), "inline must contain fenced block");
407        assert!(
408            !non_inline.contains("```"),
409            "non-inline must not contain fenced block"
410        );
411    }
412
413    #[test]
414    fn generic_envelope_is_tsv_safe() {
415        let hits = vec![make_hit("has\ttab\nand newline", 0.5)];
416        let env = GenericEnvelope.shape(&hits);
417        // Exactly one record line — the inner \t and \n in content
418        // must have been replaced with spaces.
419        assert_eq!(env.lines().count(), 1);
420        let parts: Vec<&str> = env.trim_end().split('\t').collect();
421        assert_eq!(
422            parts.len(),
423            3,
424            "TSV envelope must have id\\tscore\\tcontent"
425        );
426    }
427}