1use std::path::PathBuf;
58
59use serde::{Deserialize, Serialize};
60
61use crate::query::recall::ScoredMemory;
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum RetrievalMode {
69 VectorOnly,
71 Bm25Only,
74 HybridRrf,
80 Graph,
83 HarnessAware {
90 harness: HarnessKind,
91 format: EnvelopeFormat,
92 },
93 DomainScoped,
103 Reconstruct,
114}
115
116impl RetrievalMode {
117 pub fn to_strategy_str(&self) -> &'static str {
122 match self {
123 Self::VectorOnly => "semantic",
124 Self::Bm25Only => "lexical",
125 Self::HybridRrf | Self::HarnessAware { .. } => "auto",
126 Self::Graph => "graph",
127 Self::DomainScoped => "domain_scoped",
128 Self::Reconstruct => "reconstruct",
129 }
130 }
131
132 pub fn envelope_adapter(&self) -> Option<Box<dyn HarnessEnvelope>> {
137 let Self::HarnessAware { harness, format } = self else {
138 return None;
139 };
140 Some(adapter_for(*harness, format.clone()))
141 }
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
154pub struct DomainScope {
155 pub org_id: Option<String>,
157 pub namespace: Option<String>,
160 pub doc_class: Option<String>,
163 pub tags: Option<Vec<String>>,
165}
166
167impl DomainScope {
168 pub fn is_empty(&self) -> bool {
170 self.org_id.is_none()
171 && self.namespace.is_none()
172 && self.doc_class.is_none()
173 && self.tags.as_ref().map(|t| t.is_empty()).unwrap_or(true)
174 }
175
176 pub fn matches(&self, record: &crate::model::memory::MemoryRecord) -> bool {
179 if let Some(ref org) = self.org_id
180 && record.org_id.as_deref() != Some(org.as_str())
181 {
182 return false;
183 }
184 if let Some(ref ns) = self.namespace {
185 let tag_hit = record.tags.iter().any(|t| t == ns);
186 let meta_hit = record
187 .metadata
188 .get("namespace")
189 .and_then(|v| v.as_str())
190 .map(|v| v == ns)
191 .unwrap_or(false);
192 if !tag_hit && !meta_hit {
193 return false;
194 }
195 }
196 if let Some(ref dc) = self.doc_class {
197 let meta_hit = record
198 .metadata
199 .get("doc_class")
200 .and_then(|v| v.as_str())
201 .map(|v| v == dc)
202 .unwrap_or(false);
203 if !meta_hit {
204 return false;
205 }
206 }
207 if let Some(ref tags) = self.tags
208 && !tags.iter().all(|t| record.tags.contains(t))
209 {
210 return false;
211 }
212 true
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "snake_case")]
219pub enum HarnessKind {
220 ClaudeCode,
221 Codex,
222 GeminiCli,
223 Chronos,
224 Generic,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum EnvelopeFormat {
233 Inline,
234 FileBased { path_root: PathBuf },
235 SideChannel,
236}
237
238pub trait HarnessEnvelope {
242 fn shape(&self, hits: &[ScoredMemory]) -> String;
243}
244
245fn adapter_for(kind: HarnessKind, format: EnvelopeFormat) -> Box<dyn HarnessEnvelope> {
246 match kind {
247 HarnessKind::ClaudeCode => Box::new(ClaudeCodeEnvelope {
248 inline: matches!(format, EnvelopeFormat::Inline),
249 }),
250 HarnessKind::Codex => Box::new(CodexEnvelope {
251 file_based: matches!(format, EnvelopeFormat::FileBased { .. }),
252 }),
253 HarnessKind::GeminiCli => Box::new(GeminiCliEnvelope),
254 HarnessKind::Chronos => Box::new(ChronosEnvelope),
255 HarnessKind::Generic => Box::new(GenericEnvelope),
256 }
257}
258
259#[derive(Debug, Clone, Copy)]
263pub struct ClaudeCodeEnvelope {
264 pub inline: bool,
265}
266
267impl HarnessEnvelope for ClaudeCodeEnvelope {
268 fn shape(&self, hits: &[ScoredMemory]) -> String {
269 let mut out = String::new();
270 out.push_str("# mnemo.recall (Claude Code envelope)\n\n");
271 for (i, m) in hits.iter().enumerate() {
272 if self.inline {
273 out.push_str(&format!(
274 "## hit {} (recall://{} • score {:.3})\n```\n{}\n```\n\n",
275 i + 1,
276 m.id,
277 m.score,
278 m.content
279 ));
280 } else {
281 let first_line = m.content.lines().next().unwrap_or("").trim();
282 out.push_str(&format!(
283 "- hit {} → `recall://{}` (score {:.3}): {}\n",
284 i + 1,
285 m.id,
286 m.score,
287 first_line
288 ));
289 }
290 }
291 out
292 }
293}
294
295#[derive(Debug, Clone, Copy)]
299pub struct CodexEnvelope {
300 pub file_based: bool,
301}
302
303impl HarnessEnvelope for CodexEnvelope {
304 fn shape(&self, hits: &[ScoredMemory]) -> String {
305 if self.file_based {
306 let pointers: Vec<String> = hits
307 .iter()
308 .map(|m| format!("{{\"id\":\"{}\",\"score\":{:.3}}}", m.id, m.score))
309 .collect();
310 format!(
311 "{{\"envelope\":\"codex_file_based\",\"hits\":[{}]}}",
312 pointers.join(",")
313 )
314 } else {
315 let blocks: Vec<String> = hits
316 .iter()
317 .map(|m| {
318 format!(
319 "{{\"id\":\"{}\",\"score\":{:.3},\"content\":{}}}",
320 m.id,
321 m.score,
322 serde_json::to_string(&m.content).unwrap_or_default()
323 )
324 })
325 .collect();
326 format!(
327 "{{\"envelope\":\"codex_inline\",\"hits\":[{}]}}",
328 blocks.join(",")
329 )
330 }
331 }
332}
333
334#[derive(Debug, Clone, Copy)]
337pub struct GeminiCliEnvelope;
338
339impl HarnessEnvelope for GeminiCliEnvelope {
340 fn shape(&self, hits: &[ScoredMemory]) -> String {
341 let mut out = String::new();
342 out.push_str("mnemo recall (Gemini CLI envelope)\n");
343 for (i, m) in hits.iter().enumerate() {
344 out.push_str(&format!(
345 "[{}] score={:.3} id={} — {}\n",
346 i + 1,
347 m.score,
348 m.id,
349 m.content
350 ));
351 }
352 out
353 }
354}
355
356#[derive(Debug, Clone, Copy)]
360pub struct ChronosEnvelope;
361
362impl HarnessEnvelope for ChronosEnvelope {
363 fn shape(&self, hits: &[ScoredMemory]) -> String {
364 let mut out = String::new();
365 out.push_str("chronos recall envelope\n");
366 for m in hits {
367 let first_line = m.content.lines().next().unwrap_or("").trim();
368 out.push_str(&format!("t={:.3} id={} :: {}\n", m.score, m.id, first_line));
369 }
370 out
371 }
372}
373
374#[derive(Debug, Clone, Copy)]
377pub struct GenericEnvelope;
378
379impl HarnessEnvelope for GenericEnvelope {
380 fn shape(&self, hits: &[ScoredMemory]) -> String {
381 let mut out = String::new();
382 for m in hits {
383 let content_safe = m.content.replace(['\t', '\n', '\r'], " ");
386 out.push_str(&format!("{}\t{:.3}\t{}\n", m.id, m.score, content_safe));
387 }
388 out
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::model::memory::{MemoryType, Scope};
396 use uuid::Uuid;
397
398 fn make_hit(content: &str, score: f32) -> ScoredMemory {
399 ScoredMemory {
400 id: Uuid::now_v7(),
401 content: content.to_string(),
402 agent_id: "test-agent".to_string(),
403 memory_type: MemoryType::Episodic,
404 scope: Scope::Private,
405 importance: 0.5,
406 tags: vec![],
407 metadata: serde_json::Value::Null,
408 score,
409 access_count: 0,
410 created_at: "2026-05-17T00:00:00Z".to_string(),
411 updated_at: "2026-05-17T00:00:00Z".to_string(),
412 score_breakdown: None,
413 }
414 }
415
416 #[test]
417 fn retrieval_mode_round_trip_strategy_string() {
418 assert_eq!(RetrievalMode::VectorOnly.to_strategy_str(), "semantic");
419 assert_eq!(RetrievalMode::Bm25Only.to_strategy_str(), "lexical");
420 assert_eq!(RetrievalMode::HybridRrf.to_strategy_str(), "auto");
421 assert_eq!(RetrievalMode::Graph.to_strategy_str(), "graph");
422 assert_eq!(
423 RetrievalMode::DomainScoped.to_strategy_str(),
424 "domain_scoped"
425 );
426 assert_eq!(RetrievalMode::Reconstruct.to_strategy_str(), "reconstruct");
427 let harness = RetrievalMode::HarnessAware {
428 harness: HarnessKind::ClaudeCode,
429 format: EnvelopeFormat::Inline,
430 };
431 assert_eq!(harness.to_strategy_str(), "auto");
434 }
435
436 fn rec(
437 org: Option<&str>,
438 tags: &[&str],
439 metadata: serde_json::Value,
440 ) -> crate::model::memory::MemoryRecord {
441 use crate::model::memory::{ConsolidationState, SourceType};
442 crate::model::memory::MemoryRecord {
443 id: Uuid::now_v7(),
444 agent_id: "a".to_string(),
445 content: "c".to_string(),
446 memory_type: MemoryType::Episodic,
447 scope: Scope::Private,
448 importance: 0.5,
449 tags: tags.iter().map(|t| t.to_string()).collect(),
450 metadata,
451 embedding: None,
452 content_hash: vec![],
453 prev_hash: None,
454 source_type: SourceType::Agent,
455 source_id: None,
456 consolidation_state: ConsolidationState::Raw,
457 access_count: 0,
458 org_id: org.map(str::to_string),
459 thread_id: None,
460 created_at: "2026-06-13T00:00:00Z".to_string(),
461 updated_at: "2026-06-13T00:00:00Z".to_string(),
462 last_accessed_at: None,
463 expires_at: None,
464 deleted_at: None,
465 decay_rate: None,
466 created_by: None,
467 version: 1,
468 prev_version_id: None,
469 quarantined: false,
470 quarantine_reason: None,
471 decay_function: None,
472 }
473 }
474
475 #[test]
476 fn domain_scope_matches_logical_and() {
477 let empty = DomainScope::default();
479 assert!(empty.is_empty());
480 assert!(empty.matches(&rec(Some("alpha"), &[], serde_json::Value::Null)));
481
482 let by_org = DomainScope {
484 org_id: Some("alpha".to_string()),
485 ..Default::default()
486 };
487 assert!(by_org.matches(&rec(Some("alpha"), &[], serde_json::Value::Null)));
488 assert!(!by_org.matches(&rec(Some("beta"), &[], serde_json::Value::Null)));
489
490 let by_ns = DomainScope {
492 namespace: Some("legal".to_string()),
493 ..Default::default()
494 };
495 assert!(by_ns.matches(&rec(None, &["legal"], serde_json::Value::Null)));
496 assert!(by_ns.matches(&rec(None, &[], serde_json::json!({"namespace": "legal"}))));
497 assert!(!by_ns.matches(&rec(None, &["hr"], serde_json::json!({"namespace": "hr"}))));
498
499 let combo = DomainScope {
501 org_id: Some("alpha".to_string()),
502 doc_class: Some("contract".to_string()),
503 ..Default::default()
504 };
505 assert!(combo.matches(&rec(
506 Some("alpha"),
507 &[],
508 serde_json::json!({"doc_class": "contract"})
509 )));
510 assert!(!combo.matches(&rec(
512 Some("beta"),
513 &[],
514 serde_json::json!({"doc_class": "contract"})
515 )));
516 assert!(!combo.matches(&rec(
518 Some("alpha"),
519 &[],
520 serde_json::json!({"doc_class": "memo"})
521 )));
522 }
523
524 #[test]
525 fn retrieval_mode_serde_round_trip() {
526 for mode in [
527 RetrievalMode::VectorOnly,
528 RetrievalMode::Bm25Only,
529 RetrievalMode::HybridRrf,
530 RetrievalMode::Graph,
531 RetrievalMode::DomainScoped,
532 RetrievalMode::Reconstruct,
533 RetrievalMode::HarnessAware {
534 harness: HarnessKind::ClaudeCode,
535 format: EnvelopeFormat::Inline,
536 },
537 RetrievalMode::HarnessAware {
538 harness: HarnessKind::Codex,
539 format: EnvelopeFormat::FileBased {
540 path_root: PathBuf::from("/tmp/codex"),
541 },
542 },
543 RetrievalMode::HarnessAware {
544 harness: HarnessKind::Generic,
545 format: EnvelopeFormat::SideChannel,
546 },
547 ] {
548 let s = serde_json::to_string(&mode).unwrap();
549 let back: RetrievalMode = serde_json::from_str(&s).unwrap();
550 assert_eq!(mode, back, "round-trip failed for {mode:?} via {s}");
551 }
552 }
553
554 #[test]
555 fn harness_aware_returns_envelope_adapter() {
556 let mode = RetrievalMode::HarnessAware {
557 harness: HarnessKind::ClaudeCode,
558 format: EnvelopeFormat::Inline,
559 };
560 assert!(mode.envelope_adapter().is_some());
561 assert!(RetrievalMode::HybridRrf.envelope_adapter().is_none());
562 }
563
564 #[test]
565 fn five_adapters_produce_distinct_envelope_shapes() {
566 let hits = vec![
567 make_hit("first hit content line\nsecond line", 0.91),
568 make_hit("another hit", 0.42),
569 ];
570 let cc = ClaudeCodeEnvelope { inline: true }.shape(&hits);
571 let codex = CodexEnvelope { file_based: true }.shape(&hits);
572 let gemini = GeminiCliEnvelope.shape(&hits);
573 let chronos = ChronosEnvelope.shape(&hits);
574 let generic = GenericEnvelope.shape(&hits);
575 let shapes = [&cc, &codex, &gemini, &chronos, &generic];
578 for (i, a) in shapes.iter().enumerate() {
579 for (j, b) in shapes.iter().enumerate() {
580 if i != j {
581 assert_ne!(
582 a, b,
583 "adapter shapes {} and {} collided (both produced:\n{a})",
584 i, j
585 );
586 }
587 }
588 }
589 }
590
591 #[test]
592 fn claude_code_envelope_inline_vs_non_inline_differ() {
593 let hits = vec![make_hit("hello world", 0.5)];
594 let inline = ClaudeCodeEnvelope { inline: true }.shape(&hits);
595 let non_inline = ClaudeCodeEnvelope { inline: false }.shape(&hits);
596 assert!(inline.contains("```"), "inline must contain fenced block");
597 assert!(
598 !non_inline.contains("```"),
599 "non-inline must not contain fenced block"
600 );
601 }
602
603 #[test]
604 fn generic_envelope_is_tsv_safe() {
605 let hits = vec![make_hit("has\ttab\nand newline", 0.5)];
606 let env = GenericEnvelope.shape(&hits);
607 assert_eq!(env.lines().count(), 1);
610 let parts: Vec<&str> = env.trim_end().split('\t').collect();
611 assert_eq!(
612 parts.len(),
613 3,
614 "TSV envelope must have id\\tscore\\tcontent"
615 );
616 }
617}