Files
boxmaker/docs/plans/M5a/files/crates/memoryd/src/recall.rs
T
kyleandClaude Opus 5.5 de1032157b Plan M5a: local memory in 15 tasks
memoryd (files, the check, edits, journal, index, recall, serving),
the memory tools and built-in grants in brokerd, the memory block in
loopd, bxctl reindex and memory, and the device checks with memoryd.

Each task was built as a reference and gated. Each was replayed on its
own at its end state (854 to 936 tests) and mutation-checked. Each
skeleton compiles and fails its tests. Every todo comment was read
beside its signature.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:32:54 -07:00

125 lines
4.6 KiB
Rust

//! `recall`: search the index and answer with tagged hits (M5a spec, section 5). The query
//! reaches SQLite only as quoted terms, so no FTS5 operator from the model is ever parsed.
use serde::Serialize;
use proto::{DataClass, RecallArgs, Timestamp};
use rusqlite::params;
use crate::files::Store;
use crate::index::{Index, class_name, class_of};
/// The most characters of one hit's text.
pub const SNIPPET: usize = 600;
/// The answer to the model, and the label the result carries.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Recalled {
pub text: String,
pub class: DataClass,
pub untrusted: bool,
}
/// The FTS5 query for `query`: every run of letters and digits, double-quoted, joined by `OR`.
/// `None` when there is no such run.
pub fn terms(query: &str) -> Option<String> {
// Split `query` on every char that is not alphanumeric (`!c.is_alphanumeric()`), drop the empty
// pieces, wrap each in double quotes (format!("\"{t}\"")) and join them with " OR ". None when
// there is no piece. So `a NEAR(b` is `"a" OR "NEAR" OR "b"`: no FTS5 operator from the model
// is ever parsed.
todo!()
}
/// `2026-09-20 14:02 UTC` from `2026-09-20T14:02:00.000Z` or `2026-09-20T14:02Z`.
fn when(time: &str) -> String {
// The first 10 characters (the day) and characters 11 to 16 (the clock), as format!("{day}
// {clock} UTC"): both 2026-09-20T14:02:00.000Z and 2026-09-20T14:02Z give 2026-09-20 14:02 UTC.
// When either `get` is None, the time unchanged.
todo!()
}
fn day(time: &str) -> &str {
// The first 10 characters of `time` (`get(..10)`), or all of it when shorter.
todo!()
}
struct Hit {
kind: String,
session: String,
epoch: i64,
record: i64,
time: String,
role: String,
class: DataClass,
untrusted: bool,
text: String,
}
impl Hit {
/// `identity`, `memory` or `memory-tainted`.
fn tag(&self) -> &'static str {
// kind "moved" -> "identity"; "dropped" -> "memory"; any other kind: "memory-tainted" when
// untrusted, else "memory".
todo!()
}
fn header(&self, n: usize) -> String {
// `from` by kind: "moved" -> format!("moved {} from {}", day(time), role); "dropped" -> the
// same with "dropped"; "noted" -> format!("note, {}, session {}", when(time), session); a
// session record -> format!("session {}, {}, {said}", session, when(time)) where said is
// "the owner said" for role "owner", "the assistant called" for "call", and "the assistant
// said" otherwise. Return format!("[{n}] {} · {from} · {}", self.tag(),
// class_name(self.class)).
todo!()
}
}
#[derive(Serialize)]
struct LoggedHit<'a> {
kind: &'a str,
session: &'a str,
epoch: i64,
record: i64,
}
#[derive(Serialize)]
struct Logged<'a> {
time: String,
session: &'a str,
query: &'a str,
hits: Vec<LoggedHit<'a>>,
}
/// Search for `args` on behalf of `session`, log the recall, and answer.
pub fn recall(
index: &Index,
store: &Store,
args: &RecallArgs,
session: &str,
now: Timestamp,
) -> Result<Recalled, String> {
// 1. `args.check()?`.
// 2. hits: `terms(&args.query)` None -> none; Some(fts) -> `search(index, &fts, args.k)?`.
// 3. Log it: a Logged { time: now.to_rfc3339(), session, query, hits: a LoggedHit (kind,
// session, epoch, record) per hit } as one JSON line appended to
// format!("recalls/{month}.jsonl") (month: the first 7 characters of now.to_rfc3339()),
// after `create_dir_all(store.path("recalls"))`.
// 4. No hits: Ok(Recalled { text: format!("(no matches for \"{}\")", args.query), class
// Private, untrusted false }).
// 5. Otherwise, for each hit with i from 0: class = class.max(its class) starting at Private;
// untrusted |= its tag is "memory-tainted"; its text cut to its first SNIPPET characters;
// the item is format!("{}\n{text}", hit.header(i + 1)). Ok(Recalled { the items joined by
// newlines, class, untrusted }).
todo!()
}
fn search(index: &Index, fts: &str, k: u64) -> Result<Vec<Hit>, String> {
// `index.conn().prepare("SELECT s.kind, s.session, s.epoch, s.record, s.time, s.role, s.class,
// s.untrusted, snippet(hits, 0, '', '', '…', 64) FROM hits JOIN sources s ON s.rowid =
// hits.rowid WHERE hits MATCH ?1 ORDER BY bm25(hits) LIMIT ?2")`, then `query_map(params![fts,
// limit], ..)` building a Hit from columns 0 to 8 in that order (the class through `class_of`);
// limit is k as an i64 (`i64::try_from`, 10 when it does not fit). Collect into Vec<Hit>. Every
// rusqlite error as format!("index: {e}").
todo!()
}