ai-memory 0.7.1

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! Form 3 — deterministic helpers. Cheap, fast, JSON-output.
//!
//! The Batman exemplar is "phase one is a deterministic helper script;
//! phase two reads the JSON output and is told `Do NOT re-run discovery
//! commands or re-count lines, trust the script's results entirely`."
//! This module's three helpers (Jaccard overlap, cosine pre-filter, FTS
//! classifier) are the deterministic substrate Form 3's LLM stages
//! lean on.
//!
//! Every helper returns a [`HelperOutput`] carrying:
//!
//! 1. A `serde_json::Value` payload that goes verbatim into the LLM
//!    prompt's trust slot.
//! 2. A short `summary` line for the operator trace.
//! 3. A `kind` discriminator so the executor can label the slot.

use std::collections::HashSet;

use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

/// In-memory envelope a caller passes into the orchestrator. This is the
/// substrate-agnostic shape: an id, the body text, and (optionally) a
/// pre-computed embedding for cosine pre-filtering. The orchestrator
/// does NOT touch the storage layer directly — callers (the MCP
/// handler, CLI, integration tests) materialise the candidate set
/// before calling [`crate::multistep_ingest::executor::IngestExecutor::run`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryHandle {
    /// Stable identifier (UUID-shaped in production; arbitrary string
    /// in tests).
    pub id: String,
    /// Body text used for FTS / Jaccard overlap.
    pub body: String,
    /// Optional dense embedding for cosine pre-filter. `None` skips
    /// cosine and falls through to the keyword-only path.
    #[serde(default)]
    pub embedding: Option<Vec<f32>>,
    /// Optional namespace tag used by the FTS classifier as a coarse
    /// routing hint.
    #[serde(default)]
    pub namespace: Option<String>,
}

/// Which deterministic helper a pipeline stage runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HelperKind {
    /// Jaccard token overlap between the incoming content and a set of
    /// candidate memories. Cheap, no embedding required.
    JaccardOverlap,
    /// Cosine similarity pre-filter — drops candidates below a
    /// threshold so the LLM stage gets a tighter set.
    CosinePreFilter,
    /// FTS-style classifier — returns a coarse fact-kind tag
    /// (`procedural` / `declarative` / `episodic`) derived from
    /// substring + namespace heuristics.
    FtsClassifier,
}

impl HelperKind {
    /// Snake-case discriminator used in the JSON trace + cache key
    /// derivation.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::JaccardOverlap => "jaccard_overlap",
            Self::CosinePreFilter => "cosine_pre_filter",
            Self::FtsClassifier => "fts_classifier",
        }
    }
}

/// Parameters passed to a helper invocation. Each helper inspects only
/// the fields it cares about; unused fields are ignored.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HelperParams {
    /// Descriptor-level override for the content. The Form 3 executor
    /// passes runtime content via a [`HelperContext`] **borrow** (issue
    /// #782 PERF-11) — this field is only consulted when the pipeline
    /// descriptor itself wants to pin a specific content string (rare;
    /// almost always empty).
    pub content: String,
    /// Candidate memories to score against. Empty for helpers that
    /// don't need a candidate set (e.g., FTS classifier on standalone
    /// content).
    #[serde(default)]
    pub candidates: Vec<MemoryHandle>,
    /// Cosine threshold (only consulted by `CosinePreFilter`). Default
    /// `0.20` matches the substrate's recall semantic threshold.
    #[serde(default)]
    pub cosine_threshold: Option<f32>,
    /// Caller-supplied embedding for the incoming content (cosine
    /// pre-filter input). `None` if the caller hasn't embedded the
    /// content yet — the helper degrades to a no-op in that case.
    #[serde(default)]
    pub content_embedding: Option<Vec<f32>>,
    /// Namespace hint forwarded to the FTS classifier.
    #[serde(default)]
    pub namespace: Option<String>,
}

