M5c-2 plan: task 10, ask_memory in memoryd; task 08 keeps its own copy of the fake Honcho

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 20:18:33 -07:00
co-authored by Claude Opus 5.5
parent 30538b0243
commit 531a723e39
7 changed files with 1101 additions and 5 deletions
@@ -0,0 +1,151 @@
//! `ask_memory` (M5c spec, section 9): the one or two workspaces named, each asked by Honcho's
//! workspace chat at `minimal`, both at once, each on its own thread with its own limit; the
//! answers labelled as `recall` hits are. A name no workspace has, or a question that holds a
//! secret, asks nothing. Runs outside `memoryd`'s state lock; a chat still running at its limit is
//! left to finish, and its answer is dropped.
use std::sync::mpsc;
use std::time::{Duration, Instant};
use bxcore::AskMemoryArgs;
use roncho::{DialecticOptions, ReasoningLevel};
use crate::honcho::client::{CHAT_READ, HonchoError, classify};
use crate::honcho::decide::has_secret;
use crate::honcho::read::{Label, Reader, Readers};
use crate::honcho::recalled::{Item, label, render, unavailable};
use crate::recall::{Recalled, one_line};
use crate::serve::Log;
/// How long each chat has, counted from when the chats start (M5c spec, section 9).
pub const CHAT_WAIT: Duration = CHAT_READ;
/// The most characters of one answer: a chat answers with a paragraph or two of reasoning, not a
/// snippet, so its cap is larger than `recall`'s 600.
pub const ANSWER_CAP: usize = 2_000;
/// The answer when the question holds a known-prefix secret.
pub const SECRET: &str = "ask_memory: not asked; the question looks like it holds a secret";
/// The text of an answer with no content.
pub const NO_ANSWER: &str = "(no answer)";
/// One workspace asked: its label, and its answer's content, `None` when it did not answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chat {
pub label: Label,
pub answer: Option<String>,
}
/// What `ask` did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Asked {
/// Nothing was sent: the sentence the model is shown.
NotSent(String),
/// Each workspace named, in the order named.
Sent(Vec<Chat>),
}
/// One workspace's chat at `minimal`: its answer's content, or why it failed. The blocking
/// client refuses a workspace chat with a `target` or `filters`; neither is set.
pub fn chat(reader: &Reader, question: &str) -> Result<String, HonchoError> {
// 1. options: `DialecticOptions { query: question.to_string(), reasoning_level:
// Some(ReasoningLevel::Minimal), ..Default::default() }`.
// 2. `reader.chat().workspace_chat(&options)`, its error mapped with `classify(&e)`, then `?`.
// 3. `Ok` of the response's `content()` as a `String`.
todo!()
}
/// Ask each workspace `args` names (`own` when it names none) at once, each on a thread of its
/// own, and wait for each at most `wait` from now. A chat that failed, or that has not answered
/// by then, has no answer; each is logged by its name and the error's kind and status, never the
/// question or an answer. `NotSent` with nothing sent when a name is none of `readers`'
/// (`resolve`'s sentence) or the question holds a known-prefix secret.
pub fn ask(readers: &Readers, args: &AskMemoryArgs, wait: Duration, log: &Log) -> Asked {
let mut asked: Vec<Reader> = Vec::new();
for name in args.asked() {
match readers.resolve(name) {
Ok(reader) => asked.push(reader.clone()),
Err(sentence) => return Asked::NotSent(sentence),
}
}
if has_secret(&args.question) {
return Asked::NotSent(SECRET.to_string());
}
let deadline = Instant::now() + wait;
let (tx, rx) = mpsc::channel::<(usize, Result<String, HonchoError>)>();
let mut answers: Vec<Option<Result<String, HonchoError>>> = vec![None; asked.len()];
for (i, slot) in answers.iter_mut().enumerate() {
let sender = tx.clone();
let reader = asked.get(i).cloned();
let question = args.question.clone();
let spawned = std::thread::Builder::new()
.name("honcho-ask".to_string())
.spawn(move || {
if let Some(reader) = reader {
let _ = sender.send((i, chat(&reader, &question)));
}
});
if spawned.is_err() {
*slot = Some(Err(HonchoError::Unreachable {
kind: "thread",
status: None,
}));
}
}
drop(tx);
while answers.iter().any(Option::is_none) {
let left = deadline.saturating_duration_since(Instant::now());
match rx.recv_timeout(left) {
Ok((i, answer)) => {
if let Some(slot) = answers.get_mut(i) {
*slot = Some(answer);
}
}
Err(_) => break,
}
}
let mut chats = Vec::with_capacity(asked.len());
for (reader, answer) in asked.into_iter().zip(answers) {
let name = &reader.label.name;
let answer = match answer {
Some(Ok(content)) => Some(content),
Some(Err(e)) => {
(log)(&format!("memoryd: ask_memory: {name} unavailable: {e}"));
None
}
None => {
(log)(&format!(
"memoryd: ask_memory: {name} unavailable: no answer in {} ms",
wait.as_millis()
));
None
}
};
chats.push(Chat {
label: reader.label,
answer,
});
}
Asked::Sent(chats)
}
/// `ask_memory`'s answer: each workspace that answered, in the order named, as an item whose
/// header names the workspace (`[1] memory · own · private`) and whose text is its answer on one
/// indented line, cut to `ANSWER_CAP` characters (`(no answer)` when it is empty); then one
/// `{name}: unavailable` line for each that did not. `NotSent`'s sentence alone, labelled
/// `private` and trusted. The label is the highest class of the answers shown, `private` at the
/// least, and untrusted when any is.
pub fn answer(asked: Asked) -> Recalled {
// 1. `NotSent(sentence)` -> Recalled { text: sentence, class: bxcore::DataClass::Private,
// untrusted: false }.
// 2. `Sent(chats)`: for each chat with an answer, an Item { tag: label.tag(), from:
// label.name.clone(), class: label.class, untrusted: label.untrusted(), text:
// `one_line(content, ANSWER_CAP)`, or `NO_ANSWER` when that is empty once trimmed }; and
// the names (`&str`) of those without one, in the same order.
// 3. parts: `render(1, &items)` if there are items; then `unavailable(&names)` if there are
// names.
// 4. (class, untrusted) = `label(&items)`; Recalled { text: parts joined by "\n", class,
// untrusted }.
todo!()
}