memoryd: entries, and the check on every memory write

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 178484a981
commit ad5ce5c6e7
6 changed files with 282 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
//! The check on every entry the agent writes, and again on every identity entry when the block is
//! built (M5a spec, section 5, "The check"). Short and fixed on purpose: defence in depth. The
//! real defence is that a session which read untrusted content can only take notes.
/// Which rule an entry broke. The sentences are fixed: they reach the model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refused {
Control,
Invisible,
Markup,
Phrase,
}
impl Refused {
pub fn sentence(self) -> &'static str {
match self {
Refused::Control => "memory refused this entry: it contains a control character",
Refused::Invisible => {
"memory refused this entry: it contains an invisible or direction-changing character"
}
Refused::Markup => "memory refused this entry: it contains chat-template markup",
Refused::Phrase => "memory refused this entry: it contains an instruction phrase",
}
}
}
/// Unicode 16's `Default_Ignorable_Code_Point` (DerivedCoreProperties.txt), inclusive ranges.
const DEFAULT_IGNORABLE: &[(u32, u32)] = &[
(0x00AD, 0x00AD),
(0x034F, 0x034F),
(0x061C, 0x061C),
(0x115F, 0x1160),
(0x17B4, 0x17B5),
(0x180B, 0x180F),
(0x200B, 0x200F),
(0x202A, 0x202E),
(0x2060, 0x206F),
(0x3164, 0x3164),
(0xFE00, 0xFE0F),
(0xFEFF, 0xFEFF),
(0xFFA0, 0xFFA0),
(0xFFF0, 0xFFF8),
(0x1BCA0, 0x1BCA3),
(0x1D173, 0x1D17A),
(0xE0000, 0xE0FFF),
];
/// Invisible without being default-ignorable: the Braille blank, and the line and paragraph
/// separators.
const ALSO_INVISIBLE: &[u32] = &[0x2800, 0x2028, 0x2029];
// Built with `concat!` so this file never holds the markup itself: a model reading it could take
// the markup for its own.
const MARKUP: &[&str] = &[
concat!("<", "|"),
concat!("|", ">"),
concat!("<", "think"),
concat!("<", "/think"),
concat!("<", "tool_call"),
concat!("<", "/tool_call"),
];
const PHRASES: &[&str] = &[
"ignore previous instructions",
"ignore all previous",
"disregard previous instructions",
"disregard all previous",
"new instructions:",
"system prompt:",
];
/// Whether `c` is refused by rule 2.
pub fn invisible(c: char) -> bool {
let code = u32::from(c);
ALSO_INVISIBLE.contains(&code)
|| DEFAULT_IGNORABLE
.iter()
.any(|&(low, high)| (low..=high).contains(&code))
}
/// The first rule `text` breaks, in the order of the spec, or `Ok`.
pub fn check(text: &str) -> Result<(), Refused> {
if text
.chars()
.any(|c| c.is_control() && c != '\n' && c != '\t')
{
return Err(Refused::Control);
}
if text.chars().any(invisible) {
return Err(Refused::Invisible);
}
let lower = text.to_lowercase();
if MARKUP.iter().any(|m| lower.contains(m)) {
return Err(Refused::Markup);
}
// Runs of spaces, tabs and newlines count as one space.
let collapsed = lower.split_whitespace().collect::<Vec<_>>().join(" ");
if PHRASES.iter().any(|p| collapsed.contains(p)) {
return Err(Refused::Phrase);
}
Ok(())
}
+44
View File
@@ -0,0 +1,44 @@
//! The entries of `memory.md` and `user.md`: text separated by a line holding only `§` (M5a spec,
//! section 3). A file's size is the characters of its entries joined by separator lines, which
//! is what `render` writes, less the final newline.
use proto::memory::SEPARATOR;
/// The entries of a file's text: split at lines that are only `§` (spaces around it allowed),
/// each trimmed, empty ones left out.
pub fn parse(text: &str) -> Vec<String> {
let mut entries: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in text.lines() {
if line.trim() == SEPARATOR {
push(&mut entries, &current);
current.clear();
} else {
current.push(line);
}
}
push(&mut entries, &current);
entries
}
fn push(entries: &mut Vec<String>, lines: &[&str]) {
let joined = lines.join("\n");
let trimmed = joined.trim();
if !trimmed.is_empty() {
entries.push(trimmed.to_string());
}
}
/// The entries joined by `\n§\n`, with a final newline; an empty list is an empty file.
pub fn render(entries: &[String]) -> String {
if entries.is_empty() {
return String::new();
}
let joined = entries.join(&format!("\n{SEPARATOR}\n"));
format!("{joined}\n")
}
/// A file's size in characters, the one its limit is measured against.
pub fn size(entries: &[String]) -> usize {
render(entries).trim_end_matches('\n').chars().count()
}
+2
View File
@@ -1,4 +1,6 @@
//! The memory role: the memory files, their index, and every memory tool call (M5a spec).
pub mod check;
pub mod config;
pub mod entries;
pub mod socket;