/// Borrowed runtime inputs threaded into Phase 1 deterministic helper
/// stages. The Form 3 executor constructs ONE of these per `run()` and
/// reuses it across every helper stage so the content string is passed
/// by **reference**, not cloned per-helper (issue #782 PERF-11).
///
/// The fields are public so test scaffolding under `cfg(test)` can
/// construct a context directly; production callers go through the
/// executor.
#[derive(Debug, Clone, Copy)]
pub struct HelperContext<'a> {
    /// Incoming content slice — borrowed from the caller's owned
    /// `String`. The same `&str` is threaded into every helper stage
    /// within a run, so `content.as_ptr()` is stable across stages.
    /// This is the load-bearing invariant pinned by the
    /// `multistep_phase_1_helpers_receive_content_borrow_not_clone`
    /// integration test.
    pub content: &'a str,
    /// Candidate memory set — borrowed slice, not cloned.
    pub candidates: &'a [MemoryHandle],
    /// Optional caller-supplied embedding for the content (cosine
    /// pre-filter input).
    pub content_embedding: Option<&'a [f32]>,
    /// Optional namespace hint forwarded to the FTS classifier.
    pub namespace: Option<&'a str>,
}

impl<'a> HelperContext<'a> {
    /// Construct a borrowed context from the runtime inputs.
    #[must_use]
    pub fn new(
        content: &'a str,
        candidates: &'a [MemoryHandle],
        content_embedding: Option<&'a [f32]>,
        namespace: Option<&'a str>,
    ) -> Self {
        Self {
            content,
            candidates,
            content_embedding,
            namespace,
        }
    }

    /// Resolve the effective content slice for this invocation. The
    /// descriptor `params.content` wins when non-empty (rare pipeline
    /// override); otherwise the context's borrowed slice is returned
    /// verbatim. No `String` allocation.
    #[must_use]
    pub fn effective_content<'p>(&self, params: &'p HelperParams) -> &'p str
    where
        'a: 'p,
    {
        if params.content.is_empty() {
            self.content
        } else {
            params.content.as_str()
        }
    }
}

/// Output of a single helper invocation. Carries the JSON payload that
/// LLM stages render into trust slots verbatim.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelperOutput {
    /// Which helper produced this output.
    pub kind: HelperKind,
    /// Free-form one-line summary for the operator trace (e.g.,
    /// `"jaccard: 3/10 candidates over 0.40 overlap"`).
    pub summary: String,
    /// Structured JSON payload threaded into the LLM stage's trust
    /// slot. Helper-specific shape; downstream stages MUST treat it as
    /// authoritative per the explicit-trust contract.
    pub payload: Value,
}

/// Jaccard token overlap between the incoming content and each
/// candidate body. Returns the top-N candidates sorted by overlap.
///
/// The overlap metric is `|A ∩ B| / |A ∪ B|` on whitespace-split
/// lowercase tokens. Two empty bodies score `0.0` (no overlap) rather
/// than `1.0` (degenerate sets) to avoid surfacing zero-length matches
/// to the LLM.
///
/// Convenience wrapper that builds a self-borrowing `HelperContext`
/// out of `params`; production hot-paths call
/// [`jaccard_overlap_with`] to avoid the per-call clones.
#[must_use]
pub fn jaccard_overlap(params: &HelperParams) -> HelperOutput {
    let ctx = HelperContext::new(&params.content, &params.candidates, None, None);
    jaccard_overlap_with(params, &ctx)
}

