memoryd: refuse and flag memory writes through bxcore::sanitize
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -1,102 +0,0 @@
|
||||
//! 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(())
|
||||
}
|
||||
+103
-17
@@ -1,12 +1,16 @@
|
||||
//! 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::check::check;
|
||||
use crate::entries;
|
||||
use crate::files::{Store, grouped};
|
||||
use crate::notes::{Kind, Note, minute};
|
||||
@@ -37,6 +41,8 @@ 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.
|
||||
@@ -80,9 +86,42 @@ fn stale(file: MemoryFile) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn checked(text: &str) -> Result<String, String> {
|
||||
check(text).map_err(|refused| refused.sentence().to_string())?;
|
||||
Ok(text.trim().to_string())
|
||||
/// 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 {
|
||||
@@ -98,6 +137,10 @@ fn note(ctx: &Context, kind: Kind, text: &str) -> Note {
|
||||
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 } => {
|
||||
@@ -126,6 +169,11 @@ pub fn apply(store: &Store, args: &MemoryArgs, ctx: &Context) -> Result<Outcome,
|
||||
// &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();
|
||||
@@ -160,6 +208,7 @@ pub fn apply(store: &Store, args: &MemoryArgs, ctx: &Context) -> Result<Outcome,
|
||||
after,
|
||||
notes: moved,
|
||||
},
|
||||
flag: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,7 +220,7 @@ fn edited(
|
||||
) -> Result<(&'static str, Vec<String>, Vec<Note>), String> {
|
||||
match args {
|
||||
MemoryArgs::Add { text, .. } => {
|
||||
let text = checked(text)?;
|
||||
let text = text.trim().to_string();
|
||||
if before.contains(&text) {
|
||||
return Err(format!("{} already has this entry", file.name()));
|
||||
}
|
||||
@@ -181,7 +230,7 @@ fn edited(
|
||||
}
|
||||
MemoryArgs::Replace { old, text, .. } => {
|
||||
let pos = position(before, old).ok_or_else(|| stale(file))?;
|
||||
let text = checked(text)?;
|
||||
let text = text.trim().to_string();
|
||||
if before
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -221,30 +270,67 @@ fn noted(
|
||||
text: &str,
|
||||
action: &'static str,
|
||||
) -> Result<Outcome, String> {
|
||||
let text = checked(text)?;
|
||||
let rule = flag(text).map(|f| f.rule);
|
||||
let entry = note(
|
||||
ctx,
|
||||
Kind::Noted {
|
||||
class: ctx.taint,
|
||||
untrusted: ctx.untrusted,
|
||||
untrusted: ctx.untrusted || rule.is_some(),
|
||||
},
|
||||
&text,
|
||||
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: if ctx.untrusted {
|
||||
NOTED_UNTRUSTED
|
||||
} else {
|
||||
NOTED
|
||||
}
|
||||
.to_string(),
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -285,7 +371,7 @@ fn consolidate(
|
||||
}
|
||||
bxcore::EntryAction::Rewrite { old, text } => {
|
||||
claim(file, before, &mut named, &old)?;
|
||||
after.push(checked(&text)?);
|
||||
after.push(text.trim().to_string());
|
||||
}
|
||||
bxcore::EntryAction::Move(old) => {
|
||||
claim(file, before, &mut named, &old)?;
|
||||
@@ -296,7 +382,7 @@ fn consolidate(
|
||||
out.push(note(ctx, Kind::Dropped(file), old.trim()));
|
||||
}
|
||||
bxcore::EntryAction::New(text) => {
|
||||
after.push(checked(&text)?);
|
||||
after.push(text.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-11
@@ -7,8 +7,8 @@ use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bxcore::MemoryFile;
|
||||
use bxcore::sanitize::{Refusal, refuse};
|
||||
|
||||
use crate::check::{Refused, check};
|
||||
use crate::entries;
|
||||
|
||||
/// The most characters `soul.md` may have.
|
||||
@@ -36,12 +36,12 @@ pub struct Store {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
/// The block for a new session, and the identity entries left out of it because they failed the
|
||||
/// check (for `memoryd` to log).
|
||||
/// The block for a new session, and the identity entries left out of it because they break a
|
||||
/// refuse rule (for `memoryd` to log).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Block {
|
||||
pub text: String,
|
||||
pub left_out: Vec<(MemoryFile, Refused)>,
|
||||
pub left_out: Vec<(MemoryFile, Refusal)>,
|
||||
}
|
||||
|
||||
fn fail(path: &Path, e: std::io::Error) -> String {
|
||||
@@ -145,7 +145,8 @@ impl Store {
|
||||
|
||||
/// The block for a new session (M5a spec, section 4). A file over its limit, or unreadable,
|
||||
/// is an error: no session starts without its memory and no sign of it. Identity entries that
|
||||
/// fail the check are left out and reported; `soul.md` is the owner's and is not checked.
|
||||
/// break a refuse rule are left out and reported (M5b spec, section 7), so a new rule protects
|
||||
/// at once; a flag rule is not checked here. `soul.md` is the owner's and is not checked.
|
||||
pub fn block(&self) -> Result<Block, String> {
|
||||
// 1. Read `self.path("soul.md")` (an error through `fail`) and trim() it. When its
|
||||
// chars().count() > SOUL_LIMIT: Err(format!("{}: {} characters; its limit is {}",
|
||||
@@ -154,9 +155,9 @@ impl Store {
|
||||
// soul.
|
||||
// 3. For file in [MemoryFile::Memory, MemoryFile::User]: `self.entries(file)?`; `size =
|
||||
// entries::size(&list)`; when size > file.limit(), the same Err sentence with the path,
|
||||
// size and limit of that file. Each entry: `check(&entry)` Ok -> kept; Err(refused) ->
|
||||
// push (file, refused) onto `left_out`. The body is "(empty)" when nothing is kept, else
|
||||
// `entries::render(&kept).trim_end()`. Append format!("\n\n# {} ({} of {}
|
||||
// size and limit of that file. Each entry: `refuse(entry)` None -> kept; Some(refusal)
|
||||
// -> push (file, refusal) onto `left_out`. The body is "(empty)" when nothing is kept,
|
||||
// else `entries::render(&kept).trim_end()`. Append format!("\n\n# {} ({} of {}
|
||||
// characters)\n{body}", file.name(), grouped(size), grouped(file.limit())) to `text`.
|
||||
// `size` counts the whole file, entries left out included.
|
||||
// 4. Ok(Block { text, left_out }).
|
||||
@@ -194,9 +195,9 @@ impl Store {
|
||||
}
|
||||
let mut kept = Vec::new();
|
||||
for entry in &list {
|
||||
match check(entry) {
|
||||
Ok(()) => kept.push(entry.clone()),
|
||||
Err(refused) => left_out.push((file, refused)),
|
||||
match refuse(entry) {
|
||||
None => kept.push(entry.clone()),
|
||||
Some(refusal) => left_out.push((file, refusal)),
|
||||
}
|
||||
}
|
||||
let body = if kept.is_empty() {
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct Record {
|
||||
/// The `notes.md` entries appended, as written.
|
||||
pub notes: Vec<String>,
|
||||
pub untrusted: bool,
|
||||
/// The flag rule the write matched, when it was saved as untrusted notes (M5b spec, section
|
||||
/// 6.3). Absent from the line otherwise, so earlier lines read as they are.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flag: Option<String>,
|
||||
/// For an undo, the edit it reverted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub undoes: Option<String>,
|
||||
@@ -103,7 +107,8 @@ pub fn record(store: &Store, ctx: &Context, outcome: &Outcome) -> Result<String,
|
||||
// `append` a Record: id empty (append sets it), time `ctx.now.to_rfc3339()`, session and call
|
||||
// from ctx, action `outcome.action`, file `change.file` as its name, to that name or "notes.md"
|
||||
// when there is no file, before and after from the change, notes the change's notes each
|
||||
// `render()`ed, untrusted `ctx.untrusted`, undoes None; `now` is ctx.now.
|
||||
// `render()`ed, untrusted `ctx.untrusted`, flag `outcome.flag` as a String, undoes None; `now`
|
||||
// is ctx.now.
|
||||
let change = &outcome.change;
|
||||
let notes: Vec<String> = change.notes.iter().map(|n| n.render()).collect();
|
||||
let file = change.file.map(|f| f.name().to_string());
|
||||
@@ -125,6 +130,7 @@ pub fn record(store: &Store, ctx: &Context, outcome: &Outcome) -> Result<String,
|
||||
after: change.after.clone(),
|
||||
notes,
|
||||
untrusted: ctx.untrusted,
|
||||
flag: outcome.flag.map(str::to_string),
|
||||
undoes: None,
|
||||
},
|
||||
ctx.now,
|
||||
@@ -134,8 +140,9 @@ pub fn record(store: &Store, ctx: &Context, outcome: &Outcome) -> Result<String,
|
||||
/// The last `count` records, one line each, oldest first.
|
||||
pub fn log(store: &Store, count: usize) -> Result<String, String> {
|
||||
// The last `count` of `records(store)?` (all of them when fewer), oldest first, one line each:
|
||||
// format!("{} {} {} {} {}{}", id, time, session, action, to, and " untrusted" when the
|
||||
// record is untrusted), joined by newlines. With no records at all: "no memory edits yet".
|
||||
// format!("{} {} {} {} {}", id, time, session, action, to), then " untrusted" when the
|
||||
// record is untrusted, then format!(" flagged {rule}") when it has a flag; joined by
|
||||
// newlines. With no records at all: "no memory edits yet".
|
||||
let all = records(store)?;
|
||||
if all.is_empty() {
|
||||
return Ok("no memory edits yet".to_string());
|
||||
@@ -144,17 +151,17 @@ pub fn log(store: &Store, count: usize) -> Result<String, String> {
|
||||
let lines: Vec<String> = selected
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut line = format!(
|
||||
"{} {} {} {} {}",
|
||||
r.id, r.time, r.session, r.action, r.to
|
||||
);
|
||||
if r.untrusted {
|
||||
format!(
|
||||
"{} {} {} {} {} untrusted",
|
||||
r.id, r.time, r.session, r.action, r.to
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} {} {} {} {}",
|
||||
r.id, r.time, r.session, r.action, r.to
|
||||
)
|
||||
line.push_str(" untrusted");
|
||||
}
|
||||
if let Some(rule) = &r.flag {
|
||||
line.push_str(&format!(" flagged {rule}"));
|
||||
}
|
||||
line
|
||||
})
|
||||
.collect();
|
||||
Ok(lines.join("\n"))
|
||||
@@ -172,7 +179,7 @@ pub fn undo(store: &Store, id: &str, now: Timestamp) -> Result<String, String> {
|
||||
// {id}; nothing was undone")).
|
||||
// 5. `store.write_entries(file, &record.before)?`, then `append` a Record: session "owner",
|
||||
// call 0, action "undo", file and to the name, before the current entries, after
|
||||
// record.before, no notes, untrusted false, undoes Some(id).
|
||||
// record.before, no notes, untrusted false, flag None, undoes Some(id).
|
||||
// 6. Ok(format!("undone: {id} ({name} restored; journalled as {undo_id})")).
|
||||
let all = records(store)?;
|
||||
let record = all
|
||||
@@ -218,6 +225,7 @@ pub fn undo(store: &Store, id: &str, now: Timestamp) -> Result<String, String> {
|
||||
after: record.before.clone(),
|
||||
notes: Vec::new(),
|
||||
untrusted: false,
|
||||
flag: None,
|
||||
undoes: Some(id.to_string()),
|
||||
},
|
||||
now,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! The memory role: the memory files, their index, and every memory tool call (M5a spec).
|
||||
|
||||
pub mod check;
|
||||
pub mod config;
|
||||
pub mod edit;
|
||||
pub mod entries;
|
||||
|
||||
@@ -207,7 +207,7 @@ pub fn reject(store: &Store, id: &str, now: Timestamp) -> Result<String, String>
|
||||
// 1. The Waiting line with this id, else Err(format!("nothing waiting as {id}")).
|
||||
// 2. `add` a Rejected line { id, time now }.
|
||||
// 3. `journal::append` a Record: its session and call, action "reject", file None, to empty, no
|
||||
// entries, no notes, untrusted false, undoes Some(id).
|
||||
// entries, no notes, untrusted false, flag None, undoes Some(id).
|
||||
// 4. Ok(format!("rejected: {id}")).
|
||||
let waiting_lines = waiting(store)?;
|
||||
let found = waiting_lines
|
||||
@@ -244,6 +244,7 @@ pub fn reject(store: &Store, id: &str, now: Timestamp) -> Result<String, String>
|
||||
after: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
untrusted: false,
|
||||
flag: None,
|
||||
undoes: Some(id.to_string()),
|
||||
},
|
||||
now,
|
||||
|
||||
@@ -169,12 +169,12 @@ impl Memoryd {
|
||||
let _state = self.lock();
|
||||
match self.store.block() {
|
||||
Ok(block) => {
|
||||
for (file, refused) in &block.left_out {
|
||||
for (file, refusal) in &block.left_out {
|
||||
(self.log)(
|
||||
format!(
|
||||
"memoryd: an entry of {} was left out of the block: {}",
|
||||
"memoryd: an entry of {} was left out of the block by the rule {}; see docs/runbook.md#memory-entry-excluded",
|
||||
file.name(),
|
||||
refused.sentence()
|
||||
refusal.rule
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
@@ -239,8 +239,9 @@ impl Memoryd {
|
||||
)
|
||||
}
|
||||
|
||||
/// `memory`: staged for review, or applied and journalled; then what it appended to `notes.md`
|
||||
/// is indexed. Its answer is labelled private and trusted: it is `memoryd`'s own sentence.
|
||||
/// `memory`: refused, staged for review, or applied and journalled; then what it appended to
|
||||
/// `notes.md` is indexed. Its answer is labelled private and trusted: it is `memoryd`'s own
|
||||
/// sentence. A refusal and a flag are logged with the rule's name, never with the text.
|
||||
fn memory(&self, state: &State, call: &MemoryCall) -> Result<Recalled, String> {
|
||||
let args: MemoryArgs = match serde_json::from_str(&call.request.arguments) {
|
||||
Ok(args) => args,
|
||||
@@ -253,14 +254,35 @@ impl Memoryd {
|
||||
untrusted: call.untrusted,
|
||||
now: Timestamp::now(),
|
||||
};
|
||||
if let Some(refusal) = edit::refused(&args) {
|
||||
(self.log)(
|
||||
format!(
|
||||
"memoryd: a memory write from session {} was refused by the rule {}; see docs/runbook.md#memory-write-refused",
|
||||
ctx.session, refusal.rule
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
return Err(refusal.sentence.to_string());
|
||||
}
|
||||
// A write that matches a flag rule becomes notes, and notes are never staged.
|
||||
let answer = if self.config.memory.review
|
||||
&& !matches!(args, MemoryArgs::Note { .. })
|
||||
&& !call.untrusted
|
||||
&& edit::flagged(&args).is_none()
|
||||
{
|
||||
review::submit(&self.store, &args, &call.request.arguments, &ctx)?
|
||||
} else {
|
||||
let outcome = edit::apply(&self.store, &args, &ctx)?;
|
||||
journal::record(&self.store, &ctx, &outcome)?;
|
||||
let id = journal::record(&self.store, &ctx, &outcome)?;
|
||||
if let Some(rule) = outcome.flag {
|
||||
(self.log)(
|
||||
format!(
|
||||
"memoryd: a memory write from session {} matches the rule {rule}; it was saved in notes.md as an untrusted note (edit {id}); see docs/runbook.md#memory-write-flagged",
|
||||
ctx.session
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
}
|
||||
outcome.answer
|
||||
};
|
||||
self.index_notes(state);
|
||||
|
||||
Reference in New Issue
Block a user