398 lines
14 KiB
Rust
398 lines
14 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.
|
|
//!
|
|
//! Every text a write would store goes through the sanitizer (M5b spec, section 7): a text that
|
|
//! breaks a refuse rule is refused; one that matches a flag rule is kept, one tier lower, as a
|
|
//! note marked untrusted (section 6.3).
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use bxcore::sanitize::{Refusal, flag, refuse};
|
|
use bxcore::{DataClass, MemoryArgs, MemoryFile, Timestamp};
|
|
|
|
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 flag rule a text of the write matched, which made it an untrusted note.
|
|
pub flag: Option<&'static str>,
|
|
}
|
|
|
|
/// 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. It names the file it did not change:
|
|
/// on the device the model read a shorter answer as success.
|
|
pub fn saved_as_note(file: MemoryFile) -> String {
|
|
format!(
|
|
"not added to {}: this session has read untrusted content, so it was saved in notes.md as an untrusted note",
|
|
file.name()
|
|
)
|
|
}
|
|
|
|
/// The answer to a `note` from a session that has not read untrusted content.
|
|
pub const NOTED: &str = "saved in notes.md";
|
|
|
|
/// The answer to a `note` from a session that has read untrusted content.
|
|
pub const NOTED_UNTRUSTED: &str =
|
|
"saved in notes.md, marked 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()
|
|
)
|
|
}
|
|
|
|
/// The answer when an identity write matched a flag rule and became notes (M5b spec, section 6.3).
|
|
pub fn flagged_as_note(file: MemoryFile, rule: &str) -> String {
|
|
format!(
|
|
"not added to {}: it matches the rule {rule}, so it was saved in notes.md as an untrusted note",
|
|
file.name()
|
|
)
|
|
}
|
|
|
|
/// The answer to a `note` that matched a flag rule, from a session that has not read untrusted
|
|
/// content.
|
|
pub fn noted_flagged(rule: &str) -> String {
|
|
format!("saved in notes.md, marked untrusted: it matches the rule {rule}")
|
|
}
|
|
|
|
/// The texts `args` would store: the `text` of an add, a replace or a note, and every `text` of a
|
|
/// consolidate (its rewrites and new entries). An `old` is not here: it names what is stored.
|
|
fn texts(args: &MemoryArgs) -> Vec<&str> {
|
|
match args {
|
|
MemoryArgs::Add { text, .. }
|
|
| MemoryArgs::Replace { text, .. }
|
|
| MemoryArgs::Note { text } => vec![text.as_str()],
|
|
MemoryArgs::Remove { .. } => Vec::new(),
|
|
MemoryArgs::Consolidate { entries, .. } => {
|
|
entries.iter().filter_map(|e| e.text.as_deref()).collect()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The refusal of the first text `args` would store that breaks a refuse rule.
|
|
pub fn refused(args: &MemoryArgs) -> Option<Refusal> {
|
|
texts(args).into_iter().find_map(refuse)
|
|
}
|
|
|
|
/// The rule of the first text `args` would store that matches a flag rule.
|
|
pub fn flagged(args: &MemoryArgs) -> Option<&'static str> {
|
|
texts(args).into_iter().find_map(flag).map(|f| f.rule)
|
|
}
|
|
|
|
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()?;
|
|
// 1a. A text that breaks a refuse rule: Err(refusal.sentence), before anything is written.
|
|
if let Some(refusal) = refused(args) {
|
|
return Err(refusal.sentence.to_string());
|
|
}
|
|
// 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(file); 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(file),
|
|
..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)?;
|
|
// 4a. A text that matches a flag rule: `as_notes` saves every text of the write as an
|
|
// untrusted note instead, and the file is not changed.
|
|
if let Some(rule) = flagged(args) {
|
|
return as_notes(store, ctx, file, action, &texts(args), rule);
|
|
}
|
|
// 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,
|
|
},
|
|
flag: None,
|
|
})
|
|
}
|
|
|
|
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 = text.trim().to_string();
|
|
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 = text.trim().to_string();
|
|
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 rule = flag(text).map(|f| f.rule);
|
|
let entry = note(
|
|
ctx,
|
|
Kind::Noted {
|
|
class: ctx.taint,
|
|
untrusted: ctx.untrusted || rule.is_some(),
|
|
},
|
|
text.trim(),
|
|
);
|
|
store.append("notes.md", &entry.render())?;
|
|
let answer = match (ctx.untrusted, rule) {
|
|
(true, _) => NOTED_UNTRUSTED.to_string(),
|
|
(false, Some(rule)) => noted_flagged(rule),
|
|
(false, None) => NOTED.to_string(),
|
|
};
|
|
Ok(Outcome {
|
|
action,
|
|
answer,
|
|
change: Change {
|
|
file: None,
|
|
before: Vec::new(),
|
|
after: Vec::new(),
|
|
notes: vec![entry],
|
|
},
|
|
flag: rule,
|
|
})
|
|
}
|
|
|
|
/// An identity write that matched the flag rule `rule`: each of `texts`, trimmed, is appended to
|
|
/// `notes.md` as a note marked untrusted, in order, and the identity file is not changed.
|
|
fn as_notes(
|
|
store: &Store,
|
|
ctx: &Context,
|
|
file: MemoryFile,
|
|
action: &'static str,
|
|
texts: &[&str],
|
|
rule: &'static str,
|
|
) -> Result<Outcome, String> {
|
|
let mut notes = Vec::new();
|
|
for text in texts {
|
|
let entry = note(
|
|
ctx,
|
|
Kind::Noted {
|
|
class: ctx.taint,
|
|
untrusted: true,
|
|
},
|
|
text.trim(),
|
|
);
|
|
store.append("notes.md", &entry.render())?;
|
|
notes.push(entry);
|
|
}
|
|
Ok(Outcome {
|
|
action,
|
|
answer: flagged_as_note(file, rule),
|
|
change: Change {
|
|
file: None,
|
|
before: Vec::new(),
|
|
after: Vec::new(),
|
|
notes,
|
|
},
|
|
flag: Some(rule),
|
|
})
|
|
}
|
|
|
|
/// Mark the entry `old` names as named: stale if `before` has no such entry, an error if it was
|
|
/// named already.
|
|
fn claim(
|
|
file: MemoryFile,
|
|
before: &[String],
|
|
named: &mut HashSet<usize>,
|
|
old: &str,
|
|
) -> Result<(), String> {
|
|
let pos = position(before, old).ok_or_else(|| stale(file))?;
|
|
if named.insert(pos) {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"consolidate names an entry of {} twice",
|
|
file.name()
|
|
))
|
|
}
|
|
}
|
|
|
|
fn consolidate(
|
|
file: MemoryFile,
|
|
before: &[String],
|
|
lines: &[bxcore::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 {
|
|
bxcore::EntryAction::Keep(old) => {
|
|
claim(file, before, &mut named, &old)?;
|
|
after.push(old.trim().to_string());
|
|
}
|
|
bxcore::EntryAction::Rewrite { old, text } => {
|
|
claim(file, before, &mut named, &old)?;
|
|
after.push(text.trim().to_string());
|
|
}
|
|
bxcore::EntryAction::Move(old) => {
|
|
claim(file, before, &mut named, &old)?;
|
|
out.push(note(ctx, Kind::Moved(file), old.trim()));
|
|
}
|
|
bxcore::EntryAction::Drop(old) => {
|
|
claim(file, before, &mut named, &old)?;
|
|
out.push(note(ctx, Kind::Dropped(file), old.trim()));
|
|
}
|
|
bxcore::EntryAction::New(text) => {
|
|
after.push(text.trim().to_string());
|
|
}
|
|
}
|
|
}
|
|
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))
|
|
}
|