/// Borrow-friendly variant of [`jaccard_overlap`]. The Form 3 executor
/// threads ONE [`HelperContext`] through every helper invocation so
/// the content string is read by reference, not cloned per-helper
/// (issue #782 PERF-11).
#[must_use]
pub fn jaccard_overlap_with(params: &HelperParams, ctx: &HelperContext<'_>) -> HelperOutput {
    let content = ctx.effective_content(params);
    let candidates: &[MemoryHandle] = if params.candidates.is_empty() {
        ctx.candidates
    } else {
        params.candidates.as_slice()
    };

    let content_tokens = tokenise(content);
    let mut scored: Vec<(&str, f32, &str)> = candidates
        .iter()
        .map(|c| {
            let candidate_tokens = tokenise(&c.body);
            let overlap = jaccard(&content_tokens, &candidate_tokens);
            (c.id.as_str(), overlap, c.body.as_str())
        })
        .collect();
    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
    scored.truncate(10);

    let over_threshold: usize = scored.iter().filter(|(_, score, _)| *score >= 0.40).count();

    let summary = format!(
        "jaccard: {}/{} candidates over 0.40 overlap",
        over_threshold,
        candidates.len()
    );

    let payload = json!({
        "helper": "jaccard_overlap",
        "candidates_scored": candidates.len(),
        "top_candidates": scored
            .iter()
            .map(|(id, score, body)| json!({
                "id": id,
                "overlap": score,
                "preview": preview(body, 120),
            }))
            .collect::<Vec<_>>(),
    });

    HelperOutput {
        kind: HelperKind::JaccardOverlap,
        summary,
        payload,
    }
}

/// Cosine similarity pre-filter over candidates with embeddings.
/// Returns the candidate set above `cosine_threshold` (default `0.20`).
/// Candidates without embeddings are passed through with `score = null`
/// so the LLM still sees them but they don't contribute to ranking.
///
/// Convenience wrapper around [`cosine_pre_filter_with`].
#[must_use]
pub fn cosine_pre_filter(params: &HelperParams) -> HelperOutput {
    let ctx = HelperContext::new(
        &params.content,
        &params.candidates,
        params.content_embedding.as_deref(),
        None,
    );
    cosine_pre_filter_with(params, &ctx)
}

/// Borrow-friendly variant of [`cosine_pre_filter`] consuming the
/// borrowed [`HelperContext`] (issue #782 PERF-11).
#[must_use]
pub fn cosine_pre_filter_with(params: &HelperParams, ctx: &HelperContext<'_>) -> HelperOutput {
    let threshold = params.cosine_threshold.unwrap_or(0.20);
    let content_emb: Option<&[f32]> = if params.content_embedding.is_some() {
        params.content_embedding.as_deref()
    } else {
        ctx.content_embedding
    };
    let candidates: &[MemoryHandle] = if params.candidates.is_empty() {
        ctx.candidates
    } else {
        params.candidates.as_slice()
    };

    let scored: Vec<Value> = candidates
        .iter()
        .map(|c| {
            let score = match (content_emb, c.embedding.as_deref()) {
                (Some(a), Some(b)) => Some(cosine(a, b)),
                _ => None,
            };
            json!({
                "id": c.id,
                "score": score,
                "above_threshold": score.is_some_and(|s| s >= threshold),
                "preview": preview(&c.body, 120),
            })
        })
        .collect();

    let kept = scored
        .iter()
        .filter(|v| v["above_threshold"].as_bool().unwrap_or(false))
        .count();
    let total = scored.len();

    let summary = format!("cosine: {kept}/{total} candidates over {threshold:.2} threshold");

    let payload = json!({
        "helper": "cosine_pre_filter",
        "threshold": threshold,
        "candidates_scored": total,
        "candidates_kept": kept,
        "candidates": scored,
    });

    HelperOutput {
        kind: HelperKind::CosinePreFilter,
        summary,
        payload,
    }
}

/// FTS-style classifier — labels the incoming content as one of
/// `procedural` / `declarative` / `episodic` using substring + tag
/// heuristics. Deterministic; no LLM involved.
///
/// The classification is intentionally coarse — it exists to give the
/// LLM stage a starting hint so it doesn't burn tokens re-deriving the
/// kind from scratch. Per Batman's contract, the LLM is told "trust
/// this label" rather than asked to re-classify.
///
/// Convenience wrapper around [`fts_classifier_with`].
#[must_use]
pub fn fts_classifier(params: &HelperParams) -> HelperOutput {
    let ctx = HelperContext::new(
        &params.content,
        &params.candidates,
        None,
        params.namespace.as_deref(),
    );
    fts_classifier_with(params, &ctx)
}

