memoryd: Honcho's results as recall items, kept, labelled and interleaved

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-29 09:11:11 -07:00
co-authored by ornith-1.5-35b-a3b
parent b370c60bd4
commit d8c396e691
5 changed files with 531 additions and 1 deletions
+1
View File
@@ -10,6 +10,7 @@ pub mod messages;
pub mod mirror;
pub mod naming;
pub mod read;
pub mod recalled;
pub mod report;
pub mod start;
pub mod state;
+198
View File
@@ -0,0 +1,198 @@
//! Honcho's search results as `recall` items (M5c spec, section 8): kept only when they share a
//! word with the query, one of each text per source, neutralised, on one line and capped like a
//! local snippet, labelled by their source, and shown after the local index's hits up to `k`.
//! Pure: no I/O.
use bxcore::DataClass;
use bxcore::sanitize::neutralise;
use roncho::{Conclusion, Message};
use crate::honcho::read::Label;
use crate::index::class_name;
use crate::recall::{SNIPPET, when};
/// One Honcho hit as `recall` shows it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
/// `memory` or `memory-tainted`: its source's tag.
pub tag: &'static str,
/// Where it came from, after the tag in its header.
pub from: String,
pub class: DataClass,
pub untrusted: bool,
/// Neutralised, on one line, at most `SNIPPET` characters.
pub text: String,
}
impl Item {
/// The item as the model reads it: a header in the local format, `[n] {tag} · {from} ·
/// {class}`, then its text on one line indented by two spaces, so that no text can begin a
/// line the way a header does.
pub fn render(&self, n: usize) -> String {
format!(
"[{n}] {} · {} · {}\n {}",
self.tag,
self.from,
class_name(self.class),
self.text
)
}
}
/// The words of `text`, lowercase: every run of alphanumeric characters (`char::is_alphanumeric`),
/// as the local index's tokenizer splits them.
pub fn words(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
.collect()
}
/// Whether `text` holds one of `query` (the query's words) as a whole word.
pub fn relevant(query: &[String], text: &str) -> bool {
words(text).iter().any(|w| query.contains(w))
}
/// Text from Honcho as an item holds it: neutralised, every control character (a line break
/// among them) and every Unicode line or paragraph separator a space, then cut to its first
/// `SNIPPET` characters, as a local snippet is cut.
pub fn clean(text: &str) -> String {
neutralise(text)
.chars()
.map(|c| {
if c.is_control() || c == '\u{2028}' || c == '\u{2029}' {
' '
} else {
c
}
})
.take(SNIPPET)
.collect()
}
/// `{name}: {peer} said, {when}` for a message.
fn message_from(label: &Label, message: &Message) -> String {
format!(
"{}: {} said, {}",
label.name,
clean(&message.peer_id),
when(&message.created_at)
)
}
/// `{name}: a conclusion about {peer}, {when}` for a conclusion.
fn conclusion_from(label: &Label, conclusion: &Conclusion) -> String {
format!(
"{}: a conclusion about {}, {}",
label.name,
clean(&conclusion.observed_id),
when(&conclusion.created_at)
)
}
/// One source's results as items, in Honcho's order: the messages, then the conclusions about
/// the agent's peer, then those about the owner's (both empty for the fleet). A result is kept
/// only when it holds a word of `query`, and only once: one whose cleaned text equals an earlier
/// kept one's is dropped.
pub fn items(
label: &Label,
query: &str,
messages: &[Message],
agent: &[Conclusion],
owner: &[Conclusion],
) -> Vec<Item> {
let query = words(query);
let contents: Vec<(&str, String)> = messages
.iter()
.map(|m| (m.content.as_str(), message_from(label, m)))
.chain(
agent
.iter()
.map(|c| (c.content.as_str(), conclusion_from(label, c))),
)
.chain(
owner
.iter()
.map(|c| (c.content.as_str(), conclusion_from(label, c))),
)
.collect();
let mut kept: Vec<Item> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for (content, from) in contents {
if !relevant(&query, content) {
continue;
}
let text = clean(content);
if seen.iter().any(|s| s == &text) {
continue;
}
seen.push(text.clone());
kept.push(Item {
tag: label.tag(),
from,
class: label.class,
untrusted: label.untrusted(),
text,
});
}
kept
}
/// The Honcho items `recall` shows after `local` hits of the local index, so that there are at
/// most `k` in all: each source's first, in the order given, then each one's second, and so on.
/// None when the local hits fill `k`.
pub fn interleave(local: usize, k: usize, sources: Vec<Vec<Item>>) -> Vec<Item> {
let room = k.saturating_sub(local);
let mut iters: Vec<std::vec::IntoIter<Item>> =
sources.into_iter().map(Vec::into_iter).collect();
let mut taken: Vec<Item> = Vec::new();
loop {
if taken.len() >= room {
break;
}
let mut advanced = false;
for iter in iters.iter_mut() {
if taken.len() >= room {
break;
}
if let Some(item) = iter.next() {
taken.push(item);
advanced = true;
}
}
if !advanced {
break;
}
}
taken
}
/// `items` rendered one after another, numbered from `first`, joined by newlines.
pub fn render(first: usize, items: &[Item]) -> String {
items
.iter()
.enumerate()
.map(|(i, item)| item.render(first + i))
.collect::<Vec<_>>()
.join("\n")
}
/// The label the shown items give the answer: the highest of their classes, `private` at the
/// least, and untrusted when any is. Only items shown count.
pub fn label(items: &[Item]) -> (DataClass, bool) {
let class = items
.iter()
.fold(DataClass::Private, |a, b| DataClass::max(a, b.class));
let untrusted = items.iter().any(|i| i.untrusted);
(class, untrusted)
}
/// One `{name}: unavailable` line per source that did not answer, in the order given, joined by
/// newlines; empty when every source answered.
pub fn unavailable(names: &[&str]) -> String {
names
.iter()
.map(|name| format!("{name}: unavailable"))
.collect::<Vec<_>>()
.join("\n")
}
+1 -1
View File
@@ -44,7 +44,7 @@ pub fn terms(query: &str) -> Option<String> {
}
/// `2026-09-20 14:02 UTC` from `2026-09-20T14:02:00.000Z` or `2026-09-20T14:02Z`.
fn when(time: &str) -> String {
pub(crate) 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.