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:
2026-09-25 16:41:44 -07:00
committed by kyle
parent f5981338b4
commit b51b320706
6 changed files with 824 additions and 0 deletions
+173
View File
@@ -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
}