/// Borrow-friendly variant of [`fts_classifier`] consuming the
/// borrowed [`HelperContext`] (issue #782 PERF-11).
#[must_use]
pub fn fts_classifier_with(params: &HelperParams, ctx: &HelperContext<'_>) -> HelperOutput {
    let content = ctx.effective_content(params);
    let namespace: &str = params
        .namespace
        .as_deref()
        .or(ctx.namespace)
        .unwrap_or(crate::DEFAULT_NAMESPACE);

    let body_lower = content.to_lowercase();
    let kind = if body_lower.contains("step ")
        || body_lower.contains("first, ")
        || body_lower.contains("then ")
    {
        "procedural"
    } else if body_lower.contains("yesterday")
        || body_lower.contains("today")
        || body_lower.contains("happened")
        || body_lower.contains("event")
    {
        "episodic"
    } else {
        "declarative"
    };

    let summary = format!("fts_classifier: kind={kind} (namespace={namespace})");

    let payload = json!({
        "helper": HelperKind::FtsClassifier.as_str(),
        "fact_kind": kind,
        "namespace": namespace,
        "tokens": tokenise(content).len(),
    });

    HelperOutput {
        kind: HelperKind::FtsClassifier,
        summary,
        payload,
    }
}

/// Dispatch a helper by kind. Used by the executor when walking a
/// pipeline's stage list.
///
/// Convenience wrapper around [`run_helper_with`] that builds a
/// self-borrowing context.
#[must_use]
pub fn run_helper(kind: HelperKind, params: &HelperParams) -> HelperOutput {
    match kind {
        HelperKind::JaccardOverlap => jaccard_overlap(params),
        HelperKind::CosinePreFilter => cosine_pre_filter(params),
        HelperKind::FtsClassifier => fts_classifier(params),
    }
}

/// Borrow-friendly dispatch — used by the Form 3 executor to avoid the
/// per-helper content `String` clone (issue #782 PERF-11).
#[must_use]
pub fn run_helper_with(
    kind: HelperKind,
    params: &HelperParams,
    ctx: &HelperContext<'_>,
) -> HelperOutput {
    match kind {
        HelperKind::JaccardOverlap => jaccard_overlap_with(params, ctx),
        HelperKind::CosinePreFilter => cosine_pre_filter_with(params, ctx),
        HelperKind::FtsClassifier => fts_classifier_with(params, ctx),
    }
}

// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------

fn tokenise(body: &str) -> HashSet<String> {
    body.split_whitespace()
        .map(|t| {
            t.trim_matches(|c: char| !c.is_alphanumeric())
                .to_lowercase()
        })
        .filter(|t| !t.is_empty())
        .collect()
}

fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f32 {
    if a.is_empty() && b.is_empty() {
        return 0.0;
    }
    let intersect: usize = a.intersection(b).count();
    let union: usize = a.union(b).count();
    if union == 0 {
        0.0
    } else {
        intersect as f32 / union as f32
    }
}

fn cosine(a: &[f32], b: &[f32]) -> f32 {
    if a.is_empty() || b.is_empty() || a.len() != b.len() {
        return 0.0;
    }
    let mut dot = 0.0_f32;
    let mut na = 0.0_f32;
    let mut nb = 0.0_f32;
    for i in 0..a.len() {
        dot += a[i] * b[i];
        na += a[i] * a[i];
        nb += b[i] * b[i];
    }
    if na <= f32::EPSILON || nb <= f32::EPSILON {
        return 0.0;
    }
    dot / (na.sqrt() * nb.sqrt())
}

