diff --git a/cubecli/src/main.rs b/cubecli/src/main.rs index 8813ffa..a9f3eef 100644 --- a/cubecli/src/main.rs +++ b/cubecli/src/main.rs @@ -62,9 +62,33 @@ fn is_command_word(w: &str) -> bool { | "rollback" | "stats" | "audit" + | "note" + | "notes" ) } +/// Open (or create) the durable session-note store. Notes persist across CLI +/// invocations and sessions — the CUBE "message save path" — via the WAL + +/// checkpoint store. The directory is `$CUBE_NOTES_DIR` (`~/.cubelinux-notes`). +fn open_notes_store() -> cubesys::store::ConcurrentStore { + let dir = std::env::var("CUBE_NOTES_DIR").unwrap_or_else(|_| { + format!( + "{}/.cubelinux-notes", + std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()) + ) + }); + cubesys::store::ConcurrentStore::open( + &format!("{dir}/notes.store"), + &format!("{dir}/notes.wal"), + &format!("{dir}/recovery.log"), + cubesys::store::DurabilityConfig::default(), + ) + .unwrap_or_else(|e| { + eprintln!("error: cannot open notes store {dir}: {e}"); + std::process::exit(1); + }) +} + fn main() { let args: Vec = std::env::args().collect(); match args.get(1).map(|s| s.as_str()) { @@ -75,12 +99,29 @@ fn main() { // the coordinate-addressed API a real CLI surface the OS can call. Some(word) if is_command_word(word) => { let line = args[1..].join(" "); - let mut session = Session::new(); - match session.exec(&line) { - Ok(out) => println!("{out}"), - Err(e) => { - eprintln!("error: {e}"); - std::process::exit(1); + // Session notes live in their own DURABLE store so a note written + // in one session is reviewable in the next (the message save path). + if word == "note" || word == "notes" { + let store = open_notes_store(); + let res = cubesys::notes::note_command(&store, &line); + // Force a durable flush/checkpoint before exiting so the note is + // persisted even though this process (the CLI) is short-lived. + store.checkpoint(); + match res { + Ok(out) => println!("{out}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + } else { + let mut session = Session::new(); + match session.exec(&line) { + Ok(out) => println!("{out}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } } } } @@ -144,6 +185,11 @@ fn print_help() { stat getattr via cubefs\n \ seal encrypt a record (tf: none|gcm|chacha|xts)\n \ open decrypt + decode + run a sealed record\n \ - keyinit ensure the OS Null-space keystore exists\n" + keyinit ensure the OS Null-space keystore exists\n \ + note add a session note (durable, WordFlags-tagged)\n \ + note list [session] list notes (default: today's session)\n \ + note search search notes across sessions\n \ + note show show one note\n \ + notes alias for `note list`\n" ); } diff --git a/cubesys/src/commands.rs b/cubesys/src/commands.rs index bed20db..1b7ed91 100644 --- a/cubesys/src/commands.rs +++ b/cubesys/src/commands.rs @@ -507,6 +507,11 @@ impl Session { body.len())) } } + // Session-message / note log: a searchable, per-session, per-day + // note area stored as CZYX records carrying WordFlags metadata + // (the structured replacement for the hand-maintained RESUME-*.md + // files and the Hermes session store). + "note" | "notes" => crate::notes::note_command(store, line), "trace-capture" => { let src_s = it.next().ok_or_else(|| "trace-capture needs ".to_string())?; let tgt_s = it.next().ok_or_else(|| "trace-capture needs ".to_string())?; diff --git a/cubesys/src/lib.rs b/cubesys/src/lib.rs index ee60a5e..8af0832 100644 --- a/cubesys/src/lib.rs +++ b/cubesys/src/lib.rs @@ -54,6 +54,11 @@ pub mod commands; pub mod grants; /// Length-framed Unix-domain-socket transport shared by client and server. pub mod net; +/// Session-message / note log: a searchable, per-session, per-day note area +/// stored as CZYX records carrying WordFlags metadata. This is the structured +/// replacement for the hand-maintained `RESUME-*.md` files so a new session +/// can review prior sessions' messages over the cube store (see [`notes`]). +pub mod notes; /// Dependency-free JSON snapshot load/dump for daemon store persistence. pub mod persist; /// Concurrent, durable store: mutex-wrapped [`CubeStore`] + NDJSON write-ahead diff --git a/cubesys/src/notes.rs b/cubesys/src/notes.rs new file mode 100644 index 0000000..1f29901 --- /dev/null +++ b/cubesys/src/notes.rs @@ -0,0 +1,382 @@ +//! CUBELinux session-message / note log. +//! +//! A small, searchable note area backed by the cube store instead of the +//! growing hand-maintained `RESUME-*.md` files. Each note is a CZYX record +//! with `doc_type = "note"`, a text-type [`WordFlags`] stamp, a `created_at` +//! day, and the session label as the owner, so a new session can review prior +//! sessions' messages by scanning the note type, filtering by session/day, or +//! full-text searching — the "message save path" helper. +//! +//! Convention: +//! * `doc_type = "note"` (all notes). +//! * `created_at` (epoch seconds) groups the note into a day. +//! * `owner_local_user` holds the session label. +//! * `word_flags` = a complete single-record frame + text-type note. +//! * The C axis is a per-session class byte so `scan_prefix` can return a +//! session's notes; Z/Y/X are content-derived (collision-resolved). + +use std::time::{SystemTime, UNIX_EPOCH}; + +use cubecoords::{CubeHeader, Czyx, WordFlags}; + +use crate::store::ConcurrentStore; + +/// `doc_type` stamped on every note record. +pub const NOTE_DOC_TYPE: &str = "note"; + +/// The default session label for CLI-created notes (today's calendar date). +pub fn default_session() -> String { + date_string(now()) +} + +/// The WordFlags stamped on a note: a complete single-record frame with the +/// text data type (type bits 00). Other metadata bits (encrypted, assoc, ...) +/// are left unset for the caller to raise as needed. +pub fn note_word_flags() -> WordFlags { + WordFlags::from_bits( + WordFlags::START_RECORD | WordFlags::END_RECORD | WordFlags::IS_HEADER, + ) +} + +/// Current unix time (seconds). +pub fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// A returned note (coordinate + header + body bytes). +#[derive(Clone, Debug)] +pub struct Note { + /// The note's coordinate (`C.Z.Y.X`). + pub coord: Czyx, + /// The note's record header (title, session label, WordFlags, ...). + pub header: CubeHeader, + /// The note's body text as raw bytes. + pub body: Vec, +} + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +fn non_zero(b: u8) -> u8 { + if b == 0 { + 1 + } else { + b + } +} + +/// The single-byte notes class for a session label, so `scan_prefix(class, +/// None, None)` returns exactly that session's notes. +fn session_class(session: &str) -> u8 { + let h = fnv1a(session.as_bytes()); + 1 + ((h >> 24) as u8 & 0x7f) // 1..=128 +} + +/// Derive a deterministic, collision-light coordinate for a note: the C axis +/// is the session class, Z/Y/X come from the note content + a salt. +fn note_coord(session: &str, created: u64, subject: &str, body: &str, salt: u64) -> Czyx { + let mut mix = fnv1a(session.as_bytes()); + mix ^= created.wrapping_mul(0x9e37_79b9); + mix ^= fnv1a(subject.as_bytes()); + mix ^= fnv1a(body.as_bytes()); + mix ^= salt.wrapping_mul(0x85eb_ca6b); + Czyx::new( + session_class(session), + non_zero((mix >> 16) as u8), + non_zero((mix >> 8) as u8), + non_zero(mix as u8 ^ (salt as u8)), + ) +} + +/// Store a note record under `session`, with a `subject` title and `body` +/// text. Returns the note's coordinate. +pub fn store_note( + store: &ConcurrentStore, + session: &str, + subject: &str, + body: &str, +) -> Result { + let created = now(); + let mut salt = 0u64; + let mut coord = note_coord(session, created, subject, body, salt); + while store.get_record(&coord).is_some() && salt < 4096 { + salt += 1; + coord = note_coord(session, created, subject, body, salt); + } + if salt >= 4096 { + return Err("note store: could not find a free coordinate (4096 tries)".into()); + } + let mut h = CubeHeader::new(); + h.title = Some(subject.to_string()); + h.doc_type = Some(NOTE_DOC_TYPE.to_string()); + h.created_at = Some(created); + h.owner_local_user = Some(session.to_string()); + h.word_flags = note_word_flags(); + h.refresh_flags(); + store.put_record(coord, &h, body.as_bytes()); + Ok(coord) +} + +/// List notes, newest first. `session` filters to one session label; `day` +/// (a day-of-epoch, see [`date_to_day`]) filters to one calendar day. +pub fn list_notes( + store: &ConcurrentStore, + session: Option<&str>, + day: Option, +) -> Vec { + let snap = store.read_snapshot(); + let mut out: Vec = snap + .query_by_type(NOTE_DOC_TYPE) + .into_iter() + .filter(|(_, h, _)| match session { + Some(s) => h.owner_local_user.as_deref() == Some(s), + None => true, + }) + .filter(|(_, h, _)| match day { + Some(d) => h.created_at.map(|c| c / 86_400) == Some(d), + None => true, + }) + .map(|(coord, header, body)| Note { + coord, + header, + body, + }) + .collect(); + out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at)); + out +} + +/// Search all notes (session + body) for `needle`, newest first. +pub fn search_notes(store: &ConcurrentStore, needle: &str) -> Vec { + let needle = needle.to_lowercase(); + let snap = store.read_snapshot(); + let mut out: Vec = snap + .query_by_type(NOTE_DOC_TYPE) + .into_iter() + .filter(|(_, h, body)| { + let subject = h.title.as_deref().unwrap_or("").to_lowercase(); + let b = String::from_utf8_lossy(body).to_lowercase(); + subject.contains(&needle) || b.contains(&needle) + }) + .map(|(coord, header, body)| Note { + coord, + header, + body, + }) + .collect(); + out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at)); + out +} + +/// Fetch a single note by coordinate. +pub fn show_note(store: &ConcurrentStore, coord: Czyx) -> Option { + store.get_record(&coord).map(|(header, body)| Note { + coord, + header, + body, + }) +} + +/// Format a unix timestamp as `YYYY-MM-DD` (UTC). +pub fn date_string(ts: u64) -> String { + let days = (ts / 86_400) as i64; + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Parse `YYYY-MM-DD` into a day-of-epoch (days since 1970-01-01). +pub fn date_to_day(s: &str) -> Option { + let mut it = s.split('-'); + let y: i64 = it.next()?.parse().ok()?; + let m: u32 = it.next()?.parse().ok()?; + let d: u32 = it.next()?.parse().ok()?; + if it.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) { + return None; + } + Some(days_from_civil(y, m, d)) +} + +/// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm). +fn days_from_civil(y: i64, m: u32, d: u32) -> u64 { + let yy = if m <= 2 { y - 1 } else { y }; + let era = if yy >= 0 { yy } else { yy - 399 } / 400; + let yoe = yy - era * 400; + let mp = (m as i64 + 9) % 12; + let doy = (153 * mp + 2) / 5 + d as i64 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + // Hinnant's `days_from_civil` counts from 1970-01-01; the era frame starts + // at 0000-03-01, so subtract the 719468-day offset to land on the epoch. + (era * 146_097 + doe - 719_468).max(0) as u64 +} + +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Render a coordinate as `C.Z.Y.X` (the [`crate::commands::parse_coord`] +/// form), so `note show ` round-trips. +fn coord_str(c: Czyx) -> String { + format!("{}.{}.{}.{}", c.c, c.z, c.y, c.x) +} + +/// Subject = the first line-ish portion of the note text (first 60 chars). +fn subject_of(text: &str) -> String { + let s = text.trim(); + let s: String = s.chars().take(60).collect(); + if s.len() < text.trim().len() { + format!("{s}…") + } else { + s + } +} + +fn format_notes(notes: &[Note]) -> String { + if notes.is_empty() { + return "(no notes)".to_string(); + } + let lines: Vec = notes + .iter() + .map(|n| { + let subj = n.header.title.as_deref().unwrap_or(""); + let when = n.header.created_at.map(date_string).unwrap_or_else(|| "?".into()); + format!(" {} [{}] {}", coord_str(n.coord), when, subj) + }) + .collect(); + format!("{} note(s):\n{}", notes.len(), lines.join("\n")) +} + +/// Process one `note`/`notes` command line against `store`. +/// +/// Syntax (REPL/CLI): +/// `note ` quick note (session = today, subject = first 60 chars) +/// `note add ` explicit add (same as above) +/// `note list [session]` list a session's notes (default: current day's session label, all sessions if empty) +/// `note search ` full-text search across all notes +/// `note show ` show one note's subject + body +/// `notes` alias for `note list` +pub fn note_command(store: &ConcurrentStore, line: &str) -> Result { + let body = line + .splitn(2, char::is_whitespace) + .nth(1) + .unwrap_or("") + .trim() + .to_string(); + let Some((head, rest)) = split_head(&body) else { + // bare `note` / `notes` -> list the current session's notes + let session = default_session(); + let notes = list_notes(store, Some(&session), None); + return Ok(format_notes(¬es)); + }; + match head { + "list" => { + let s = rest.trim(); + let session = if s.is_empty() { None } else { Some(s) }; + let notes = list_notes(store, session, None); + Ok(format_notes(¬es)) + } + "search" => { + let needle = rest.trim(); + if needle.is_empty() { + return Err("note search needs ".to_string()); + } + let notes = search_notes(store, needle); + Ok(format!("search '{needle}':\n{}", format_notes(¬es))) + } + "show" => { + let cs = rest.trim(); + let coord = crate::commands::parse_coord(cs) + .ok_or_else(|| format!("bad coordinate '{cs}' (want C.Z.Y.X)"))?; + match show_note(store, coord) { + Some(n) => { + let subj = n.header.title.as_deref().unwrap_or(""); + let when = n.header.created_at.map(date_string).unwrap_or_else(|| "?".into()); + Ok(format!( + "note {cs} [{}] {}\n{}", + when, + subj, + String::from_utf8_lossy(&n.body) + )) + } + None => Ok(format!("note {cs} -> (no note)")), + } + } + "add" | _ => { + let text = rest.trim(); + if text.is_empty() { + return Err("note needs ".to_string()); + } + let session = default_session(); + let subject = subject_of(text); + let coord = store_note(store, &session, &subject, text)?; + Ok(format!("note saved at {} (session {})", coord_str(coord), session)) + } + } +} + +/// Split the first whitespace-delimited token from the rest of a string. +fn split_head(s: &str) -> Option<(&str, &str)> { + let idx = s.find(char::is_whitespace)?; + Some((&s[..idx], &s[idx..].trim())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_and_list_a_note() { + let store = ConcurrentStore::memory(); + let coord = store_note(&store, "test-session", "hello", "world note").unwrap(); + assert!(store.get_record(&coord).is_some()); + let all = list_notes(&store, None, None); + assert_eq!(all.len(), 1); + assert_eq!(all[0].header.doc_type.as_deref(), Some(NOTE_DOC_TYPE)); + assert_eq!(all[0].header.owner_local_user.as_deref(), Some("test-session")); + assert_eq!(all[0].body, b"world note"); + // word_flags: text-type single-frame + assert!(all[0].header.word_flags.has(WordFlags::START_RECORD)); + assert!(!all[0].header.word_flags.has(WordFlags::ENCRYPTED)); + } + + #[test] + fn session_and_day_filters() { + let store = ConcurrentStore::memory(); + store_note(&store, "a", "one", "first").unwrap(); + store_note(&store, "b", "two", "second").unwrap(); + assert_eq!(list_notes(&store, Some("a"), None).len(), 1); + assert_eq!(list_notes(&store, Some("b"), None).len(), 1); + assert_eq!(list_notes(&store, None, None).len(), 2); + let today = now() / 86_400; + assert_eq!(list_notes(&store, None, Some(today)).len(), 2); + } + + #[test] + fn full_text_search_and_dates() { + let store = ConcurrentStore::memory(); + store_note(&store, "s", "kernel", "the ipu4 camera driver linked").unwrap(); + store_note(&store, "s", "mail", "opendkim signing works").unwrap(); + assert_eq!(search_notes(&store, "ipu4").len(), 1); + assert_eq!(search_notes(&store, "opendkim").len(), 1); + assert_eq!(search_notes(&store, "nomatchxyz").len(), 0); + assert_eq!(date_to_day("2026-09-08"), Some(20_704)); + assert_eq!(date_string(20_704 * 86_400), "2026-09-08"); + } +}