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:
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user