fn preview(body: &str, max: usize) -> String {
    if body.chars().count() <= max {
        body.to_string()
    } else {
        let truncated: String = body.chars().take(max).collect();
        format!("{truncated}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn mh(id: &str, body: &str) -> MemoryHandle {
        MemoryHandle {
            id: id.to_string(),
            body: body.to_string(),
            embedding: None,
            namespace: None,
        }
    }

    fn mh_emb(id: &str, body: &str, embedding: Vec<f32>) -> MemoryHandle {
        MemoryHandle {
            id: id.to_string(),
            body: body.to_string(),
            embedding: Some(embedding),
            namespace: None,
        }
    }

    #[test]
    fn jaccard_overlap_returns_non_empty_for_overlapping_text() {
        let params = HelperParams {
            content: "the quick brown fox jumps over the lazy dog".to_string(),
            candidates: vec![
                mh("a", "a quick brown dog"),
                mh("b", "completely unrelated content here"),
            ],
            ..Default::default()
        };
        let out = jaccard_overlap(&params);
        assert_eq!(out.kind, HelperKind::JaccardOverlap);
        let top = out.payload["top_candidates"].as_array().unwrap();
        assert_eq!(top.len(), 2);
        // The 'a' candidate must rank higher than 'b'.
        assert_eq!(top[0]["id"].as_str(), Some("a"));
        let top_score = top[0]["overlap"].as_f64().unwrap();
        let bot_score = top[1]["overlap"].as_f64().unwrap();
        assert!(top_score > bot_score);
    }

    #[test]
    fn jaccard_overlap_handles_empty_candidates_cleanly() {
        let params = HelperParams {
            content: "hello world".to_string(),
            candidates: vec![],
            ..Default::default()
        };
        let out = jaccard_overlap(&params);
        assert_eq!(out.payload["candidates_scored"], 0);
        assert_eq!(out.payload["top_candidates"].as_array().unwrap().len(), 0);
    }

    #[test]
    fn cosine_pre_filter_drops_below_threshold() {
        let params = HelperParams {
            content: "x".to_string(),
            candidates: vec![
                mh_emb("near", "near body", vec![1.0, 0.0, 0.0]),
                mh_emb("far", "far body", vec![0.0, 1.0, 0.0]),
            ],
            content_embedding: Some(vec![1.0, 0.05, 0.0]),
            cosine_threshold: Some(0.50),
            ..Default::default()
        };
        let out = cosine_pre_filter(&params);
        let kept = out.payload["candidates_kept"].as_u64().unwrap();
        assert_eq!(kept, 1, "only the 'near' candidate should pass");
    }

    #[test]
    fn cosine_pre_filter_no_embedding_degrades_to_null_scores() {
        let params = HelperParams {
            content: "x".to_string(),
            candidates: vec![mh("a", "a")],
            content_embedding: None,
            ..Default::default()
        };
        let out = cosine_pre_filter(&params);
        let candidates = out.payload["candidates"].as_array().unwrap();
        assert!(candidates[0]["score"].is_null());
        assert_eq!(candidates[0]["above_threshold"], false);
    }

    #[test]
    fn fts_classifier_labels_procedural_text() {
        let params = HelperParams {
            content: "Step 1: open the door. Then walk through.".to_string(),
            ..Default::default()
        };
        let out = fts_classifier(&params);
        assert_eq!(out.payload["fact_kind"], "procedural");
    }

    #[test]
    fn fts_classifier_labels_episodic_text() {
        let params = HelperParams {
            content: "Yesterday I went to the store.".to_string(),
            ..Default::default()
        };
        let out = fts_classifier(&params);
        assert_eq!(out.payload["fact_kind"], "episodic");
    }

    #[test]
    fn fts_classifier_default_is_declarative() {
        let params = HelperParams {
            content: "The capital of France is Paris.".to_string(),
            ..Default::default()
        };
        let out = fts_classifier(&params);
        assert_eq!(out.payload["fact_kind"], "declarative");
    }

    #[test]
    fn run_helper_dispatches_correctly() {
        let params = HelperParams {
            content: "anything".to_string(),
            ..Default::default()
        };
        let out = run_helper(HelperKind::FtsClassifier, &params);
        assert_eq!(out.kind, HelperKind::FtsClassifier);
    }

    #[test]
    fn helper_kind_serialisation_is_snake_case() {
        assert_eq!(HelperKind::JaccardOverlap.as_str(), "jaccard_overlap");
        assert_eq!(HelperKind::CosinePreFilter.as_str(), "cosine_pre_filter");
        assert_eq!(HelperKind::FtsClassifier.as_str(), "fts_classifier");
    }
}