use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::query::recall::ScoredMemory;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetrievalMode {
VectorOnly,
Bm25Only,
HybridRrf,
Graph,
HarnessAware {
harness: HarnessKind,
format: EnvelopeFormat,
},
}
impl RetrievalMode {
pub fn to_strategy_str(&self) -> &'static str {
match self {
Self::VectorOnly => "semantic",
Self::Bm25Only => "lexical",
Self::HybridRrf | Self::HarnessAware { .. } => "auto",
Self::Graph => "graph",
}
}
pub fn envelope_adapter(&self) -> Option<Box<dyn HarnessEnvelope>> {
let Self::HarnessAware { harness, format } = self else {
return None;
};
Some(adapter_for(*harness, format.clone()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HarnessKind {
ClaudeCode,
Codex,
GeminiCli,
Chronos,
Generic,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnvelopeFormat {
Inline,
FileBased { path_root: PathBuf },
SideChannel,
}
pub trait HarnessEnvelope {
fn shape(&self, hits: &[ScoredMemory]) -> String;
}
fn adapter_for(kind: HarnessKind, format: EnvelopeFormat) -> Box<dyn HarnessEnvelope> {
match kind {
HarnessKind::ClaudeCode => Box::new(ClaudeCodeEnvelope {
inline: matches!(format, EnvelopeFormat::Inline),
}),
HarnessKind::Codex => Box::new(CodexEnvelope {
file_based: matches!(format, EnvelopeFormat::FileBased { .. }),
}),
HarnessKind::GeminiCli => Box::new(GeminiCliEnvelope),
HarnessKind::Chronos => Box::new(ChronosEnvelope),
HarnessKind::Generic => Box::new(GenericEnvelope),
}
}
#[derive(Debug, Clone, Copy)]
pub struct ClaudeCodeEnvelope {
pub inline: bool,
}
impl HarnessEnvelope for ClaudeCodeEnvelope {
fn shape(&self, hits: &[ScoredMemory]) -> String {
let mut out = String::new();
out.push_str("# mnemo.recall (Claude Code envelope)\n\n");
for (i, m) in hits.iter().enumerate() {
if self.inline {
out.push_str(&format!(
"## hit {} (recall://{} • score {:.3})\n```\n{}\n```\n\n",
i + 1,
m.id,
m.score,
m.content
));
} else {
let first_line = m.content.lines().next().unwrap_or("").trim();
out.push_str(&format!(
"- hit {} → `recall://{}` (score {:.3}): {}\n",
i + 1,
m.id,
m.score,
first_line
));
}
}
out
}
}
#[derive(Debug, Clone, Copy)]
pub struct CodexEnvelope {
pub file_based: bool,
}
impl HarnessEnvelope for CodexEnvelope {
fn shape(&self, hits: &[ScoredMemory]) -> String {
if self.file_based {
let pointers: Vec<String> = hits
.iter()
.map(|m| format!("{{\"id\":\"{}\",\"score\":{:.3}}}", m.id, m.score))
.collect();
format!(
"{{\"envelope\":\"codex_file_based\",\"hits\":[{}]}}",
pointers.join(",")
)
} else {
let blocks: Vec<String> = hits
.iter()
.map(|m| {
format!(
"{{\"id\":\"{}\",\"score\":{:.3},\"content\":{}}}",
m.id,
m.score,
serde_json::to_string(&m.content).unwrap_or_default()
)
})
.collect();
format!(
"{{\"envelope\":\"codex_inline\",\"hits\":[{}]}}",
blocks.join(",")
)
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct GeminiCliEnvelope;
impl HarnessEnvelope for GeminiCliEnvelope {
fn shape(&self, hits: &[ScoredMemory]) -> String {
let mut out = String::new();
out.push_str("mnemo recall (Gemini CLI envelope)\n");
for (i, m) in hits.iter().enumerate() {
out.push_str(&format!(
"[{}] score={:.3} id={} — {}\n",
i + 1,
m.score,
m.id,
m.content
));
}
out
}
}
#[derive(Debug, Clone, Copy)]
pub struct ChronosEnvelope;
impl HarnessEnvelope for ChronosEnvelope {
fn shape(&self, hits: &[ScoredMemory]) -> String {
let mut out = String::new();
out.push_str("chronos recall envelope\n");
for m in hits {
let first_line = m.content.lines().next().unwrap_or("").trim();
out.push_str(&format!("t={:.3} id={} :: {}\n", m.score, m.id, first_line));
}
out
}
}
#[derive(Debug, Clone, Copy)]
pub struct GenericEnvelope;
impl HarnessEnvelope for GenericEnvelope {
fn shape(&self, hits: &[ScoredMemory]) -> String {
let mut out = String::new();
for m in hits {
let content_safe = m.content.replace(['\t', '\n', '\r'], " ");
out.push_str(&format!("{}\t{:.3}\t{}\n", m.id, m.score, content_safe));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::memory::{MemoryType, Scope};
use uuid::Uuid;
fn make_hit(content: &str, score: f32) -> ScoredMemory {
ScoredMemory {
id: Uuid::now_v7(),
content: content.to_string(),
agent_id: "test-agent".to_string(),
memory_type: MemoryType::Episodic,
scope: Scope::Private,
importance: 0.5,
tags: vec![],
metadata: serde_json::Value::Null,
score,
access_count: 0,
created_at: "2026-05-17T00:00:00Z".to_string(),
updated_at: "2026-05-17T00:00:00Z".to_string(),
score_breakdown: None,
}
}
#[test]
fn retrieval_mode_round_trip_strategy_string() {
assert_eq!(RetrievalMode::VectorOnly.to_strategy_str(), "semantic");
assert_eq!(RetrievalMode::Bm25Only.to_strategy_str(), "lexical");
assert_eq!(RetrievalMode::HybridRrf.to_strategy_str(), "auto");
assert_eq!(RetrievalMode::Graph.to_strategy_str(), "graph");
let harness = RetrievalMode::HarnessAware {
harness: HarnessKind::ClaudeCode,
format: EnvelopeFormat::Inline,
};
assert_eq!(harness.to_strategy_str(), "auto");
}
#[test]
fn retrieval_mode_serde_round_trip() {
for mode in [
RetrievalMode::VectorOnly,
RetrievalMode::Bm25Only,
RetrievalMode::HybridRrf,
RetrievalMode::Graph,
RetrievalMode::HarnessAware {
harness: HarnessKind::ClaudeCode,
format: EnvelopeFormat::Inline,
},
RetrievalMode::HarnessAware {
harness: HarnessKind::Codex,
format: EnvelopeFormat::FileBased {
path_root: PathBuf::from("/tmp/codex"),
},
},
RetrievalMode::HarnessAware {
harness: HarnessKind::Generic,
format: EnvelopeFormat::SideChannel,
},
] {
let s = serde_json::to_string(&mode).unwrap();
let back: RetrievalMode = serde_json::from_str(&s).unwrap();
assert_eq!(mode, back, "round-trip failed for {mode:?} via {s}");
}
}
#[test]
fn harness_aware_returns_envelope_adapter() {
let mode = RetrievalMode::HarnessAware {
harness: HarnessKind::ClaudeCode,
format: EnvelopeFormat::Inline,
};
assert!(mode.envelope_adapter().is_some());
assert!(RetrievalMode::HybridRrf.envelope_adapter().is_none());
}
#[test]
fn five_adapters_produce_distinct_envelope_shapes() {
let hits = vec![
make_hit("first hit content line\nsecond line", 0.91),
make_hit("another hit", 0.42),
];
let cc = ClaudeCodeEnvelope { inline: true }.shape(&hits);
let codex = CodexEnvelope { file_based: true }.shape(&hits);
let gemini = GeminiCliEnvelope.shape(&hits);
let chronos = ChronosEnvelope.shape(&hits);
let generic = GenericEnvelope.shape(&hits);
let shapes = [&cc, &codex, &gemini, &chronos, &generic];
for (i, a) in shapes.iter().enumerate() {
for (j, b) in shapes.iter().enumerate() {
if i != j {
assert_ne!(
a, b,
"adapter shapes {} and {} collided (both produced:\n{a})",
i, j
);
}
}
}
}
#[test]
fn claude_code_envelope_inline_vs_non_inline_differ() {
let hits = vec![make_hit("hello world", 0.5)];
let inline = ClaudeCodeEnvelope { inline: true }.shape(&hits);
let non_inline = ClaudeCodeEnvelope { inline: false }.shape(&hits);
assert!(inline.contains("```"), "inline must contain fenced block");
assert!(
!non_inline.contains("```"),
"non-inline must not contain fenced block"
);
}
#[test]
fn generic_envelope_is_tsv_safe() {
let hits = vec![make_hit("has\ttab\nand newline", 0.5)];
let env = GenericEnvelope.shape(&hits);
assert_eq!(env.lines().count(), 1);
let parts: Vec<&str> = env.trim_end().split('\t').collect();
assert_eq!(
parts.len(),
3,
"TSV envelope must have id\\tscore\\tcontent"
);
}
}