memoryd: Tiers listing; bxctl memory tiers
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -365,6 +365,8 @@ pub enum MemoryCommand {
|
||||
Reject,
|
||||
/// What the Honcho ledger holds: its counts and failed items (M5c spec, section 5).
|
||||
Sync,
|
||||
/// Identity entries and notes with recall-hit counts (M5d spec, section 4.4).
|
||||
Tiers,
|
||||
}
|
||||
|
||||
/// `bxctl` → `memoryd`: one command; `arg` is the count for `log`, the edit or pending id for
|
||||
|
||||
@@ -34,7 +34,7 @@ usage: bxctl <command> [options]
|
||||
bxctl reindex [--memory-socket PATH]
|
||||
Rebuild memoryd's index from the session logs and notes.
|
||||
|
||||
bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> | sync [--memory-socket PATH]
|
||||
bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> | sync | tiers [--memory-socket PATH]
|
||||
List memory edits, undo the latest edit of a file, or answer writes waiting for review.
|
||||
sync shows what memoryd sends to Honcho: counts, and each failed item by its label.
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ pub fn parse_memory(args: &[String], home: &Path) -> Result<Command, UsageError>
|
||||
["accept", id] => Ask::Admin(MemoryCommand::Accept, id.to_string()),
|
||||
["reject", id] => Ask::Admin(MemoryCommand::Reject, id.to_string()),
|
||||
["sync"] => Ask::Admin(MemoryCommand::Sync, String::new()),
|
||||
["tiers"] => Ask::Admin(MemoryCommand::Tiers, String::new()),
|
||||
_ => return Err(UsageError),
|
||||
};
|
||||
Ok(Command::Memory { socket, ask })
|
||||
|
||||
@@ -61,7 +61,7 @@ fn sync_parses() {
|
||||
);
|
||||
assert_eq!(parse(&args("memory sync now"), &home), Err(UsageError));
|
||||
assert!(
|
||||
USAGE.contains("| reject <id> | sync [--memory-socket PATH]\n"),
|
||||
USAGE.contains("| reject <id> | sync | tiers [--memory-socket PATH]\n"),
|
||||
"{USAGE}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Tests for `bxctl memory tiers`. Do not edit.
|
||||
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bxcore::{Envelope, MemoryAdminReply, MemoryCommand, Message, read_frame, write_frame};
|
||||
use bxctl::cli::{Command, UsageError, parse};
|
||||
use bxctl::memory::{Ask, run};
|
||||
|
||||
fn args(line: &str) -> Vec<String> {
|
||||
line.split_whitespace().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
PathBuf::from("/srv/bx")
|
||||
}
|
||||
|
||||
fn fake(socket: &Path, answer: Message) -> mpsc::Receiver<Message> {
|
||||
let listener = UnixListener::bind(socket).unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let env = read_frame(&mut stream).unwrap();
|
||||
tx.send(env.msg).unwrap();
|
||||
let reply = Envelope {
|
||||
v: 1,
|
||||
id: env.id,
|
||||
r#final: true,
|
||||
msg: answer,
|
||||
};
|
||||
write_frame(&mut stream, &reply).unwrap();
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_tiers_parses() {
|
||||
let h = home();
|
||||
assert_eq!(
|
||||
parse(&args("memory tiers"), &h),
|
||||
Ok(Command::Memory {
|
||||
socket: PathBuf::from("/srv/bx/run/owner-memory/admin.sock"),
|
||||
ask: Ask::Admin(MemoryCommand::Tiers, String::new())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&args("memory --memory-socket /r/m.sock tiers"), &h),
|
||||
Ok(Command::Memory {
|
||||
socket: PathBuf::from("/r/m.sock"),
|
||||
ask: Ask::Admin(MemoryCommand::Tiers, String::new())
|
||||
})
|
||||
);
|
||||
assert_eq!(parse(&args("memory tiers extra"), &h), Err(UsageError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_tiers_prints_the_reply() {
|
||||
let d = temp::TempDir::new("bx-tiers");
|
||||
let socket = d.join("admin.sock");
|
||||
let listing = "identity memory.md hits 0 last -\n I like tea.\n";
|
||||
let seen = fake(
|
||||
&socket,
|
||||
Message::MemoryAdminReply(MemoryAdminReply {
|
||||
text: listing.to_string(),
|
||||
}),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
run(
|
||||
&socket,
|
||||
&Ask::Admin(MemoryCommand::Tiers, String::new()),
|
||||
Duration::from_secs(5),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(out).unwrap(), format!("{listing}\n"));
|
||||
match seen.recv().unwrap() {
|
||||
Message::MemoryAdmin(m) => {
|
||||
assert_eq!((m.command, m.arg.as_str()), (MemoryCommand::Tiers, ""))
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,4 @@ pub mod recall;
|
||||
pub mod review;
|
||||
pub mod serve;
|
||||
pub mod socket;
|
||||
pub mod tiers;
|
||||
|
||||
@@ -62,6 +62,7 @@ impl Memoryd {
|
||||
Some(mirror) => mirror.report(),
|
||||
None => report::NOT_CONFIGURED.to_string(),
|
||||
}),
|
||||
MemoryCommand::Tiers => crate::tiers::list(&self.store, now),
|
||||
};
|
||||
if result.is_ok() && matches!(command, MemoryCommand::Undo | MemoryCommand::Accept) {
|
||||
self.mirror(None);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The tiering listing: identity entries and notes with recall-hit counts (M5d spec, 4.4).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::ErrorKind;
|
||||
|
||||
use bxcore::{MemoryFile, Timestamp};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::files::Store;
|
||||
use crate::notes::{self, Kind, Note};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Logged {
|
||||
time: String,
|
||||
hits: Vec<Hit>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Hit {
|
||||
kind: String,
|
||||
session: String,
|
||||
record: i64,
|
||||
}
|
||||
|
||||
/// How many recall lines named one note, and the latest of those lines' times.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
n: u64,
|
||||
last: String,
|
||||
}
|
||||
|
||||
fn fail(path: &std::path::Path, e: std::io::Error) -> String {
|
||||
format!("{}: {e}", path.display())
|
||||
}
|
||||
|
||||
fn class_of(note: &Note) -> &'static str {
|
||||
match ¬e.kind {
|
||||
Kind::Noted { class, .. } => notes::class_name(*class),
|
||||
Kind::Moved(_) | Kind::Dropped(_) => "private",
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_of(note: &Note) -> &'static str {
|
||||
match ¬e.kind {
|
||||
Kind::Noted { untrusted, .. } => {
|
||||
if *untrusted {
|
||||
"untrusted"
|
||||
} else {
|
||||
"trusted"
|
||||
}
|
||||
}
|
||||
Kind::Moved(_) | Kind::Dropped(_) => "trusted",
|
||||
}
|
||||
}
|
||||
|
||||
fn read_notes(store: &Store) -> Result<String, String> {
|
||||
let path = store.path("notes.md");
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => Ok(text),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(String::new()),
|
||||
Err(e) => Err(fail(&path, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The recall hits per `(session, record)`: how many lines named it and the latest of their times
|
||||
/// (M5d spec, section 4.4). A missing `recalls/` is an empty map; a line that does not parse is
|
||||
/// skipped, and a line counts once for each note it names.
|
||||
fn tally(store: &Store) -> Result<HashMap<(String, i64), Tally>, String> {
|
||||
let dir = store.path("recalls");
|
||||
let files = match std::fs::read_dir(&dir) {
|
||||
Ok(files) => files,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(HashMap::new()),
|
||||
Err(e) => return Err(fail(&dir, e)),
|
||||
};
|
||||
let mut hits: HashMap<(String, i64), Tally> = HashMap::new();
|
||||
for file in files {
|
||||
let file = file.map_err(|e| fail(&dir, e))?;
|
||||
if !file.file_type().map_err(|e| fail(&dir, e))?.is_file() {
|
||||
continue;
|
||||
}
|
||||
let path = file.path();
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(fail(&path, e)),
|
||||
};
|
||||
for line in text.lines() {
|
||||
let logged: Logged = match serde_json::from_str(line) {
|
||||
Ok(logged) => logged,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mut seen: HashSet<(String, i64)> = HashSet::new();
|
||||
for hit in &logged.hits {
|
||||
if hit.kind == "noted" {
|
||||
seen.insert((hit.session.clone(), hit.record));
|
||||
}
|
||||
}
|
||||
for key in seen {
|
||||
let tally = hits.entry(key).or_default();
|
||||
tally.n += 1;
|
||||
if logged.time > tally.last {
|
||||
tally.last = logged.time.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// One listing for `bxctl memory tiers` and for the tiering job's user text.
|
||||
pub fn list(store: &Store, _now: Timestamp) -> Result<String, String> {
|
||||
let mut out = String::new();
|
||||
|
||||
for file in [MemoryFile::Memory, MemoryFile::User] {
|
||||
for entry in store.entries(file)? {
|
||||
out.push_str(&format!(
|
||||
"identity {} hits 0 last -\n {}\n",
|
||||
file.name(),
|
||||
entry
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let hits = tally(store)?;
|
||||
for (i, note) in notes::parse(&read_notes(store)?).into_iter().enumerate() {
|
||||
let key = (note.session.clone(), i as i64);
|
||||
let (n, last) = match hits.get(&key) {
|
||||
Some(t) => (t.n, t.last.clone()),
|
||||
None => (0, "-".to_string()),
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"note session {} hits {} last {} {} {}\n {}\n",
|
||||
note.session,
|
||||
n,
|
||||
last,
|
||||
class_of(¬e),
|
||||
trust_of(¬e),
|
||||
note.text
|
||||
));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Tests for `MemoryCommand::Tiers`: identity entries and notes with recall-hit counts. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use bxcore::{MemoryFile, Timestamp};
|
||||
use memoryd::files::Store;
|
||||
use memoryd::tiers;
|
||||
use tmp::TempDir;
|
||||
|
||||
fn now() -> Timestamp {
|
||||
Timestamp::parse("2026-10-03T08:00:00.000Z").unwrap()
|
||||
}
|
||||
|
||||
struct Setup {
|
||||
_dir: TempDir,
|
||||
store: Store,
|
||||
}
|
||||
|
||||
fn setup(tag: &str) -> Setup {
|
||||
let dir = TempDir::new(tag);
|
||||
let starter = dir.write("etc/soul.md", "Soul.\n");
|
||||
let store = Store::new(&dir.path().join("memory"));
|
||||
store.init(&starter).unwrap();
|
||||
Setup { _dir: dir, store }
|
||||
}
|
||||
|
||||
fn write_recall(store: &Store, line: &str) {
|
||||
std::fs::create_dir_all(store.path("recalls")).unwrap();
|
||||
store
|
||||
.append("recalls/2026-10.jsonl", &format!("{line}\n"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_entries_list_with_zero_hits() {
|
||||
let s = setup("tiers-id");
|
||||
s.store
|
||||
.write_entries(MemoryFile::Memory, &["I like tea.".to_string()])
|
||||
.unwrap();
|
||||
s.store
|
||||
.write_entries(MemoryFile::User, &["The owner is Kyle.".to_string()])
|
||||
.unwrap();
|
||||
let text = tiers::list(&s.store, now()).unwrap();
|
||||
assert!(
|
||||
text.contains("identity memory.md hits 0 last -"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("I like tea."), "{text}");
|
||||
assert!(text.contains("identity user.md hits 0 last -"), "{text}");
|
||||
assert!(text.contains("The owner is Kyle."), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_carry_counts_from_recalls() {
|
||||
let s = setup("tiers-notes");
|
||||
std::fs::write(
|
||||
s.store.path("notes.md"),
|
||||
"§ noted 2026-09-22T18:20Z in session mm-a · private\nlaunch window moved.\n",
|
||||
)
|
||||
.unwrap();
|
||||
write_recall(
|
||||
&s.store,
|
||||
r#"{"time":"2026-10-03T07:00:00.000Z","session":"mm-now","query":"launch","hits":[{"kind":"noted","session":"mm-a","epoch":0,"record":0}]}"#,
|
||||
);
|
||||
write_recall(
|
||||
&s.store,
|
||||
r#"{"time":"2026-10-03T07:30:00.000Z","session":"mm-now","query":"window","hits":[{"kind":"noted","session":"mm-a","epoch":0,"record":0}]}"#,
|
||||
);
|
||||
let text = tiers::list(&s.store, now()).unwrap();
|
||||
assert!(
|
||||
text.contains(
|
||||
"note session mm-a hits 2 last 2026-10-03T07:30:00.000Z private trusted"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("launch window moved."), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_notes_are_listed_and_marked() {
|
||||
let s = setup("tiers-untrusted");
|
||||
std::fs::write(
|
||||
s.store.path("notes.md"),
|
||||
"§ noted 2026-09-22T18:20Z in session mm-c · private · untrusted\nexample.org says the launch moved.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let text = tiers::list(&s.store, now()).unwrap();
|
||||
assert!(
|
||||
text.contains("note session mm-c hits 0 last - private untrusted"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("example.org says the launch moved."),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recall_of_a_different_note_does_not_count() {
|
||||
let s = setup("tiers-other");
|
||||
std::fs::write(
|
||||
s.store.path("notes.md"),
|
||||
"§ noted 2026-09-22T18:20Z in session mm-a · private\nfirst note.\n§ noted 2026-09-22T18:21Z in session mm-b · private\nsecond note.\n",
|
||||
)
|
||||
.unwrap();
|
||||
write_recall(
|
||||
&s.store,
|
||||
r#"{"time":"2026-10-03T07:00:00.000Z","session":"mm-now","query":"second","hits":[{"kind":"noted","session":"mm-b","epoch":0,"record":1}]}"#,
|
||||
);
|
||||
let text = tiers::list(&s.store, now()).unwrap();
|
||||
assert!(
|
||||
text.contains("note session mm-a hits 0 last - private trusted"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(
|
||||
"note session mm-b hits 1 last 2026-10-03T07:00:00.000Z private trusted"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user