memoryd: notes.md, and the five memory actions with routing by trust
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
//! 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", ¬e.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))
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
pub mod check;
|
||||
pub mod config;
|
||||
pub mod edit;
|
||||
pub mod entries;
|
||||
pub mod files;
|
||||
pub mod notes;
|
||||
pub mod socket;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `notes.md`: experience, append-only. Each entry is a header line saying how it got there,
|
||||
//! then its text (M5a spec, section 3):
|
||||
//!
|
||||
//! ```text
|
||||
//! § noted 2026-09-24T21:02Z in session mm-dh45qg… · private · untrusted
|
||||
//! § moved 2026-09-24T20:14Z from memory.md in session mm-y73sxj…
|
||||
//! § dropped 2026-09-24T20:14Z from memory.md in session mm-y73sxj…
|
||||
//! ```
|
||||
|
||||
use proto::{DataClass, MemoryFile, Timestamp};
|
||||
|
||||
/// How an entry came into `notes.md`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// Something seen or told, with the session's class and whether it had read untrusted
|
||||
/// content.
|
||||
Noted { class: DataClass, untrusted: bool },
|
||||
/// Identity that `consolidate` took out of the prompt: still held true.
|
||||
Moved(MemoryFile),
|
||||
/// Identity the agent stopped holding true.
|
||||
Dropped(MemoryFile),
|
||||
}
|
||||
|
||||
/// One entry of `notes.md`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Note {
|
||||
pub kind: Kind,
|
||||
/// `YYYY-MM-DDTHH:MMZ`.
|
||||
pub time: String,
|
||||
pub session: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A time as the notes show it: to the minute, in UTC.
|
||||
pub fn minute(time: Timestamp) -> String {
|
||||
let s = time.to_rfc3339();
|
||||
let head = if s.len() >= 16 { &s[..16] } else { &s };
|
||||
format!("{head}Z")
|
||||
}
|
||||
|
||||
fn class_name(class: DataClass) -> &'static str {
|
||||
match class {
|
||||
DataClass::Public => "public",
|
||||
DataClass::Private => "private",
|
||||
DataClass::Secret => "secret",
|
||||
}
|
||||
}
|
||||
|
||||
fn file_of(name: &str) -> Option<MemoryFile> {
|
||||
match name {
|
||||
"memory.md" => Some(MemoryFile::Memory),
|
||||
"user.md" => Some(MemoryFile::User),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Note {
|
||||
/// The header line, without its newline.
|
||||
pub fn header(&self) -> String {
|
||||
match &self.kind {
|
||||
Kind::Noted { class, untrusted } => {
|
||||
let mut s = format!(
|
||||
"§ noted {} in session {} · {}",
|
||||
self.time,
|
||||
self.session,
|
||||
class_name(*class)
|
||||
);
|
||||
if *untrusted {
|
||||
s.push_str(" · untrusted");
|
||||
}
|
||||
s
|
||||
}
|
||||
Kind::Moved(file) => {
|
||||
format!(
|
||||
"§ moved {} from {} in session {}",
|
||||
self.time,
|
||||
file.name(),
|
||||
self.session
|
||||
)
|
||||
}
|
||||
Kind::Dropped(file) => {
|
||||
format!(
|
||||
"§ dropped {} from {} in session {}",
|
||||
self.time,
|
||||
file.name(),
|
||||
self.session
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The header, the text and a final newline: what is appended to `notes.md`.
|
||||
pub fn render(&self) -> String {
|
||||
format!("{}\n{}\n", self.header(), self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a header line back, or `None` when the line is not one.
|
||||
fn parse_header(line: &str) -> Option<(Kind, String, String)> {
|
||||
let after = line.strip_prefix("§ ")?;
|
||||
let (word, rest) = after.split_once(' ')?;
|
||||
let (time, rest) = rest.split_once(' ')?;
|
||||
match word {
|
||||
"noted" => {
|
||||
let rest = rest.strip_prefix("in session ")?;
|
||||
let parts: Vec<&str> = rest.split(" · ").collect();
|
||||
if parts.len() < 2 || parts.len() > 3 {
|
||||
return None;
|
||||
}
|
||||
let session = parts[0];
|
||||
if session.is_empty() || session.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
let class = match parts[1] {
|
||||
"public" => DataClass::Public,
|
||||
"private" => DataClass::Private,
|
||||
"secret" => DataClass::Secret,
|
||||
_ => return None,
|
||||
};
|
||||
let untrusted = match parts.get(2) {
|
||||
None => false,
|
||||
Some(s) => *s == "untrusted",
|
||||
};
|
||||
Some((
|
||||
Kind::Noted { class, untrusted },
|
||||
time.to_string(),
|
||||
session.to_string(),
|
||||
))
|
||||
}
|
||||
"moved" | "dropped" => {
|
||||
let rest = rest.strip_prefix("from ")?;
|
||||
let (file_name, after) = rest.split_once(' ')?;
|
||||
let file = file_of(file_name)?;
|
||||
let after = after.strip_prefix("in session ")?;
|
||||
if after.is_empty() || after.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
let kind = match word {
|
||||
"moved" => Kind::Moved(file),
|
||||
_ => Kind::Dropped(file),
|
||||
};
|
||||
Some((kind, time.to_string(), after.to_string()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries of `notes.md`. Text before the first header, or under a line that is not a
|
||||
/// header, belongs to the entry above it; the owner may have edited the file.
|
||||
pub fn parse(text: &str) -> Vec<Note> {
|
||||
let mut notes: Vec<Note> = Vec::new();
|
||||
for line in text.lines() {
|
||||
if let Some((kind, time, session)) = parse_header(line) {
|
||||
notes.push(Note {
|
||||
kind,
|
||||
time,
|
||||
session,
|
||||
text: String::new(),
|
||||
});
|
||||
} else if let Some(last) = notes.last_mut() {
|
||||
if last.text.is_empty() {
|
||||
last.text.push_str(line);
|
||||
} else {
|
||||
last.text.push('\n');
|
||||
last.text.push_str(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
for note in &mut notes {
|
||||
note.text = note.text.trim().to_string();
|
||||
}
|
||||
notes
|
||||
}
|
||||
Reference in New Issue
Block a user