Plan M5a: local memory in 15 tasks
memoryd (files, the check, edits, journal, index, recall, serving), the memory tools and built-in grants in brokerd, the memory block in loopd, bxctl reindex and memory, and the device checks with memoryd. Each task was built as a reference and gated. Each was replayed on its own at its end state (854 to 936 tests) and mutation-checked. Each skeleton compiles and fails its tests. Every todo comment was read beside its signature. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
//! 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];
|
||||
|
||||
const MARKUP: &[&str] = &["<|", "|>", "<think", "</think", "<tool_call", "</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)`. True when ALSO_INVISIBLE contains it, or it lies in one of the
|
||||
// DEFAULT_IGNORABLE ranges, both ends included: `(low..=high).contains(&code)`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The first rule `text` breaks, in the order of the spec, or `Ok`.
|
||||
pub fn check(text: &str) -> Result<(), Refused> {
|
||||
// Return the first that applies, in this order.
|
||||
// 1. Any char with `c.is_control()` that is not '\n' and not '\t' -> Err(Refused::Control).
|
||||
// 2. Any char for which `invisible(c)` -> Err(Refused::Invisible).
|
||||
// 3. Lower-case the text (`to_lowercase()`); if it contains any string of MARKUP ->
|
||||
// Err(Refused::Markup).
|
||||
// 4. Collapse whitespace: the words of the lower-cased text (`split_whitespace()`) joined by
|
||||
// one space; if that contains any of PHRASES -> Err(Refused::Phrase). Otherwise Ok(()).
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! `memoryd` configuration: read `memoryd.toml` into a typed `Config` (M5a spec, section 11).
|
||||
//!
|
||||
//! Unknown keys are errors in every table, as in the other roles: a misspelt key that silently
|
||||
//! fell back to its default would be a setting the owner believes is set and is not.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use proto::DataClass;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Paths {
|
||||
pub home: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for Paths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
home: std::env::var_os("BOXMAKER_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[memory]`: the starter `soul.md`, and whether identity writes wait for the owner.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Memory {
|
||||
/// A relative path is resolved next to the config file by `Config::load`.
|
||||
pub soul_starter: PathBuf,
|
||||
pub review: bool,
|
||||
}
|
||||
|
||||
impl Default for Memory {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
soul_starter: PathBuf::from("soul.md"),
|
||||
review: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[index]`: records above `max_class` are not indexed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Index {
|
||||
pub max_class: DataClass,
|
||||
}
|
||||
|
||||
impl Default for Index {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_class: DataClass::Private,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty path means "the default under `home`".
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Sockets {
|
||||
#[serde(rename = "loop")]
|
||||
pub loopd: PathBuf,
|
||||
pub broker: PathBuf,
|
||||
pub admin: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Config {
|
||||
pub paths: Paths,
|
||||
pub memory: Memory,
|
||||
pub index: Index,
|
||||
pub sockets: Sockets,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Read(PathBuf, std::io::Error),
|
||||
Parse(PathBuf, toml::de::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()),
|
||||
ConfigError::Parse(path, err) => write!(f, "{}: {err}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
impl Config {
|
||||
pub fn parse(text: &str) -> Result<Config, toml::de::Error> {
|
||||
toml::from_str(text)
|
||||
}
|
||||
|
||||
/// Read and parse `path`; a relative `soul_starter` is taken to be next to it.
|
||||
pub fn load(path: &Path) -> Result<Config, ConfigError> {
|
||||
// 1. std::fs::read_to_string(path), an error as ConfigError::Read(path.to_path_buf(), e).
|
||||
// 2. `Config::parse(&text)`, an error as ConfigError::Parse(path.to_path_buf(), e).
|
||||
// 3. If `config.memory.soul_starter.is_relative()`, replace it with the directory of the
|
||||
// config file (`path.parent()`, or `Path::new(".")` when there is none) joined with it.
|
||||
// Ok(config).
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn socket(&self, set: &Path, default: &str) -> PathBuf {
|
||||
// An empty `set` (`set.as_os_str().is_empty()`) means `self.paths.home.join(default)`;
|
||||
// otherwise `set.to_path_buf()`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn loop_socket(&self) -> PathBuf {
|
||||
self.socket(&self.sockets.loopd, "run/loop-memory/memory.sock")
|
||||
}
|
||||
pub fn broker_socket(&self) -> PathBuf {
|
||||
self.socket(&self.sockets.broker, "run/broker-memory/memory.sock")
|
||||
}
|
||||
pub fn admin_socket(&self) -> PathBuf {
|
||||
self.socket(&self.sockets.admin, "run/owner-memory/admin.sock")
|
||||
}
|
||||
pub fn memory_dir(&self) -> PathBuf {
|
||||
self.paths.home.join("memory")
|
||||
}
|
||||
pub fn index_path(&self) -> PathBuf {
|
||||
self.paths.home.join("index/memory.sqlite3")
|
||||
}
|
||||
pub fn sessions_dir(&self) -> PathBuf {
|
||||
self.paths.home.join("sessions")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 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.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use proto::{DataClass, EntryAction, MemoryArgs, MemoryFile, Timestamp};
|
||||
|
||||
use crate::check::check;
|
||||
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 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.
|
||||
pub const SAVED_AS_NOTE: &str =
|
||||
"saved as a note (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()
|
||||
)
|
||||
}
|
||||
|
||||
fn checked(text: &str) -> Result<String, String> {
|
||||
check(text).map_err(|refused| refused.sentence().to_string())?;
|
||||
Ok(text.trim().to_string())
|
||||
}
|
||||
|
||||
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()?`.
|
||||
// 2. A Note: return `noted(store, ctx, text, "note")`. Otherwise take the file.
|
||||
// 3. When `ctx.untrusted`: an Add becomes `noted(store, ctx, text, "add")?` with its answer
|
||||
// replaced by SAVED_AS_NOTE; any other action -> Err(untrusted_refusal(file)).
|
||||
// 4. `let before = store.entries(file)?;` then `let (action, after, moved) = edited(file,
|
||||
// &before, args, ctx)?;`.
|
||||
// 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())).
|
||||
// 6. `size = entries::size(&after)`; size > file.limit() -> Err(over_limit(file, size)).
|
||||
// 7. `store.write_entries(file, &after)?`, then `store.append("notes.md", ¬e.render())?` for
|
||||
// each note in `moved`.
|
||||
// 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 } }).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// An identity action applied to `before`, not yet written: the action's name, the new list, and
|
||||
/// the notes for what it dropped or moved. `Err` is the sentence for the model.
|
||||
fn edited(
|
||||
file: MemoryFile,
|
||||
before: &[String],
|
||||
args: &MemoryArgs,
|
||||
ctx: &Context,
|
||||
) -> Result<(&'static str, Vec<String>, Vec<Note>), String> {
|
||||
// Match args. Add: `checked(text)?`; if `before` contains it -> Err(format!("{} already has
|
||||
// this entry", file.name())); after = before plus it; ("add", after, no notes). Replace:
|
||||
// `position(before, old)`, None -> Err(stale(file)); `checked(text)?`; if another entry (not
|
||||
// the one at that place) equals it -> the "already has this entry" Err; after = before with
|
||||
// that entry replaced; ("replace", after, no notes). Remove: find it the same way; after =
|
||||
// before without it; ("remove", after, vec![note(ctx, Kind::Dropped(file), &gone)]).
|
||||
// Consolidate: `consolidate(file, before, entries, ctx)?`; ("consolidate", after, notes). Note
|
||||
// -> Err("a note is not an identity edit") with the comment `// `apply` takes notes before it
|
||||
// asks for an identity edit.` (apply never passes one). Clone `before` with `to_vec()`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 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 text = checked(text)?;` Build `note(ctx, Kind::Noted { class: ctx.taint, untrusted:
|
||||
// ctx.untrusted }, &text)` and `store.append("notes.md", &entry.render())?`. Ok(Outcome {
|
||||
// action, answer: "noted (untrusted)" when ctx.untrusted else "noted", change: Change { file:
|
||||
// None, before and after empty, notes: vec![entry] } }).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The new list of a `consolidate`, and the notes for what it moved or dropped. Every current
|
||||
/// entry must be named exactly once.
|
||||
fn consolidate(
|
||||
file: MemoryFile,
|
||||
before: &[String],
|
||||
lines: &[proto::ConsolidateEntry],
|
||||
ctx: &Context,
|
||||
) -> Result<(Vec<String>, Vec<Note>), String> {
|
||||
// Walk `lines` in order, keeping `named` (a HashSet of the places in `before` already named),
|
||||
// `after` and `out` (the notes). For each line: `line.action()` (its Err as a String). For
|
||||
// Keep, Move, Drop and Rewrite, find its old with `position(before, old)` (None ->
|
||||
// Err(stale(file))); a place already in `named` -> Err(format!("consolidate names an entry of
|
||||
// {} twice", file.name())). Then: Keep -> push old.trim() onto after; Rewrite and New -> push
|
||||
// `checked(&text)?`; Move -> push note(ctx, Kind::Moved(file), old.trim()) onto out; Drop ->
|
||||
// the same with Kind::Dropped. At the end, `missing = before.len() - named.len()`; when it is
|
||||
// not 0: Err(format!("consolidate must name every entry of {}; it leaves out {missing}",
|
||||
// file.name())). Ok((after, out)).
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! 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> {
|
||||
// Walk `text.lines()`, collecting lines into `current`. A line whose trim() equals SEPARATOR
|
||||
// ends an entry: `push(&mut entries, ¤t)`, then clear `current`. Any other line is pushed
|
||||
// onto `current`. After the last line, `push` once more. Return `entries`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn push(entries: &mut Vec<String>, lines: &[&str]) {
|
||||
// Join `lines` with a newline, trim() the result, and push it (as a String) only when it is not
|
||||
// empty.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The entries joined by `\n§\n`, with a final newline; an empty list is an empty file.
|
||||
pub fn render(entries: &[String]) -> String {
|
||||
// An empty list is "". Otherwise the entries joined by format!("\n{SEPARATOR}\n"), with one
|
||||
// newline added at the end.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A file's size in characters, the one its limit is measured against.
|
||||
pub fn size(entries: &[String]) -> usize {
|
||||
// `render(entries)` without its final newline (`trim_end_matches('\n')`), counted in
|
||||
// characters: chars().count().
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The files under `memory/` and the memory block (M5a spec, sections 3 and 4). `memoryd` creates
|
||||
//! what is missing and never overwrites anything; files are 0600 in a 0700 directory.
|
||||
|
||||
use std::fs::{DirBuilder, OpenOptions, Permissions};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use proto::MemoryFile;
|
||||
|
||||
use crate::check::{Refused, check};
|
||||
use crate::entries;
|
||||
|
||||
/// The most characters `soul.md` may have.
|
||||
pub const SOUL_LIMIT: usize = 4_000;
|
||||
|
||||
/// `n` with commas between thousands: `2,200`.
|
||||
pub fn grouped(n: usize) -> String {
|
||||
// `let digits = n.to_string()`. Walk its chars with their index i; before each char where i > 0
|
||||
// and `(digits.len() - i).is_multiple_of(3)`, push a comma. So 2200 is "2,200" and 999 is
|
||||
// "999".
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The memory directory.
|
||||
#[derive(Debug, Clone)]
|
||||
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).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Block {
|
||||
pub text: String,
|
||||
pub left_out: Vec<(MemoryFile, Refused)>,
|
||||
}
|
||||
|
||||
fn fail(path: &Path, e: std::io::Error) -> String {
|
||||
format!("{}: {e}", path.display())
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new(dir: &Path) -> Store {
|
||||
Store {
|
||||
dir: dir.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self, name: &str) -> PathBuf {
|
||||
self.dir.join(name)
|
||||
}
|
||||
|
||||
/// Create `memory/` (0700) and each missing file (0600): `soul.md` from `starter`, the others
|
||||
/// empty. Nothing that exists is changed. The error names the path.
|
||||
pub fn init(&self, starter: &Path) -> Result<(), String> {
|
||||
// 1. `DirBuilder::new().recursive(true).mode(0o700).create(&self.dir)`, then
|
||||
// `set_permissions(&self.dir, Permissions::from_mode(0o700))`.
|
||||
// 2. When `self.path("soul.md")` does not exist: `read_to_string(starter)`, then
|
||||
// `self.create(&soul, &text)`.
|
||||
// 3. Each of "memory.md", "user.md", "notes.md" that does not exist: `self.create(&path,
|
||||
// "")`. Every error through `fail(path, e)`. Nothing that exists is changed. Ok(()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Create `path` at 0600 with `text`; an existing file is an error, never overwritten.
|
||||
fn create(&self, path: &Path, text: &str) -> Result<(), String> {
|
||||
// `OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)`, then
|
||||
// `write_all(text.as_bytes())`. Errors through `fail(path, e)`. create_new makes an
|
||||
// existing file an error: it is never overwritten.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The entries of an identity file.
|
||||
pub fn entries(&self, file: MemoryFile) -> Result<Vec<String>, String> {
|
||||
// `read_to_string(self.path(file.name()))` (an error through `fail`), then
|
||||
// `entries::parse(&text)`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Replace an identity file's entries: written to a temporary file at 0600, then renamed.
|
||||
pub fn write_entries(&self, file: MemoryFile, list: &[String]) -> Result<(), String> {
|
||||
// The file is `self.path(file.name())`; the temporary file is
|
||||
// `self.path(&format!(".{}.tmp", file.name()))`. Remove the temporary file if it is there
|
||||
// (ignore the result), `self.create(&temp, &entries::render(list))?`, then
|
||||
// `std::fs::rename(&temp, &path)` (an error through `fail(&path, e)`).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Append `text` to a file (0600, created if missing), as one write.
|
||||
pub fn append(&self, name: &str, text: &str) -> Result<(), String> {
|
||||
// `OpenOptions::new().append(true).create(true).mode(0o600).open(&path)` for
|
||||
// `self.path(name)`, then one `write_all(text.as_bytes())`. Errors through `fail`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 {}",
|
||||
// path.display(), grouped(chars), grouped(SOUL_LIMIT))).
|
||||
// 2. `text` starts as format!("# soul.md\n{}", soul), with "(empty)" in place of an empty
|
||||
// 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 {}
|
||||
// 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 }).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! The index: SQLite FTS5 over the session logs and `notes.md` (M5a spec, section 6). Disposable:
|
||||
//! it is rebuilt from those files. What is indexed: the owner's messages, the assistant's
|
||||
//! replies, and each tool call by name and arguments; never a tool's result.
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use proto::{DataClass, LogRecord, Timestamp};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::notes::{self, Kind};
|
||||
|
||||
const SCHEMA: &str = "
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS hits USING fts5(text, tokenize = 'unicode61');
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
rowid INTEGER PRIMARY KEY, kind TEXT NOT NULL, session TEXT NOT NULL, epoch INTEGER NOT NULL,
|
||||
record INTEGER NOT NULL, time TEXT NOT NULL, role TEXT NOT NULL, class TEXT NOT NULL,
|
||||
untrusted INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS progress (log TEXT PRIMARY KEY, records INTEGER NOT NULL);
|
||||
";
|
||||
|
||||
/// One indexed item.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Source {
|
||||
/// `session`, `noted`, `moved` or `dropped`.
|
||||
pub kind: String,
|
||||
pub session: String,
|
||||
pub epoch: i64,
|
||||
/// The record's line in its log, or the note's place in `notes.md` (from 0).
|
||||
pub record: i64,
|
||||
pub time: String,
|
||||
/// `owner`, `assistant` or `call` for a session record; the file (`memory.md`, `user.md`) for
|
||||
/// a moved or dropped note; empty for a noted one.
|
||||
pub role: String,
|
||||
pub class: DataClass,
|
||||
pub untrusted: bool,
|
||||
}
|
||||
|
||||
/// How much a reindex read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Counts {
|
||||
pub logs: u64,
|
||||
pub records: u64,
|
||||
pub notes: u64,
|
||||
}
|
||||
|
||||
pub struct Index {
|
||||
conn: Connection,
|
||||
max_class: DataClass,
|
||||
}
|
||||
|
||||
fn db(e: rusqlite::Error) -> String {
|
||||
format!("index: {e}")
|
||||
}
|
||||
|
||||
pub fn class_name(class: DataClass) -> &'static str {
|
||||
match class {
|
||||
DataClass::Public => "public",
|
||||
DataClass::Private => "private",
|
||||
DataClass::Secret => "secret",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn class_of(name: &str) -> DataClass {
|
||||
match name {
|
||||
"public" => DataClass::Public,
|
||||
"secret" => DataClass::Secret,
|
||||
_ => DataClass::Private,
|
||||
}
|
||||
}
|
||||
|
||||
fn int(n: usize) -> i64 {
|
||||
i64::try_from(n).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Open or create the index at `path` (its directory 0700, the file 0600).
|
||||
pub fn open(path: &Path, max_class: DataClass) -> Result<Index, String> {
|
||||
// 1. When `path.parent()` is Some(dir): `create_dir_all(dir)`, then `set_permissions(dir,
|
||||
// Permissions::from_mode(0o700))`.
|
||||
// 2. `Connection::open(path)`, then `set_permissions(path, Permissions::from_mode(0o600))`,
|
||||
// then `conn.execute_batch(SCHEMA)`. Every error is format!("{}: {e}", the path).
|
||||
// Ok(Index { conn, max_class }).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The connection, for `recall`.
|
||||
pub fn conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
fn progress(&self, log: &str) -> Result<usize, String> {
|
||||
// `self.conn.query_row("SELECT records FROM progress WHERE log = ?1", params![log], |row|
|
||||
// row.get(0))` as an Option<i64>: Err(rusqlite::Error::QueryReturnedNoRows) means None,
|
||||
// another error goes through `db`. Return it as a usize (`usize::try_from`, 0 when it does
|
||||
// not fit), 0 for None.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn add(&self, text: &str, source: &Source) -> Result<(), String> {
|
||||
// 1. `execute("INSERT INTO hits (text) VALUES (?1)", params![text])`.
|
||||
// 2. `let rowid = self.conn.last_insert_rowid();`
|
||||
// 3. `execute("INSERT INTO sources (rowid, kind, session, epoch, record, time, role, class,
|
||||
// untrusted) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![rowid, kind, session,
|
||||
// epoch, record, time, role, class_name(class), untrusted])`. Errors through `db`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn set_progress(&self, log: &str, records: usize) -> Result<(), String> {
|
||||
// `execute("INSERT INTO progress (log, records) VALUES (?1, ?2) ON CONFLICT(log) DO UPDATE
|
||||
// SET records = excluded.records", params![log, int(records)])`, errors through `db`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Index the records of one session log past what was indexed before; how many were read.
|
||||
/// The label is walked from the log's first record: the highest class of any tool result
|
||||
/// before a record (at least `private`), and whether any was untrusted.
|
||||
pub fn index_log(&self, path: &Path, session: &str, epoch: i64) -> Result<usize, String> {
|
||||
// 1. `read_to_string(path)` (an error names the path). `key` =
|
||||
// format!("{session}/{epoch}"); `done = self.progress(&key)?`.
|
||||
// 2. The label starts at class Private, untrusted false; `read` at 0.
|
||||
// 3. For each line with its place n (from 0): parse it as a LogRecord (an error ->
|
||||
// Err(format!("{}: line {}: {e}", path.display(), n + 1))). When n >= done: read += 1,
|
||||
// and `self.index_record(&record, &base)?` where base is a Source with kind "session",
|
||||
// session, epoch, record int(n), time and role empty, and the label as it stands. Then,
|
||||
// for every record (indexed or not): a ToolResult raises the label: class =
|
||||
// class.max(its class), untrusted |= its untrusted.
|
||||
// 4. `self.set_progress(&key, done + read)?`; Ok(read).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Index what one log record holds: the owner's message, the assistant's reply, and each tool
|
||||
/// call by name and arguments. `base` carries the kind, session, epoch, line and label; each item
|
||||
/// gets its own role and time. Nothing when the label's class is above `max_class`, and nothing
|
||||
/// for an item with no text.
|
||||
fn index_record(&self, record: &LogRecord, base: &Source) -> Result<(), String> {
|
||||
// When base.class > self.max_class: Ok(()) and nothing indexed. Otherwise each item is
|
||||
// `self.add(text, &Source { time: time.to_rfc3339(), role, ..base.clone() })`, skipped when
|
||||
// text.trim() is empty. User -> its content, role "owner". Assistant -> its content when
|
||||
// Some, role "assistant"; then each tool call as format!("{} {}", name, arguments), role
|
||||
// "call"; all with the record's time. Every other record: nothing.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Index the entries of `notes.md` past what was indexed before; how many were read.
|
||||
pub fn index_notes(&self, text: &str) -> Result<usize, String> {
|
||||
// `done = self.progress("notes.md")?`. Parse `text` with notes::parse and skip the first
|
||||
// `done`. For each remaining note (with its place n): read += 1; its kind, role, class and
|
||||
// untrusted: Noted -> ("noted", "", its class, its untrusted); Moved(file) -> ("moved",
|
||||
// file.name(), Private, false); Dropped(file) -> ("dropped", file.name(), Private, false).
|
||||
// Skip it when class > self.max_class. Otherwise `self.add(¬e.text, &Source { kind,
|
||||
// session: note.session, epoch: 0, record: int(n), time: note.time, role, class, untrusted
|
||||
// })?`. Then `self.set_progress("notes.md", done + read)?`; Ok(read).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Index everything new: every `sessions/<id>/<epoch>.jsonl`, then `notes.md`. A log that
|
||||
/// cannot be read or parsed is skipped and named in the returned problems; it is `loopd`'s.
|
||||
pub fn catch_up(
|
||||
&self,
|
||||
sessions: &Path,
|
||||
notes_md: &Path,
|
||||
) -> Result<(Counts, Vec<String>), String> {
|
||||
// For each (session, epoch, path) of `logs(sessions)?`: `self.index_log(&path, &session,
|
||||
// epoch)`; Ok(n) -> counts.logs += 1 and counts.records += n (u64::try_from, u64::MAX when
|
||||
// it does not fit); Err(e) -> push e onto `problems` and go on. Then read `notes_md`:
|
||||
// NotFound is an empty text, another error -> Err(format!("{}: {e}", notes_md.display()));
|
||||
// counts.notes = `self.index_notes(&text)?` as u64 (u64::try_from). Ok((counts, problems)).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every session log under `sessions`: `(session, epoch, path)`, sorted.
|
||||
pub fn logs(sessions: &Path) -> Result<Vec<(String, i64, PathBuf)>, String> {
|
||||
// Read the directory `sessions` (NotFound -> Ok(empty); another error -> Err naming it). For
|
||||
// each entry, its name is the session; read that directory (skip it when that fails); every
|
||||
// file named `<n>.jsonl` where n parses as an i64 gives (session, n, the file's path). Sort the
|
||||
// list and return it.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Build a fresh index at `path` from `sessions` and `notes.md`: into a new file beside it,
|
||||
/// renamed over the old one, so a running `recall` keeps its index until the new one is whole.
|
||||
pub fn rebuild(
|
||||
path: &Path,
|
||||
max_class: DataClass,
|
||||
sessions: &Path,
|
||||
notes_md: &Path,
|
||||
) -> Result<(Counts, Vec<String>), String> {
|
||||
// `fresh = path.with_extension("rebuilding")`; remove it if it is there (ignore the result).
|
||||
// `Index::open(&fresh, max_class)?`, then `catch_up(sessions, notes_md)?` on it, and drop that
|
||||
// Index. `std::fs::rename(&fresh, path)` (an error names path). Return the result of catch_up.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! The edit journal: one JSON line per applied edit in `memory/edits/YYYY-MM.jsonl`, the owner's
|
||||
//! record of every write, and `undo` of the latest edit of a file (M5a spec, section 5).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use proto::{MemoryFile, Timestamp};
|
||||
|
||||
use crate::edit::{Context, Outcome};
|
||||
use crate::files::Store;
|
||||
use crate::notes::Note;
|
||||
|
||||
/// One line of the journal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Record {
|
||||
/// `YYYY-MM-DD-NNNN`, numbered from 1 each day.
|
||||
pub id: String,
|
||||
pub time: String,
|
||||
pub session: String,
|
||||
pub call: u64,
|
||||
/// `add`, `replace`, `remove`, `consolidate`, `note`, or `undo`.
|
||||
pub action: String,
|
||||
/// The identity file changed, if any.
|
||||
pub file: Option<String>,
|
||||
/// Where the write went: `memory.md`, `user.md` or `notes.md`.
|
||||
pub to: String,
|
||||
pub before: Vec<String>,
|
||||
pub after: Vec<String>,
|
||||
/// The `notes.md` entries appended, as written.
|
||||
pub notes: Vec<String>,
|
||||
pub untrusted: bool,
|
||||
/// For an undo, the edit it reverted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub undoes: Option<String>,
|
||||
}
|
||||
|
||||
fn file_of(name: &str) -> Option<MemoryFile> {
|
||||
match name {
|
||||
"memory.md" => Some(MemoryFile::Memory),
|
||||
"user.md" => Some(MemoryFile::User),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every record, oldest first, from every month's file.
|
||||
pub fn records(store: &Store) -> Result<Vec<Record>, String> {
|
||||
// Read the directory `store.path("edits")`: NotFound -> Ok(empty); another error ->
|
||||
// Err(format!("{}: {e}", dir.display())). Take the names that end ".jsonl" and sort them. For
|
||||
// each file, `read_to_string` and parse every line as a Record with serde_json; a bad line ->
|
||||
// Err(format!("{}: line {}: {e}", path.display(), n + 1)) with n counted from 0. Return all
|
||||
// records, oldest first.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Append a record, giving it the next id of its day.
|
||||
pub(crate) fn append(store: &Store, mut record: Record, now: Timestamp) -> Result<String, String> {
|
||||
// 1. `day` is the first 10 characters of `now.to_rfc3339()` ("0000-00-00" if shorter).
|
||||
// 2. `taken` = how many of `records(store)?` have an id starting format!("{day}-").
|
||||
// 3. record.id = format!("{day}-{:04}", taken + 1).
|
||||
// 4. `create_dir_all(store.path("edits"))` (an error names the directory),
|
||||
// `serde_json::to_string(&record)`, then `store.append(&format!("edits/{month}.jsonl"),
|
||||
// &format!("{line}\n"))?` where month is the first 7 characters of day. Ok(record.id).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Journal an applied call; the id it was given.
|
||||
pub fn record(store: &Store, ctx: &Context, outcome: &Outcome) -> Result<String, 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.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 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".
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Revert edit `id`, if it is the latest edit of its identity file and the file still holds
|
||||
/// exactly what it left. Notes it appended stay (the file is append-only). The undo is journalled.
|
||||
pub fn undo(store: &Store, id: &str, now: Timestamp) -> Result<String, String> {
|
||||
// 1. Find the record with this id in `records(store)?`, else Err(format!("no edit {id}")).
|
||||
// 2. Its file must be Some and known to `file_of`, else Err(format!("{id} changed no identity
|
||||
// file; notes are append-only")).
|
||||
// 3. The latest record whose file is the same name must be this one, else Err(format!("{id} is
|
||||
// not the latest edit of {name}")).
|
||||
// 4. `store.entries(file)?` must equal record.after, else Err(format!("{name} has changed since
|
||||
// {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).
|
||||
// 6. Ok(format!("undone: {id} ({name} restored; journalled as {undo_id})")).
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! The memory role: the memory files, their index, and every memory tool call (M5a spec).
|
||||
|
||||
pub mod config;
|
||||
pub mod socket;
|
||||
@@ -0,0 +1,33 @@
|
||||
//! `memoryd serve --config <path>`.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
const USAGE: &str = "usage: memoryd serve --config <path>";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
|
||||
let Some(config) = parse(&args) else {
|
||||
eprintln!("{USAGE}");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
match memoryd::config::Config::load(&config) {
|
||||
Ok(_) => {
|
||||
eprintln!("memoryd: not implemented");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("memoryd: {e}\nsee docs/runbook.md#memoryd-start-failed");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The config path of `serve --config <path>`, or `None` for any other list.
|
||||
fn parse(args: &[OsString]) -> Option<PathBuf> {
|
||||
match args {
|
||||
[serve, flag, path] if serve == "serve" && flag == "--config" => Some(PathBuf::from(path)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! `memoryd serve --config <path>`.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use memoryd::config::Config;
|
||||
use memoryd::serve;
|
||||
|
||||
const USAGE: &str = "usage: memoryd serve --config <path>";
|
||||
const START_FAILED: &str = "see docs/runbook.md#memoryd-start-failed";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
|
||||
let Some(path) = parse(&args) else {
|
||||
eprintln!("{USAGE}");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
let config = match Config::load(&path) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!("memoryd: {e}\n{START_FAILED}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
let log: serve::Log = Arc::new(|line: &str| eprintln!("{line}"));
|
||||
let running = match serve::start(config, log) {
|
||||
Ok(running) => running,
|
||||
Err(e) => {
|
||||
eprintln!("memoryd: {e}\n{START_FAILED}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
eprintln!("memoryd: serving");
|
||||
match running.run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("memoryd: stopped serving: {e}\n{START_FAILED}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The config path of `serve --config <path>`, or `None` for any other list.
|
||||
fn parse(args: &[OsString]) -> Option<PathBuf> {
|
||||
match args {
|
||||
[serve, flag, path] if serve == "serve" && flag == "--config" => Some(PathBuf::from(path)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! `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 {
|
||||
// `time.to_rfc3339()` is like 2026-09-24T20:14:37.123Z. Keep its first 16 characters
|
||||
// (`get(..16)`, or the whole string if it is shorter) and add "Z": 2026-09-24T20:14Z.
|
||||
todo!()
|
||||
}
|
||||
|
||||
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 {
|
||||
// Noted: format!("§ noted {time} in session {session} · {class}") with " · untrusted" added
|
||||
// when untrusted; the class is written by `class_name`. Moved: format!("§ moved {time} from
|
||||
// {file} in session {session}"), the file as `file.name()`. Dropped: the same with
|
||||
// "dropped".
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 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): the header, the text, a final newline.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a header line back, or `None` when the line is not one.
|
||||
fn parse_header(line: &str) -> Option<(Kind, String, String)> {
|
||||
// `None` whenever the line does not fit exactly.
|
||||
// 1. `strip_prefix("§ ")?`, then `split_once(' ')?` for the word, then again for the time.
|
||||
// 2. "noted": the rest must start "in session "; split what follows on " · ": the session, then
|
||||
// the class ("public", "private" or "secret", anything else None), then optionally
|
||||
// "untrusted" (anything else None), then nothing more. The session must not be empty or
|
||||
// contain a space. Some((Kind::Noted { class, untrusted }, time, session)).
|
||||
// 3. "moved" or "dropped": the rest must start "from "; `split_once(' ')?` gives the file name,
|
||||
// which `file_of` must know; what follows must start "in session " and the session must not
|
||||
// be empty or contain a space. Some((Kind::Moved(file) or Kind::Dropped(file), time,
|
||||
// session)).
|
||||
// 4. Any other word: None.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// For each line of `text.lines()`: when `parse_header(line)` is Some, push a new Note with an
|
||||
// empty text; otherwise, when there is a note above, add the line to its text (a newline before
|
||||
// it unless its text is still empty). Lines before the first header are dropped. At the end,
|
||||
// trim() the text of every note.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! `recall`: search the index and answer with tagged hits (M5a spec, section 5). The query
|
||||
//! reaches SQLite only as quoted terms, so no FTS5 operator from the model is ever parsed.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use proto::{DataClass, RecallArgs, Timestamp};
|
||||
use rusqlite::params;
|
||||
|
||||
use crate::files::Store;
|
||||
use crate::index::{Index, class_name, class_of};
|
||||
|
||||
/// The most characters of one hit's text.
|
||||
pub const SNIPPET: usize = 600;
|
||||
|
||||
/// The answer to the model, and the label the result carries.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Recalled {
|
||||
pub text: String,
|
||||
pub class: DataClass,
|
||||
pub untrusted: bool,
|
||||
}
|
||||
|
||||
/// The FTS5 query for `query`: every run of letters and digits, double-quoted, joined by `OR`.
|
||||
/// `None` when there is no such run.
|
||||
pub fn terms(query: &str) -> Option<String> {
|
||||
// Split `query` on every char that is not alphanumeric (`!c.is_alphanumeric()`), drop the empty
|
||||
// pieces, wrap each in double quotes (format!("\"{t}\"")) and join them with " OR ". None when
|
||||
// there is no piece. So `a NEAR(b` is `"a" OR "NEAR" OR "b"`: no FTS5 operator from the model
|
||||
// is ever parsed.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `2026-09-20 14:02 UTC` from `2026-09-20T14:02:00.000Z` or `2026-09-20T14:02Z`.
|
||||
fn when(time: &str) -> String {
|
||||
// The first 10 characters (the day) and characters 11 to 16 (the clock), as format!("{day}
|
||||
// {clock} UTC"): both 2026-09-20T14:02:00.000Z and 2026-09-20T14:02Z give 2026-09-20 14:02 UTC.
|
||||
// When either `get` is None, the time unchanged.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn day(time: &str) -> &str {
|
||||
// The first 10 characters of `time` (`get(..10)`), or all of it when shorter.
|
||||
todo!()
|
||||
}
|
||||
|
||||
struct Hit {
|
||||
kind: String,
|
||||
session: String,
|
||||
epoch: i64,
|
||||
record: i64,
|
||||
time: String,
|
||||
role: String,
|
||||
class: DataClass,
|
||||
untrusted: bool,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Hit {
|
||||
/// `identity`, `memory` or `memory-tainted`.
|
||||
fn tag(&self) -> &'static str {
|
||||
// kind "moved" -> "identity"; "dropped" -> "memory"; any other kind: "memory-tainted" when
|
||||
// untrusted, else "memory".
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn header(&self, n: usize) -> String {
|
||||
// `from` by kind: "moved" -> format!("moved {} from {}", day(time), role); "dropped" -> the
|
||||
// same with "dropped"; "noted" -> format!("note, {}, session {}", when(time), session); a
|
||||
// session record -> format!("session {}, {}, {said}", session, when(time)) where said is
|
||||
// "the owner said" for role "owner", "the assistant called" for "call", and "the assistant
|
||||
// said" otherwise. Return format!("[{n}] {} · {from} · {}", self.tag(),
|
||||
// class_name(self.class)).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoggedHit<'a> {
|
||||
kind: &'a str,
|
||||
session: &'a str,
|
||||
epoch: i64,
|
||||
record: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Logged<'a> {
|
||||
time: String,
|
||||
session: &'a str,
|
||||
query: &'a str,
|
||||
hits: Vec<LoggedHit<'a>>,
|
||||
}
|
||||
|
||||
/// Search for `args` on behalf of `session`, log the recall, and answer.
|
||||
pub fn recall(
|
||||
index: &Index,
|
||||
store: &Store,
|
||||
args: &RecallArgs,
|
||||
session: &str,
|
||||
now: Timestamp,
|
||||
) -> Result<Recalled, String> {
|
||||
// 1. `args.check()?`.
|
||||
// 2. hits: `terms(&args.query)` None -> none; Some(fts) -> `search(index, &fts, args.k)?`.
|
||||
// 3. Log it: a Logged { time: now.to_rfc3339(), session, query, hits: a LoggedHit (kind,
|
||||
// session, epoch, record) per hit } as one JSON line appended to
|
||||
// format!("recalls/{month}.jsonl") (month: the first 7 characters of now.to_rfc3339()),
|
||||
// after `create_dir_all(store.path("recalls"))`.
|
||||
// 4. No hits: Ok(Recalled { text: format!("(no matches for \"{}\")", args.query), class
|
||||
// Private, untrusted false }).
|
||||
// 5. Otherwise, for each hit with i from 0: class = class.max(its class) starting at Private;
|
||||
// untrusted |= its tag is "memory-tainted"; its text cut to its first SNIPPET characters;
|
||||
// the item is format!("{}\n{text}", hit.header(i + 1)). Ok(Recalled { the items joined by
|
||||
// newlines, class, untrusted }).
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn search(index: &Index, fts: &str, k: u64) -> Result<Vec<Hit>, String> {
|
||||
// `index.conn().prepare("SELECT s.kind, s.session, s.epoch, s.record, s.time, s.role, s.class,
|
||||
// s.untrusted, snippet(hits, 0, '', '', '…', 64) FROM hits JOIN sources s ON s.rowid =
|
||||
// hits.rowid WHERE hits MATCH ?1 ORDER BY bm25(hits) LIMIT ?2")`, then `query_map(params![fts,
|
||||
// limit], ..)` building a Hit from columns 0 to 8 in that order (the class through `class_of`);
|
||||
// limit is k as an i64 (`i64::try_from`, 10 when it does not fit). Collect into Vec<Hit>. Every
|
||||
// rusqlite error as format!("index: {e}").
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Review (off by default, `[memory] review = true`): identity writes wait in
|
||||
//! `memory/pending.jsonl` for the owner's `accept` or `reject` instead of being applied (M5a spec,
|
||||
//! section 5). Notes are never staged.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use proto::escape::escape_model_text;
|
||||
use proto::{DataClass, MemoryArgs, Timestamp};
|
||||
|
||||
use crate::edit::{Context, apply};
|
||||
use crate::files::Store;
|
||||
use crate::journal::{self, Record};
|
||||
|
||||
/// One line of `pending.jsonl`: a write waiting, or what became of one.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Line {
|
||||
Waiting {
|
||||
id: String,
|
||||
time: String,
|
||||
session: String,
|
||||
call: u64,
|
||||
taint: DataClass,
|
||||
/// The call's arguments, as `brokerd` sent them.
|
||||
arguments: String,
|
||||
},
|
||||
Accepted {
|
||||
id: String,
|
||||
time: String,
|
||||
edit: String,
|
||||
},
|
||||
Rejected {
|
||||
id: String,
|
||||
time: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn lines(store: &Store) -> Result<Vec<Line>, String> {
|
||||
// Read `store.path("pending.jsonl")`: NotFound -> Ok(empty); another error -> Err(format!("{}:
|
||||
// {e}", path.display())). Parse every line as a Line with serde_json; a bad one ->
|
||||
// Err(format!("{}: line {}: {e}", path.display(), n + 1)).
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn add(store: &Store, line: &Line) -> Result<(), String> {
|
||||
// `serde_json::to_string(line)` (its error as a String), then `store.append("pending.jsonl",
|
||||
// &format!("{text}\n"))`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The writes still waiting: `(id, session, arguments, taint, call)`.
|
||||
fn waiting(store: &Store) -> Result<Vec<Line>, String> {
|
||||
// The ids that have an Accepted or a Rejected line are done. Return a clone of every Waiting
|
||||
// line whose id is not done, in file order.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Stage an identity write. Its shape is checked now; its content when it is accepted.
|
||||
pub fn submit(
|
||||
store: &Store,
|
||||
args: &MemoryArgs,
|
||||
arguments: &str,
|
||||
ctx: &Context,
|
||||
) -> Result<String, String> {
|
||||
// 1. `args.check()?`.
|
||||
// 2. `day` is the first 10 characters of `ctx.now.to_rfc3339()`. `taken` = how many Waiting
|
||||
// lines have an id starting format!("p-{day}-"). The id is format!("p-{day}-{:04}", taken +
|
||||
// 1).
|
||||
// 3. `add` a Waiting line: id, time `ctx.now.to_rfc3339()`, session, call and taint from ctx,
|
||||
// arguments as given.
|
||||
// 4. Ok(format!("waiting for the owner's review ({id}); nothing is changed until it is
|
||||
// accepted")).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The writes waiting, one per line, arguments escaped.
|
||||
pub fn list(store: &Store) -> Result<String, String> {
|
||||
// One line per `waiting` entry: format!("{id} {session} {}", escape_model_text(arguments)),
|
||||
// joined by newlines; with none, "nothing is waiting for review".
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Apply a waiting write, with the same checks, against the file as it is now, and journal it.
|
||||
pub fn accept(store: &Store, id: &str, now: Timestamp) -> Result<String, String> {
|
||||
// 1. The Waiting line with this id among `waiting(store)?`, else Err(format!("nothing waiting
|
||||
// as {id}")).
|
||||
// 2. Parse its arguments as MemoryArgs (the error as a String).
|
||||
// 3. A Context from its session, call and taint, untrusted false, now.
|
||||
// 4. `apply(store, &args, &ctx)?`, then `journal::record(store, &ctx, &outcome)?` gives the
|
||||
// edit id.
|
||||
// 5. `add` an Accepted line { id, time now, edit }.
|
||||
// 6. Ok(format!("accepted: {id} as {edit}: {}", outcome.answer)).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Discard a waiting write; the journal records it.
|
||||
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).
|
||||
// 4. Ok(format!("rejected: {id}")).
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Serving: three sockets, one request per connection, each answered by one frame (M5a spec,
|
||||
//! section 7). One lock covers the files and the index, so writes never interleave.
|
||||
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use proto::{
|
||||
Empty, Envelope, ErrorCode, MemoryAdminReply, MemoryArgs, MemoryBlockReply, MemoryCall,
|
||||
MemoryCommand, Message, PROTOCOL_VERSION, RecallArgs, Reindexed, Timestamp, ToolResponse,
|
||||
WireError, read_frame, write_frame,
|
||||
};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::edit::{self, Context};
|
||||
use crate::files::Store;
|
||||
use crate::index::{self, Index};
|
||||
use crate::recall::Recalled;
|
||||
use crate::{journal, recall, review};
|
||||
|
||||
/// How long a peer may take to send its request.
|
||||
pub const READ_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Where log lines go: stderr in `main`, a buffer in tests.
|
||||
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
||||
|
||||
struct State {
|
||||
index: Index,
|
||||
}
|
||||
|
||||
pub struct Memoryd {
|
||||
config: Config,
|
||||
store: Store,
|
||||
state: Mutex<State>,
|
||||
log: Log,
|
||||
}
|
||||
|
||||
/// A started `memoryd`: its three listeners, bound and waiting.
|
||||
pub struct Running {
|
||||
memoryd: Arc<Memoryd>,
|
||||
listeners: Vec<(UnixListener, Which)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Which {
|
||||
Loop,
|
||||
Broker,
|
||||
Admin,
|
||||
}
|
||||
|
||||
/// Create what is missing under `memory/`, open the index and catch it up, then bind the three
|
||||
/// sockets. The error names what failed; the caller points at `memoryd-start-failed`.
|
||||
pub fn start(config: Config, log: Log) -> Result<Running, String> {
|
||||
// 1. `Store::new(&config.memory_dir())`, then `store.init(&config.memory.soul_starter)?`.
|
||||
// 2. `Index::open(&config.index_path(), config.index.max_class)?`, then
|
||||
// `index.catch_up(&config.sessions_dir(), &store.path("notes.md"))?`; log each problem as
|
||||
// format!("memoryd: not indexed: {problem}").
|
||||
// 3. `crate::socket::listen` the loop, broker and admin sockets of the config, in that order,
|
||||
// paired with Which::Loop, Which::Broker and Which::Admin.
|
||||
// 4. Ok(Running { memoryd: Arc::new(Memoryd { config, store, state: Mutex::new(State { index
|
||||
// }), log }), listeners }).
|
||||
todo!()
|
||||
}
|
||||
|
||||
impl Running {
|
||||
/// Accept on every socket, one thread per connection, until a listener fails.
|
||||
pub fn run(self) -> Result<(), String> {
|
||||
// An mpsc channel of String. For each (listener, which): spawn a thread (with
|
||||
// `std::thread::Builder::new().spawn`, its error -> Err(format!("cannot start a thread:
|
||||
// {e}"))) that loops over `listener.incoming()`: Ok(stream) -> spawn one more thread
|
||||
// running `memoryd.connection(stream, which)`, and if that spawn fails, log
|
||||
// format!("memoryd: cannot start a thread: {e}") and go on; Err(e) -> send format!("a
|
||||
// listener failed: {e}") and return. Drop the sender, then wait on `rx.recv()`: a message
|
||||
// -> Err(it); all senders gone -> Ok(()).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
fn error(detail: String) -> Message {
|
||||
Message::Error(WireError {
|
||||
code: ErrorCode::Internal,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
fn bad(detail: &str) -> Message {
|
||||
Message::Error(WireError {
|
||||
code: ErrorCode::BadMessage,
|
||||
detail: detail.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Memoryd {
|
||||
fn lock(&self) -> MutexGuard<'_, State> {
|
||||
self.state.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
fn connection(&self, mut stream: UnixStream, which: Which) {
|
||||
// `set_read_timeout(Some(READ_WAIT))` (ignore its result). `read_frame`; on error just
|
||||
// return. The reply is `self.handle(request.msg, which)`, written with `write_frame` in an
|
||||
// Envelope with v PROTOCOL_VERSION, the request's id, final true (ignore the result).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The answer to one request on one socket. A message sent to the wrong socket is refused.
|
||||
fn handle(&self, msg: Message, which: Which) -> Message {
|
||||
match (which, msg) {
|
||||
(Which::Loop, Message::MemoryBlock(_)) => self.block(),
|
||||
(Which::Loop, Message::SessionUpdated(update)) => {
|
||||
self.session_updated(update.session.as_str())
|
||||
}
|
||||
(Which::Broker, Message::MemoryCall(call)) => Message::ToolResponse(self.call(call)),
|
||||
(Which::Admin, Message::Reindex(_)) => self.reindex(),
|
||||
(Which::Admin, Message::MemoryAdmin(admin)) => self.admin(admin.command, &admin.arg),
|
||||
_ => bad("memoryd does not take this message on this socket"),
|
||||
}
|
||||
}
|
||||
|
||||
fn block(&self) -> Message {
|
||||
// Hold the lock. `self.store.block()`: Ok(block) -> log each (file, refused) of
|
||||
// block.left_out as format!("memoryd: an entry of {} was left out of the block: {}",
|
||||
// file.name(), refused.sentence()), and answer Message::MemoryBlockReply(MemoryBlockReply {
|
||||
// text: block.text }); Err(e) -> error(e).
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn session_updated(&self, session: &str) -> Message {
|
||||
// Hold the lock. `index::session_logs(&self.config.sessions_dir(), session)`; its error ->
|
||||
// error(e). For each (epoch, path): `state.index.index_log(path, session, *epoch)`, and log
|
||||
// an error as format!("memoryd: not indexed: {e}"). Answer Message::Ok(Empty {}).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A decided call from `brokerd`: `recall` or `memory`, under the one lock. `Failed` carries the
|
||||
/// sentence for the model.
|
||||
fn call(&self, call: MemoryCall) -> ToolResponse {
|
||||
// Hold the lock. By the request's tool: proto::memory::RECALL -> `self.recall(&state,
|
||||
// &call)`; proto::memory::MEMORY -> `self.memory(&state, &call)`; anything else ->
|
||||
// Err("memoryd runs only recall and memory"). Ok(found) -> ToolResponse::Result { content:
|
||||
// found.text, class: found.class, untrusted: found.untrusted, truncated: false };
|
||||
// Err(message) -> ToolResponse::Failed { message }.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `recall`: search, labelled by what it found.
|
||||
fn recall(&self, state: &State, call: &MemoryCall) -> Result<Recalled, String> {
|
||||
// Parse the request's arguments as RecallArgs (an error -> format!("recall's arguments:
|
||||
// {e}")), then `recall::recall(&state.index, &self.store, &args, session,
|
||||
// Timestamp::now())`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `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.
|
||||
fn memory(&self, state: &State, call: &MemoryCall) -> Result<Recalled, String> {
|
||||
// 1. Parse the arguments as MemoryArgs (an error -> format!("memory's arguments: {e}")).
|
||||
// 2. A Context: session, call (request.call.0), taint and untrusted from the MemoryCall,
|
||||
// now Timestamp::now().
|
||||
// 3. When `self.config.memory.review` is on, the action is not a Note, and the call is not
|
||||
// untrusted: `review::submit(&self.store, &args, &request.arguments, &ctx)`. Otherwise
|
||||
// `edit::apply(&self.store, &args, &ctx)`, then `journal::record(&self.store, &ctx,
|
||||
// &outcome)?`, answering outcome.answer.
|
||||
// 4. `self.index_notes(state)`, whatever the answer.
|
||||
// 5. The answer as a Recalled { text, class Private, untrusted false }.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Index what was appended to `notes.md`; a failure is logged, and the next start catches up.
|
||||
fn index_notes(&self, state: &State) {
|
||||
// Read `self.store.path("notes.md")` (an error names the path) and
|
||||
// `state.index.index_notes(&text)`; log an error as format!("memoryd: notes not indexed:
|
||||
// {e}").
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn reindex(&self) -> Message {
|
||||
// Hold the lock (mutably). `index::rebuild(&self.config.index_path(), max_class,
|
||||
// &sessions_dir, &self.store.path("notes.md"))`; its error -> error(e); log each problem as
|
||||
// format!("memoryd: not indexed: {problem}"). Then `Index::open` the same path again and
|
||||
// put it in `state.index` (the old connection still points at the replaced file); its error
|
||||
// -> error(e). Answer Message::Reindexed { logs, records, notes } from the counts.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn admin(&self, command: MemoryCommand, arg: &str) -> Message {
|
||||
// Hold the lock; now = Timestamp::now(). Log: an empty arg means 10, otherwise it must
|
||||
// parse as usize, else Err(format!("log takes a count; {arg:?} is not one")); then
|
||||
// `journal::log(&self.store, n)`. Undo -> `journal::undo(&self.store, arg, now)`. Pending
|
||||
// -> `review::list`. Accept -> `review::accept(.., arg, now)`. Reject ->
|
||||
// `review::reject(.., arg, now)`. Then `self.index_notes(&state)`. Ok(text) ->
|
||||
// Message::MemoryAdminReply(MemoryAdminReply { text }); Err(e) -> error(e).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Listening on a Unix socket: the directory at 0700, a stale socket removed, the socket at 0600
|
||||
//! (M5a spec, section 7). The same steps as `brokerd`'s; no crate depends on another role's.
|
||||
|
||||
use std::fs::{DirBuilder, Permissions};
|
||||
use std::io;
|
||||
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
|
||||
/// Bind one socket: prepare its directory at 0700, make it private, remove any stale socket,
|
||||
/// bind, then make the socket itself private at 0600. The error names the path.
|
||||
pub fn listen(socket: &Path) -> Result<UnixListener, String> {
|
||||
// The same five steps as `listen` in brokerd/src/serve.rs. Every error is a String naming its
|
||||
// path: format!("{}: {e}", path.display()).
|
||||
// 1. The directory is `socket.parent()`; when that is None, empty, or `/` itself (its own
|
||||
// parent() is None): Err(format!("{}: a socket needs a directory of its own, not / or none",
|
||||
// socket.display())).
|
||||
// 2. `DirBuilder::new().recursive(true).mode(0o700).create(dir)`.
|
||||
// 3. `std::fs::symlink_metadata(dir)`; when its file_type() is not a directory:
|
||||
// Err(format!("{}: is a symbolic link or not a directory; memoryd will not change its mode",
|
||||
// dir.display())). Then `set_permissions(dir, Permissions::from_mode(0o700))`.
|
||||
// 4. Remove a stale socket: `remove_file(socket)`; NotFound is fine, any other error is
|
||||
// returned.
|
||||
// 5. `UnixListener::bind(socket)`, then `set_permissions(socket,
|
||||
// Permissions::from_mode(0o600))`. Ok(listener).
|
||||
todo!()
|
||||
}
|
||||
Reference in New Issue
Block a user