Files
boxmaker/crates/memoryd/src/edit.rs
T
kyle b51b320706 memoryd: notes.md, and the five memory actions with routing by trust
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
2026-09-25 16:41:44 -07:00

305 lines
10 KiB
Rust

//! Applying a `memory` call (M5a spec, section 5). `note` records experience in any session;
//! the identity actions change `memory.md` or `user.md`, and only a session that has not read
//! untrusted content may use them. Every refusal is a fixed sentence: it reaches the model.
use std::collections::HashSet;
use proto::{DataClass, MemoryArgs, MemoryFile, Timestamp};
use crate::check::check;
use crate::entries;
use crate::files::{Store, grouped};
use crate::notes::{Kind, Note, minute};
/// The session a call comes from, as `brokerd` saw it when it decided the call.
#[derive(Debug, Clone)]
pub struct Context {
pub session: String,
pub call: u64,
pub taint: DataClass,
pub untrusted: bool,
pub now: Timestamp,
}
/// What an applied call changed: an identity file's entries before and after (both empty for a
/// note), and the notes it appended.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
pub file: Option<MemoryFile>,
pub before: Vec<String>,
pub after: Vec<String>,
pub notes: Vec<Note>,
}
/// The answer to the model, and the change for the journal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub action: &'static str,
pub answer: String,
pub change: Change,
}
/// The sentence for a write over a file's limit.
pub fn over_limit(file: MemoryFile, size: usize) -> String {
format!(
"{} would be {} of {} characters; use consolidate to move entries to notes",
file.name(),
grouped(size),
grouped(file.limit())
)
}
/// The sentence for an identity edit other than `add` from a session that read untrusted content.
pub fn untrusted_refusal(file: MemoryFile) -> String {
format!(
"this session has read untrusted content; it can take notes but not change {}",
file.name()
)
}
/// The answer when an untrusted session's `add` became a note.
pub const SAVED_AS_NOTE: &str =
"saved as a note (untrusted): this session has read untrusted content";
fn stale(file: MemoryFile) -> String {
format!(
"no entry of {} is exactly this text; the file may have changed; nothing was changed",
file.name()
)
}
fn checked(text: &str) -> Result<String, String> {
check(text).map_err(|refused| refused.sentence().to_string())?;
Ok(text.trim().to_string())
}
fn note(ctx: &Context, kind: Kind, text: &str) -> Note {
Note {
kind,
time: minute(ctx.now),
session: ctx.session.clone(),
text: text.to_string(),
}
}
/// Apply `args` for the session in `ctx`. `Err` is the sentence for the model; nothing changed.
pub fn apply(store: &Store, args: &MemoryArgs, ctx: &Context) -> Result<Outcome, String> {
// 1. `args.check()?`.
args.check()?;
// 2. A Note: return `noted(store, ctx, text, "note")`. Otherwise take the file.
let file = match args {
MemoryArgs::Note { text } => {
return noted(store, ctx, text, "note");
}
MemoryArgs::Add { file, .. }
| MemoryArgs::Replace { file, .. }
| MemoryArgs::Remove { file, .. }
| MemoryArgs::Consolidate { file, .. } => *file,
};
// 3. When `ctx.untrusted`: an Add becomes `noted(store, ctx, text, "add")?` with its answer
// replaced by SAVED_AS_NOTE; any other action -> Err(untrusted_refusal(file)).
if ctx.untrusted {
match args {
MemoryArgs::Add { text, .. } => {
let outcome = noted(store, ctx, text, "add")?;
return Ok(Outcome {
answer: SAVED_AS_NOTE.to_string(),
..outcome
});
}
_ => return Err(untrusted_refusal(file)),
}
}
// 4. `let before = store.entries(file)?;` then `let (action, after, moved) = edited(file,
// &before, args, ctx)?;`.
let before = store.entries(file)?;
let (action, after, moved) = edited(file, &before, args, ctx)?;
// 5. When `after` holds the same entry twice (collect into a HashSet and compare lengths):
// Err(format!("{} would hold the same entry twice", file.name())).
let unique: HashSet<&String> = after.iter().collect();
if unique.len() != after.len() {
return Err(format!("{} would hold the same entry twice", file.name()));
}
// 6. `size = entries::size(&after)`; size > file.limit() -> Err(over_limit(file, size)).
let size = entries::size(&after);
if size > file.limit() {
return Err(over_limit(file, size));
}
// 7. `store.write_entries(file, &after)?`, then `store.append("notes.md", &note.render())?` for
// each note in `moved`.
store.write_entries(file, &after)?;
for entry in &moved {
store.append("notes.md", &entry.render())?;
}
// 8. Ok(Outcome { action, answer: format!("{} updated ({} of {} characters); it takes effect in
// the next session", file.name(), grouped(size), grouped(file.limit())), change: Change {
// file: Some(file), before, after, notes: moved } }).
Ok(Outcome {
action,
answer: format!(
"{} updated ({} of {} characters); it takes effect in the next session",
file.name(),
grouped(size),
grouped(file.limit())
),
change: Change {
file: Some(file),
before,
after,
notes: moved,
},
})
}
fn edited(
file: MemoryFile,
before: &[String],
args: &MemoryArgs,
ctx: &Context,
) -> Result<(&'static str, Vec<String>, Vec<Note>), String> {
match args {
MemoryArgs::Add { text, .. } => {
let text = checked(text)?;
if before.contains(&text) {
return Err(format!("{} already has this entry", file.name()));
}
let mut after = before.to_vec();
after.push(text);
Ok(("add", after, Vec::new()))
}
MemoryArgs::Replace { old, text, .. } => {
let pos = position(before, old).ok_or_else(|| stale(file))?;
let text = checked(text)?;
if before
.iter()
.enumerate()
.any(|(i, e)| i != pos && *e == text)
{
return Err(format!("{} already has this entry", file.name()));
}
let mut after = before.to_vec();
after[pos] = text;
Ok(("replace", after, Vec::new()))
}
MemoryArgs::Remove { old, .. } => {
let pos = position(before, old).ok_or_else(|| stale(file))?;
let gone = before[pos].clone();
let mut after = before.to_vec();
after.remove(pos);
Ok(("remove", after, vec![note(ctx, Kind::Dropped(file), &gone)]))
}
MemoryArgs::Consolidate { entries, .. } => {
let (after, notes) = consolidate(file, before, entries, ctx)?;
Ok(("consolidate", after, notes))
}
MemoryArgs::Note { .. } => Err(
"a note is not an identity edit".to_string(), // `apply` takes notes before it asks for an identity edit.
),
}
}
/// Where `old` is, matched exactly (after trimming, as entries are stored).
fn position(list: &[String], old: &str) -> Option<usize> {
list.iter().position(|e| e == old.trim())
}
fn noted(
store: &Store,
ctx: &Context,
text: &str,
action: &'static str,
) -> Result<Outcome, String> {
let text = checked(text)?;
let entry = note(
ctx,
Kind::Noted {
class: ctx.taint,
untrusted: ctx.untrusted,
},
&text,
);
store.append("notes.md", &entry.render())?;
Ok(Outcome {
action,
answer: if ctx.untrusted {
"noted (untrusted)"
} else {
"noted"
}
.to_string(),
change: Change {
file: None,
before: Vec::new(),
after: Vec::new(),
notes: vec![entry],
},
})
}
fn consolidate(
file: MemoryFile,
before: &[String],
lines: &[proto::ConsolidateEntry],
ctx: &Context,
) -> Result<(Vec<String>, Vec<Note>), String> {
let mut named: HashSet<usize> = HashSet::new();
let mut after = Vec::new();
let mut out = Vec::new();
for line in lines {
let action = line.action().map_err(|e| e.to_string())?;
match action {
proto::EntryAction::Keep(old) => {
let pos = position(before, &old).ok_or_else(|| stale(file))?;
if !named.insert(pos) {
return Err(format!(
"consolidate names an entry of {} twice",
file.name()
));
}
after.push(old.trim().to_string());
}
proto::EntryAction::Rewrite { old, text } => {
let pos = position(before, &old).ok_or_else(|| stale(file))?;
if !named.insert(pos) {
return Err(format!(
"consolidate names an entry of {} twice",
file.name()
));
}
after.push(checked(&text)?);
}
proto::EntryAction::Move(old) => {
let pos = position(before, &old).ok_or_else(|| stale(file))?;
if !named.insert(pos) {
return Err(format!(
"consolidate names an entry of {} twice",
file.name()
));
}
out.push(note(ctx, Kind::Moved(file), old.trim()));
}
proto::EntryAction::Drop(old) => {
let pos = position(before, &old).ok_or_else(|| stale(file))?;
if !named.insert(pos) {
return Err(format!(
"consolidate names an entry of {} twice",
file.name()
));
}
out.push(note(ctx, Kind::Dropped(file), old.trim()));
}
proto::EntryAction::New(text) => {
after.push(checked(&text)?);
}
}
}
let missing = before.len() - named.len();
if missing != 0 {
return Err(format!(
"consolidate must name every entry of {}; it leaves out {missing}",
file.name()
));
}
Ok((after, out))
}