The journal now records each call's class (absent on the owner's undo and on older lines, which still read). The mirror turns everything a secret write would conclude or send to the fleet into one Skip, after its deletions, which still go; a note whose own header says secret is skipped the same way. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
175 lines
5.5 KiB
Rust
175 lines
5.5 KiB
Rust
//! `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 bxcore::{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();
|
|
format!("{}Z", s.get(..16).unwrap_or(&s))
|
|
}
|
|
|
|
/// A class as the notes and the journal spell it, the same as on the wire.
|
|
pub 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(&"untrusted") => true,
|
|
Some(_) => return None,
|
|
};
|
|
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
|
|
}
|