diff --git a/README.md b/README.md index af40b32..18cf92a 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,11 @@ Ephemeral ──promote──▶ Stable ──gc──▶ Deprecated | `memora branch [NAME]` | List or create branches. | | `memora switch NAME` | Move HEAD to an existing branch. | | `memora rollback --to ` | Reset HEAD to a previous commit (auto-checkpoint first). | +| `memora promote --id \| --type \| --all-confirmed [T]` | Promote ephemeral nodes to stable. | +| `memora diff [FROM] [TO] [--working] [--semantic]` | Show belief changes between two revisions. | -Future phases add `diff`, `merge`, `replay`, `export`, `import`, `gc`, -`promote`, `push`, `pull`. See `SPEC.md` for the full roadmap. +Future phases add `merge`, `replay`, `export`, `import`, `gc`, `push`, +`pull`. See `SPEC.md` for the full roadmap. --- diff --git a/SPEC.md b/SPEC.md index 17840ab..efbb4cd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -99,6 +99,23 @@ CREATE TABLE commit_nodes ( PRIMARY KEY (commit_id, node_id) ); +CREATE TABLE node_versions ( + commit_id TEXT NOT NULL REFERENCES commits(id) ON DELETE CASCADE, + node_id TEXT NOT NULL, + kind TEXT NOT NULL, + content TEXT NOT NULL, + confidence REAL NOT NULL, + status TEXT NOT NULL, + source TEXT NOT NULL, + evidence TEXT, + tags_json TEXT NOT NULL DEFAULT '[]', + related_to_json TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (commit_id, node_id) +); + CREATE TABLE sessions ( id TEXT PRIMARY KEY, started_at INTEGER NOT NULL, @@ -118,7 +135,8 @@ CREATE TABLE session_events ( Indexes are `idx_nodes_kind`, `idx_nodes_status`, `idx_nodes_updated`, `idx_commits_parent`, `idx_commits_ts`, `idx_commit_nodes_node`, -`idx_session_events_session`. They are advisory: any tool may rebuild them. +`idx_node_versions_node`, `idx_session_events_session`. They are advisory: +any tool may rebuild them. `PRAGMA foreign_keys = ON` and `PRAGMA journal_mode = WAL` are required for correctness and concurrency. diff --git a/crates/memora-cli/src/cli.rs b/crates/memora-cli/src/cli.rs index f94549f..048d4a7 100644 --- a/crates/memora-cli/src/cli.rs +++ b/crates/memora-cli/src/cli.rs @@ -47,6 +47,12 @@ pub enum Command { /// Roll HEAD back to a specified commit, with auto checkpoint. Rollback(RollbackArgs), + + /// Promote ephemeral nodes to stable. + Promote(PromoteArgs), + + /// Show what changed between two commits (or commit vs working set). + Diff(DiffArgs), } /// Arguments for `memora init`. @@ -145,3 +151,50 @@ pub struct RollbackArgs { #[arg(long, default_value = "human")] pub author: String, } + +/// Arguments for `memora promote`. +/// +/// Exactly one of `--id`, `--type`, or `--all-confirmed` must be provided. +#[derive(Debug, clap::Args)] +pub struct PromoteArgs { + /// Promote a single node by id (full or short hex). + #[arg(long = "id", value_name = "NODE")] + pub id: Option, + + /// Promote every ephemeral node of the given memory type. + #[arg(long = "type", value_name = "KIND", conflicts_with = "id")] + pub kind: Option, + + /// Promote every ephemeral node whose confidence is >= the threshold + /// (default 0.8 when the flag is given without a value). + #[arg( + long = "all-confirmed", + value_name = "THRESHOLD", + num_args = 0..=1, + default_missing_value = "0.8", + conflicts_with_all = ["id", "kind"], + )] + pub all_confirmed: Option, +} + +/// Arguments for `memora diff`. +#[derive(Debug, clap::Args)] +pub struct DiffArgs { + /// `from` revision (commit, branch, HEAD, HEAD~N). Defaults to HEAD~1. + #[arg(value_name = "FROM", default_value = "HEAD~1")] + pub from: String, + + /// `to` revision. Defaults to HEAD; pass `--working` to compare + /// against the uncommitted working set instead. + #[arg(value_name = "TO", default_value = "HEAD")] + pub to: String, + + /// Compare `from` to the current uncommitted working set rather than + /// to a second commit. + #[arg(long)] + pub working: bool, + + /// Include a natural-language summary of belief changes. + #[arg(long)] + pub semantic: bool, +} diff --git a/crates/memora-cli/src/commands/add.rs b/crates/memora-cli/src/commands/add.rs index c127b9c..7c616f3 100644 --- a/crates/memora-cli/src/commands/add.rs +++ b/crates/memora-cli/src/commands/add.rs @@ -4,13 +4,12 @@ use std::env; use std::str::FromStr; use anyhow::{Context, Result}; -use owo_colors::OwoColorize; use memora_core::node::{MemoryKind, MemorySource, NewNode}; use memora_core::Repository; use crate::cli::AddArgs; -use crate::ui::short_id; +use crate::ui::{bold, green, short_id}; /// Entry point for the `add` subcommand. pub fn run(args: AddArgs) -> Result<()> { @@ -42,9 +41,9 @@ pub fn run(args: AddArgs) -> Result<()> { println!( "{} [{}] node {} (confidence {:.2}, status {})", - "Added".green().bold(), + bold(green("Added")), node.kind, - short_id(&node.id).bold(), + bold(short_id(&node.id)), node.confidence, node.status, ); diff --git a/crates/memora-cli/src/commands/branch.rs b/crates/memora-cli/src/commands/branch.rs index 1449f7b..1d59f6c 100644 --- a/crates/memora-cli/src/commands/branch.rs +++ b/crates/memora-cli/src/commands/branch.rs @@ -3,11 +3,11 @@ use std::env; use anyhow::Result; -use owo_colors::OwoColorize; use memora_core::Repository; use crate::cli::BranchArgs; +use crate::ui::{bold, dim, green}; /// Entry point for the `branch` subcommand. pub fn run(args: BranchArgs) -> Result<()> { @@ -20,12 +20,12 @@ pub fn run(args: BranchArgs) -> Result<()> { let current = head.branch(); let branches = repo.list_branches()?; if branches.is_empty() { - println!("{}", "no branches".dimmed()); + println!("{}", dim("no branches")); return Ok(()); } for b in branches { if Some(b.as_str()) == current { - println!("* {}", b.green().bold()); + println!("* {}", bold(green(&b))); } else { println!(" {b}"); } @@ -36,6 +36,6 @@ pub fn run(args: BranchArgs) -> Result<()> { // Otherwise create a new branch from HEAD. let name = args.name.unwrap(); repo.create_branch(&name)?; - println!("{} branch {}", "Created".green().bold(), name.bold()); + println!("{} branch {}", bold(green("Created")), bold(&name)); Ok(()) } diff --git a/crates/memora-cli/src/commands/commit.rs b/crates/memora-cli/src/commands/commit.rs index f95c837..46ffeba 100644 --- a/crates/memora-cli/src/commands/commit.rs +++ b/crates/memora-cli/src/commands/commit.rs @@ -3,12 +3,11 @@ use std::env; use anyhow::Result; -use owo_colors::OwoColorize; use memora_core::Repository; use crate::cli::CommitArgs; -use crate::ui::short_id; +use crate::ui::{bold, cyan, green, red, short_id, yellow}; /// Entry point for the `commit` subcommand. pub fn run(args: CommitArgs) -> Result<()> { @@ -21,24 +20,24 @@ pub fn run(args: CommitArgs) -> Result<()> { None => { println!( "{} no changes since the last commit.", - "Nothing to commit:".yellow().bold() + bold(yellow("Nothing to commit:")) ); } Some(c) => { let branch = outcome.branch.as_deref().unwrap_or("(detached)"); println!( "[{} {}] {}", - branch.bold(), - short_id(&c.id).yellow(), + bold(branch), + yellow(short_id(&c.id)), c.message ); println!( " +{} added · ~{} modified · -{} removed · {} promoted · {} conflicted", - c.stats.added.green(), - c.stats.modified.cyan(), - c.stats.removed.red(), - c.stats.promoted.green(), - c.stats.conflicted.yellow(), + green(c.stats.added.to_string()), + cyan(c.stats.modified.to_string()), + red(c.stats.removed.to_string()), + green(c.stats.promoted.to_string()), + yellow(c.stats.conflicted.to_string()), ); } } diff --git a/crates/memora-cli/src/commands/diff.rs b/crates/memora-cli/src/commands/diff.rs new file mode 100644 index 0000000..926f356 --- /dev/null +++ b/crates/memora-cli/src/commands/diff.rs @@ -0,0 +1,105 @@ +//! `memora diff` — show belief changes between two commits or vs working set. + +use std::env; + +use anyhow::Result; + +use memora_core::{NodeChange, Repository}; + +use crate::cli::DiffArgs; +use crate::ui::{bold, cyan, dim, green, red, short_id, yellow}; + +/// Entry point for the `diff` subcommand. +pub fn run(args: DiffArgs) -> Result<()> { + let cwd = env::current_dir()?; + let repo = Repository::open_from(&cwd)?; + + let to = if args.working { None } else { Some(args.to.as_str()) }; + let report = repo.diff(&args.from, to)?; + + let to_label = if to.is_none() { + dim("(working set)") + } else { + yellow(short_id(&report.to_label)) + }; + println!( + "{} {} → {}", + bold("diff"), + yellow(short_id(&report.from_id)), + to_label, + ); + + if report.is_empty() { + println!("{}", dim("no belief changes between the two states")); + return Ok(()); + } + + if !report.added.is_empty() { + println!("\n{}", bold(green("Added:"))); + for n in &report.added { + println!( + " + {} [{}] {}", + dim(short_id(&n.id)), + bold(n.kind.to_string()), + truncate(&n.content, 96), + ); + } + } + + if !report.modified.is_empty() { + println!("\n{}", bold(cyan("Changed:"))); + for m in &report.modified { + println!( + " ~ {} [{}] {}", + dim(short_id(&m.after.id)), + bold(m.after.kind.to_string()), + truncate(&m.after.content, 96), + ); + for ch in &m.changes { + let label = match ch { + NodeChange::Status { from, to } => format!("status: {from} → {to}"), + NodeChange::Content => "content updated".to_string(), + NodeChange::Confidence { from, to } => { + format!("confidence: {from:.2} → {to:.2}") + } + NodeChange::Source => "source updated".to_string(), + NodeChange::Evidence => "evidence updated".to_string(), + }; + println!(" {}", dim(&label)); + } + } + } + + if !report.removed.is_empty() { + println!("\n{}", bold(red("Removed:"))); + for n in &report.removed { + println!( + " - {} [{}] {}", + dim(short_id(&n.id)), + bold(n.kind.to_string()), + truncate(&n.content, 96), + ); + } + } + + if args.semantic { + let lines = report.semantic_lines(); + if !lines.is_empty() { + println!("\n{}", bold("Semantic summary:")); + for l in lines { + println!(" {l}"); + } + } + } + Ok(()) +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut out: String = s.chars().take(max - 1).collect(); + out.push('…'); + out + } +} diff --git a/crates/memora-cli/src/commands/init.rs b/crates/memora-cli/src/commands/init.rs index 30f4c9a..6c47707 100644 --- a/crates/memora-cli/src/commands/init.rs +++ b/crates/memora-cli/src/commands/init.rs @@ -3,11 +3,11 @@ use std::env; use anyhow::Result; -use owo_colors::OwoColorize; use memora_core::Repository; use crate::cli::InitArgs; +use crate::ui::{bold, dim, green}; /// Entry point for the `init` subcommand. pub fn run(args: InitArgs) -> Result<()> { @@ -18,12 +18,12 @@ pub fn run(args: InitArgs) -> Result<()> { let repo = Repository::init(&target)?; println!( "{} memora store at {}", - "Initialised".green().bold(), + bold(green("Initialised")), repo.memora_dir().display() ); - println!("HEAD now points at branch {}.", "main".bold()); + println!("HEAD now points at branch {}.", bold("main")); println!("Next steps:"); - println!(" {}", "memora add --type=semantic --content=\"...\"".dimmed()); - println!(" {}", "memora commit -m \"first memory\"".dimmed()); + println!(" {}", dim("memora add --type=semantic --content=\"...\"")); + println!(" {}", dim("memora commit -m \"first memory\"")); Ok(()) } diff --git a/crates/memora-cli/src/commands/log.rs b/crates/memora-cli/src/commands/log.rs index d3341bb..85517ed 100644 --- a/crates/memora-cli/src/commands/log.rs +++ b/crates/memora-cli/src/commands/log.rs @@ -3,12 +3,11 @@ use std::env; use anyhow::Result; -use owo_colors::OwoColorize; use memora_core::Repository; use crate::cli::LogArgs; -use crate::ui::{fmt_timestamp, short_id}; +use crate::ui::{dim, fmt_timestamp, short_id, yellow}; /// Entry point for the `log` subcommand. pub fn run(args: LogArgs) -> Result<()> { @@ -16,14 +15,14 @@ pub fn run(args: LogArgs) -> Result<()> { let repo = Repository::open_from(&cwd)?; let commits = repo.log(args.limit)?; if commits.is_empty() { - println!("{}", "no commits yet".dimmed()); + println!("{}", dim("no commits yet")); return Ok(()); } for c in commits { if args.oneline { - println!("{} {}", short_id(&c.id).yellow(), c.message); + println!("{} {}", yellow(short_id(&c.id)), c.message); } else { - println!("{} {}", "commit".yellow(), c.id.yellow()); + println!("{} {}", yellow("commit"), yellow(&c.id)); if let Some(p) = &c.parent { println!("Parent: {}", short_id(p)); } diff --git a/crates/memora-cli/src/commands/mod.rs b/crates/memora-cli/src/commands/mod.rs index 411e138..f083164 100644 --- a/crates/memora-cli/src/commands/mod.rs +++ b/crates/memora-cli/src/commands/mod.rs @@ -11,8 +11,10 @@ use crate::cli::{Cli, Command}; mod add; mod branch; mod commit; +mod diff; mod init; mod log; +mod promote; mod rollback; mod status; mod switch; @@ -28,5 +30,7 @@ pub fn dispatch(cli: Cli) -> Result<()> { Command::Branch(args) => branch::run(args), Command::Switch(args) => switch::run(args), Command::Rollback(args) => rollback::run(args), + Command::Promote(args) => promote::run(args), + Command::Diff(args) => diff::run(args), } } diff --git a/crates/memora-cli/src/commands/promote.rs b/crates/memora-cli/src/commands/promote.rs new file mode 100644 index 0000000..66c17d3 --- /dev/null +++ b/crates/memora-cli/src/commands/promote.rs @@ -0,0 +1,100 @@ +//! `memora promote` — move ephemeral nodes to stable. + +use std::env; +use std::str::FromStr; + +use anyhow::{Context, Result}; + +use memora_core::node::MemoryKind; +use memora_core::{PromotePlan, Repository}; + +use crate::cli::PromoteArgs; +use crate::ui::{bold, dim, green, short_id, yellow}; + +/// Entry point for the `promote` subcommand. +pub fn run(args: PromoteArgs) -> Result<()> { + let plan = build_plan(&args)?; + let cwd = env::current_dir()?; + let repo = Repository::open_from(&cwd)?; + + let plan = match plan { + PromotePlan::Ids(ids) => { + let resolved = ids + .into_iter() + .map(|id| resolve_node_id(&repo, &id)) + .collect::>>()?; + PromotePlan::Ids(resolved) + } + other => other, + }; + + let promoted = repo.promote(plan)?; + + if promoted.is_empty() { + println!( + "{} no ephemeral nodes matched the promotion plan.", + bold(yellow("Nothing to promote:")) + ); + return Ok(()); + } + + println!( + "{} {} node{}", + bold(green("Promoted")), + promoted.len(), + if promoted.len() == 1 { "" } else { "s" } + ); + for id in &promoted { + println!(" {} {}", bold(short_id(id)), dim("ephemeral → stable")); + } + Ok(()) +} + +fn build_plan(args: &PromoteArgs) -> Result { + match (&args.id, &args.kind, args.all_confirmed) { + (Some(id), None, None) => Ok(PromotePlan::Ids(vec![id.clone()])), + (None, Some(k), None) => { + let kind = MemoryKind::from_str(k) + .with_context(|| format!("invalid --type value '{k}'"))?; + Ok(PromotePlan::Kind(kind)) + } + (None, None, Some(threshold)) => { + anyhow::ensure!( + (0.0..=1.0).contains(&threshold), + "--all-confirmed threshold must be between 0.0 and 1.0 (got {threshold})" + ); + Ok(PromotePlan::AllConfirmed { + min_confidence: threshold, + }) + } + _ => anyhow::bail!( + "specify exactly one of --id , --type , or --all-confirmed [THRESHOLD]" + ), + } +} + +/// Allow short ids on the CLI by scanning the working set. +fn resolve_node_id(repo: &Repository, candidate: &str) -> Result { + if repo.store().get_node(candidate)?.is_some() { + return Ok(candidate.to_string()); + } + let trimmed = candidate.trim(); + anyhow::ensure!( + trimmed.len() >= 4 && trimmed.chars().all(|c| c.is_ascii_hexdigit()), + "node id '{candidate}' must be at least 4 hex characters" + ); + let mut matches = Vec::new(); + for n in repo.store().all_nodes()? { + if n.id.starts_with(trimmed) { + matches.push(n.id); + } + } + match matches.len() { + 0 => anyhow::bail!("no node matches id prefix '{candidate}'"), + 1 => Ok(matches.pop().unwrap()), + _ => anyhow::bail!( + "ambiguous node id prefix '{candidate}' (matched {} nodes)", + matches.len() + ), + } +} diff --git a/crates/memora-cli/src/commands/rollback.rs b/crates/memora-cli/src/commands/rollback.rs index 5226cbd..4fb562b 100644 --- a/crates/memora-cli/src/commands/rollback.rs +++ b/crates/memora-cli/src/commands/rollback.rs @@ -6,12 +6,11 @@ use std::env; use anyhow::Result; -use owo_colors::OwoColorize; use memora_core::Repository; use crate::cli::RollbackArgs; -use crate::ui::short_id; +use crate::ui::{bold, dim, short_id, yellow}; /// Entry point for the `rollback` subcommand. pub fn run(args: RollbackArgs) -> Result<()> { @@ -22,14 +21,13 @@ pub fn run(args: RollbackArgs) -> Result<()> { let target = repo.rollback_to(&target_id, &args.author)?; println!( "{} HEAD → {} ({})", - "Rolled back".yellow().bold(), - short_id(&target.id).yellow(), + bold(yellow("Rolled back")), + yellow(short_id(&target.id)), target.message ); println!( "{}", - " (a pre-rollback checkpoint commit was recorded if there were uncommitted changes)" - .dimmed() + dim(" (a pre-rollback checkpoint commit was recorded if there were uncommitted changes)") ); Ok(()) } diff --git a/crates/memora-cli/src/commands/status.rs b/crates/memora-cli/src/commands/status.rs index fa7fc14..62d3d60 100644 --- a/crates/memora-cli/src/commands/status.rs +++ b/crates/memora-cli/src/commands/status.rs @@ -4,12 +4,11 @@ use std::collections::BTreeMap; use std::env; use anyhow::Result; -use owo_colors::OwoColorize; use memora_core::node::MemoryKind; use memora_core::Repository; -use crate::ui::short_id; +use crate::ui::{bold, cyan, dim, green, red, short_id}; /// Entry point for the `status` subcommand. pub fn run() -> Result<()> { @@ -19,26 +18,26 @@ pub fn run() -> Result<()> { let summary = repo.status()?; match head.branch() { - Some(b) => println!("On branch {}", b.bold()), + Some(b) => println!("On branch {}", bold(b)), None => println!("HEAD is detached"), } println!("{} nodes in working set", summary.total); if summary.added.is_empty() && summary.modified.is_empty() && summary.removed.is_empty() { - println!("{}", "Nothing to commit; working set matches HEAD.".dimmed()); + println!("{}", dim("Nothing to commit; working set matches HEAD.")); return Ok(()); } if !summary.added.is_empty() { - println!("\n{}", "Added since HEAD:".green().bold()); + println!("\n{}", bold(green("Added since HEAD:"))); let by_kind = group_by_kind(&summary.added); for (kind, nodes) in by_kind { - println!(" {}", kind.to_string().bold()); + println!(" {}", bold(kind.to_string())); for n in nodes { println!( " {} {} {}", - short_id(&n.id).dimmed(), - format!("[{}]", n.status).dimmed(), + dim(short_id(&n.id)), + dim(format!("[{}]", n.status)), truncate(&n.content, 80) ); } @@ -46,21 +45,21 @@ pub fn run() -> Result<()> { } if !summary.modified.is_empty() { - println!("\n{}", "Modified since HEAD:".cyan().bold()); + println!("\n{}", bold(cyan("Modified since HEAD:"))); for n in &summary.modified { println!( " {} {} {}", - short_id(&n.id).dimmed(), - format!("[{}]", n.kind).dimmed(), + dim(short_id(&n.id)), + dim(format!("[{}]", n.kind)), truncate(&n.content, 80) ); } } if !summary.removed.is_empty() { - println!("\n{}", "Removed since HEAD:".red().bold()); + println!("\n{}", bold(red("Removed since HEAD:"))); for id in &summary.removed { - println!(" {}", short_id(id).red()); + println!(" {}", red(short_id(id))); } } Ok(()) diff --git a/crates/memora-cli/src/commands/switch.rs b/crates/memora-cli/src/commands/switch.rs index e19685a..60b6061 100644 --- a/crates/memora-cli/src/commands/switch.rs +++ b/crates/memora-cli/src/commands/switch.rs @@ -3,17 +3,17 @@ use std::env; use anyhow::Result; -use owo_colors::OwoColorize; use memora_core::Repository; use crate::cli::SwitchArgs; +use crate::ui::bold; /// Entry point for the `switch` subcommand. pub fn run(args: SwitchArgs) -> Result<()> { let cwd = env::current_dir()?; let repo = Repository::open_from(&cwd)?; repo.switch_branch(&args.name)?; - println!("Switched to branch {}", args.name.bold()); + println!("Switched to branch {}", bold(&args.name)); Ok(()) } diff --git a/crates/memora-cli/src/main.rs b/crates/memora-cli/src/main.rs index a945b93..6cd6d8d 100644 --- a/crates/memora-cli/src/main.rs +++ b/crates/memora-cli/src/main.rs @@ -15,6 +15,12 @@ mod commands; mod ui; fn main() -> ExitCode { + // Decide colour once. We disable when NO_COLOR is set or stdout is + // not a real terminal (so `memora ... | grep ...` is clean). + let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout()); + let colour = stdout_is_tty && std::env::var_os("NO_COLOR").is_none(); + ui::set_color(colour); + let args = cli::Cli::parse(); match commands::dispatch(args) { Ok(()) => ExitCode::SUCCESS, diff --git a/crates/memora-cli/src/ui.rs b/crates/memora-cli/src/ui.rs index 4919104..b388fa1 100644 --- a/crates/memora-cli/src/ui.rs +++ b/crates/memora-cli/src/ui.rs @@ -1,18 +1,88 @@ //! Pretty-printing helpers for the CLI. //! -//! Centralising colours and formatting here means we can later add a -//! `--no-color` flag in one place. For now we lean on `owo_colors` to -//! auto-detect tty support. +//! The colour helpers here check a process-wide `COLOR_ENABLED` flag set +//! by `main.rs` based on `NO_COLOR` and TTY detection. Each helper takes a +//! `&str` and returns a `String` so callers can interpolate them naturally +//! with `format!` / `println!`. When colour is disabled the helpers simply +//! pass the string through unchanged. + +use std::sync::atomic::{AtomicBool, Ordering}; use owo_colors::OwoColorize; +static COLOR_ENABLED: AtomicBool = AtomicBool::new(true); + +/// Enable or disable colour for the rest of the process. Called once from +/// `main.rs` after parsing `NO_COLOR` and checking whether stdout is a tty. +pub fn set_color(enabled: bool) { + COLOR_ENABLED.store(enabled, Ordering::SeqCst); +} + +fn enabled() -> bool { + COLOR_ENABLED.load(Ordering::SeqCst) +} + +/// Apply the *bold* attribute. +pub fn bold(s: impl AsRef) -> String { + if enabled() { + s.as_ref().bold().to_string() + } else { + s.as_ref().to_string() + } +} + +/// Apply the *dim* attribute. +pub fn dim(s: impl AsRef) -> String { + if enabled() { + s.as_ref().dimmed().to_string() + } else { + s.as_ref().to_string() + } +} + +/// Green text. +pub fn green(s: impl AsRef) -> String { + if enabled() { + s.as_ref().green().to_string() + } else { + s.as_ref().to_string() + } +} + +/// Red text. +pub fn red(s: impl AsRef) -> String { + if enabled() { + s.as_ref().red().to_string() + } else { + s.as_ref().to_string() + } +} + +/// Yellow text. +pub fn yellow(s: impl AsRef) -> String { + if enabled() { + s.as_ref().yellow().to_string() + } else { + s.as_ref().to_string() + } +} + +/// Cyan text. +pub fn cyan(s: impl AsRef) -> String { + if enabled() { + s.as_ref().cyan().to_string() + } else { + s.as_ref().to_string() + } +} + /// Print an error to stderr in red. Errors come back as `anyhow::Error` /// from the command dispatcher so we can also show their cause chain. pub fn print_error(err: &anyhow::Error) { - eprintln!("{} {}", "error:".red().bold(), err); + eprintln!("{} {}", bold(red("error:")), err); let mut source = err.source(); while let Some(s) = source { - eprintln!(" {} {}", "caused by:".dimmed(), s); + eprintln!(" {} {}", dim("caused by:"), s); source = s.source(); } } diff --git a/crates/memora-cli/tests/cli.rs b/crates/memora-cli/tests/cli.rs index e2b9336..0d595e3 100644 --- a/crates/memora-cli/tests/cli.rs +++ b/crates/memora-cli/tests/cli.rs @@ -192,3 +192,94 @@ fn branch_create_and_list() { .success() .stdout(predicate::str::contains("Switched")); } + + +#[test] +fn promote_by_kind_then_diff_shows_status_change() { + let tmp = tempdir().unwrap(); + let path = tmp.path(); + + memora().arg("init").current_dir(path).assert().success(); + + memora() + .args([ + "add", + "--type", + "assumption", + "--content", + "redis is the cache", + "--source", + "model-inference", + ]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["commit", "-m", "initial guess"]) + .current_dir(path) + .assert() + .success(); + + memora() + .args(["promote", "--type", "assumption"]) + .current_dir(path) + .assert() + .success() + .stdout(predicate::str::contains("ephemeral → stable")); + memora() + .args(["commit", "-m", "promote redis"]) + .current_dir(path) + .assert() + .success() + .stdout(predicate::str::contains("1 promoted")); + + memora() + .args(["diff", "HEAD~1", "HEAD", "--semantic"]) + .current_dir(path) + .assert() + .success() + .stdout(predicate::str::contains("ephemeral → stable")) + .stdout(predicate::str::contains("Semantic summary")); +} + +#[test] +fn promote_requires_one_target() { + let tmp = tempdir().unwrap(); + let path = tmp.path(); + memora().arg("init").current_dir(path).assert().success(); + memora() + .args(["promote"]) + .current_dir(path) + .assert() + .failure() + .stderr(predicate::str::contains("specify exactly one")); +} + +#[test] +fn diff_against_working_set_picks_up_uncommitted_changes() { + let tmp = tempdir().unwrap(); + let path = tmp.path(); + memora().arg("init").current_dir(path).assert().success(); + memora() + .args(["add", "--type", "project", "--content", "v1", "--source", "code-read"]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["commit", "-m", "first"]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["add", "--type", "project", "--content", "v2", "--source", "code-read"]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["diff", "HEAD", "--working"]) + .current_dir(path) + .assert() + .success() + .stdout(predicate::str::contains("Added:")) + .stdout(predicate::str::contains("v2")); +} diff --git a/crates/memora-core/src/commit.rs b/crates/memora-core/src/commit.rs index 8d3e353..01365ce 100644 --- a/crates/memora-core/src/commit.rs +++ b/crates/memora-core/src/commit.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::hash::sha256_hex; +use crate::node::MemoryNode; /// Aggregated counts for the changes a commit introduces, used by /// `memora commit` and `memora log` for friendly output. @@ -63,6 +64,42 @@ pub fn tree_id_for(node_ids: &[String]) -> String { sha256_hex(canonical.as_bytes()) } +/// Compute a tree id over the *full state* of a set of nodes, not just +/// their ids. This means a status flip (e.g. `ephemeral` → `stable`) on +/// the same node id produces a different tree, which is what we want for +/// `memora commit` to detect "something actually changed". +/// +/// Each node contributes the line `\t`, where the state +/// digest hashes the fields that participate in equality: +/// `kind | status | confidence (3dp) | content | source | evidence`. +pub fn tree_id_for_nodes(nodes: &[MemoryNode]) -> String { + let mut entries: Vec<(String, String)> = nodes + .iter() + .map(|n| (n.id.clone(), node_state_digest(n))) + .collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let canonical: String = entries + .into_iter() + .map(|(id, st)| format!("{id}\t{st}")) + .collect::>() + .join("\n"); + sha256_hex(canonical.as_bytes()) +} + +/// SHA-256 of the comparable fields of a node. Used inside `tree_id_for_nodes`. +fn node_state_digest(n: &MemoryNode) -> String { + let canonical = format!( + "kind:{}\nstatus:{}\nconf:{:.3}\ncontent:{}\nsource:{}\nevidence:{}", + n.kind.as_str(), + n.status.as_str(), + n.confidence, + n.content, + n.source.as_str(), + n.evidence.as_deref().unwrap_or(""), + ); + sha256_hex(canonical.as_bytes()) +} + /// Compute a commit id from its component fields. Pure function — exposed /// so tests can build commits without going through the store. pub fn commit_id( @@ -110,4 +147,17 @@ mod tests { let b = commit_id(Some("deadbeef"), &t, "human", "init", 0); assert_ne!(a, b); } + + #[test] + fn tree_id_for_nodes_reflects_status_changes() { + use crate::node::{MemoryKind, MemorySource, NewNode}; + let mut node = MemoryNode::from_new( + NewNode::new(MemoryKind::Assumption, "x", MemorySource::ModelInference), + 42, + ); + let before = tree_id_for_nodes(std::slice::from_ref(&node)); + node.status = crate::node::MemoryStatus::Stable; + let after = tree_id_for_nodes(std::slice::from_ref(&node)); + assert_ne!(before, after, "status change must alter tree id"); + } } diff --git a/crates/memora-core/src/lib.rs b/crates/memora-core/src/lib.rs index a797d87..c5937c1 100644 --- a/crates/memora-core/src/lib.rs +++ b/crates/memora-core/src/lib.rs @@ -39,7 +39,7 @@ pub mod time; pub use commit::{CommitStats, MemoryCommit}; pub use error::{MemoraError, Result}; pub use node::{MemoryKind, MemoryNode, MemorySource, MemoryStatus}; -pub use repo::Repository; +pub use repo::{DiffReport, ModifiedNode, NodeChange, PromotePlan, Repository}; /// On-disk format version written into `.memora/config`. Bumped whenever the /// schema or directory layout changes in a non backwards-compatible way. diff --git a/crates/memora-core/src/repo.rs b/crates/memora-core/src/repo.rs index 36b2c7f..9ce2702 100644 --- a/crates/memora-core/src/repo.rs +++ b/crates/memora-core/src/repo.rs @@ -8,9 +8,9 @@ use std::fs; use std::path::{Path, PathBuf}; -use crate::commit::{commit_id, tree_id_for, CommitStats, MemoryCommit}; +use crate::commit::{commit_id, tree_id_for_nodes, CommitStats, MemoryCommit}; use crate::error::{MemoraError, Result}; -use crate::node::{MemoryNode, MemoryStatus, NewNode}; +use crate::node::{MemoryKind, MemoryNode, MemoryStatus, NewNode}; use crate::store::{HeadRef, Refs, Store, UnstagedSummary}; use crate::time::{Clock, SystemClock}; use crate::{DEFAULT_BRANCH, FORMAT_VERSION, STORE_DIR}; @@ -182,18 +182,22 @@ impl Repository { pub fn commit(&self, message: &str, author: &str) -> Result { let head_ref = self.refs.read_head()?; let parent = self.head_commit_id()?; - let parent_node_ids: Vec = match parent.as_deref() { - Some(p) => self.store.commit_node_ids(p)?, - None => Vec::new(), + + // Pull the parent's full per-node snapshot so we can compute proper + // promotion / modification / removal stats. + let parent_versions: std::collections::HashMap = match parent.as_deref() + { + Some(p) => self + .store + .commit_node_versions(p)? + .into_iter() + .map(|n| (n.id.clone(), n)) + .collect(), + None => std::collections::HashMap::new(), }; - let parent_set: std::collections::HashSet = - parent_node_ids.iter().cloned().collect(); let nodes = self.store.all_nodes()?; - let mut node_ids: Vec = nodes.iter().map(|n| n.id.clone()).collect(); - node_ids.sort(); - let current_set: std::collections::HashSet = node_ids.iter().cloned().collect(); - let tree = tree_id_for(&node_ids); + let tree = tree_id_for_nodes(&nodes); // Detect "nothing to commit": same tree id as parent. let parent_tree = match parent.as_deref() { @@ -202,7 +206,7 @@ impl Repository { .get_commit(p)? .map(|c| c.tree_id) .unwrap_or_default(), - None => tree_id_for(&[]), + None => tree_id_for_nodes(&[]), }; if parent.is_some() && tree == parent_tree { return Ok(CommitOutcome { @@ -211,27 +215,32 @@ impl Repository { }); } - // Compute stats relative to the parent. - let parent_ts = match parent.as_deref() { - Some(p) => self.store.get_commit(p)?.map(|c| c.timestamp).unwrap_or(0), - None => 0, - }; let mut stats = CommitStats::default(); + let mut current_ids = std::collections::HashSet::new(); for node in &nodes { - let is_new = !parent_set.contains(&node.id); - if is_new { - stats.added += 1; - } else if node.updated_at > parent_ts { - stats.modified += 1; - } - if node.status == MemoryStatus::Stable && node.updated_at > parent_ts { - stats.promoted += 1; + current_ids.insert(node.id.clone()); + match parent_versions.get(&node.id) { + None => stats.added += 1, + Some(prev) => { + if state_differs(prev, node) { + stats.modified += 1; + } + if prev.status != MemoryStatus::Stable && node.status == MemoryStatus::Stable { + stats.promoted += 1; + } + if prev.status != MemoryStatus::Conflicted + && node.status == MemoryStatus::Conflicted + { + stats.conflicted += 1; + } + } } - if node.status == MemoryStatus::Conflicted && node.updated_at > parent_ts { - stats.conflicted += 1; + } + for id in parent_versions.keys() { + if !current_ids.contains(id) { + stats.removed += 1; } } - stats.removed = parent_set.difference(¤t_set).count() as u32; let now = self.clock.now(); let id = commit_id(parent.as_deref(), &tree, author, message, now); @@ -245,12 +254,14 @@ impl Repository { stats, }; self.store.insert_commit(&commit)?; + let mut node_ids: Vec = nodes.iter().map(|n| n.id.clone()).collect(); + node_ids.sort(); self.store.insert_commit_nodes(&id, &node_ids)?; + self.store.insert_node_versions(&id, &nodes)?; let branch_name = head_ref.branch().map(str::to_string); match &head_ref { HeadRef::Branch(name) => { - // Make sure the branch ref exists (it always should after init). if !self.refs.branch_path(name).exists() { self.refs.create_branch(name, Some(&id))?; } else { @@ -328,6 +339,360 @@ impl Repository { } Ok(target) } + + // --- promotion ------------------------------------------------------- + + /// Promote one or more `ephemeral` nodes to `stable`. Returns the + /// list of node ids that were actually promoted. Idempotent: nodes + /// that are already `stable` are skipped without error. + pub fn promote(&self, plan: PromotePlan) -> Result> { + let candidate_ids: Vec = match plan { + PromotePlan::Ids(ids) => { + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + let node = self + .store + .get_node(&id)? + .ok_or_else(|| MemoraError::NodeNotFound(id.clone()))?; + if node.status == MemoryStatus::Ephemeral { + out.push(id); + } + } + out + } + PromotePlan::Kind(kind) => self.store.find_promotion_candidates(Some(kind), None)?, + PromotePlan::AllConfirmed { min_confidence } => self + .store + .find_promotion_candidates(None, Some(min_confidence.clamp(0.0, 1.0)))?, + }; + let now = self.clock.now(); + for id in &candidate_ids { + self.store.set_status(id, MemoryStatus::Stable, now)?; + } + Ok(candidate_ids) + } + + // --- diff ------------------------------------------------------------ + + /// Compute a [`DiffReport`] between two commits (or a commit and the + /// working set). `from` and `to` are revspecs supported by + /// [`Self::resolve_revision`]. `to == None` means the working set. + pub fn diff(&self, from: &str, to: Option<&str>) -> Result { + let from_id = self.resolve_revision(from)?; + let from_nodes: Vec = self.store.commit_node_versions(&from_id)?; + let to_label: String; + let to_nodes: Vec = match to { + Some(rev) => { + let to_id = self.resolve_revision(rev)?; + to_label = to_id.clone(); + self.store.commit_node_versions(&to_id)? + } + None => { + to_label = "(working set)".to_string(); + self.store.all_nodes()? + } + }; + Ok(DiffReport::compute(from_id, to_label, &from_nodes, &to_nodes)) + } + + // --- revision parsing ------------------------------------------------ + + /// Resolve a revision spec to a full commit id. Supported forms: + /// - full or short commit id (>=4 hex chars) + /// - branch name + /// - `HEAD`, `HEAD~`, `HEAD~N` + pub fn resolve_revision(&self, rev: &str) -> Result { + let rev = rev.trim(); + if rev.is_empty() { + return Err(MemoraError::Invalid("empty revision".into())); + } + + if rev == "HEAD" { + return self + .head_commit_id()? + .ok_or_else(|| MemoraError::CommitNotFound("HEAD".into())); + } + if let Some(rest) = rev.strip_prefix("HEAD") { + if let Some(n) = parse_tilde(rest) { + let head = self + .head_commit_id()? + .ok_or_else(|| MemoraError::CommitNotFound("HEAD".into()))?; + return self.nth_ancestor(&head, n); + } + } + + if rev.chars().all(|c| c.is_ascii_hexdigit()) && rev.len() >= 4 { + if let Ok(id) = self.store.resolve_commit_prefix(rev) { + return Ok(id); + } + } + + if self.refs.branch_path(rev).exists() { + return self + .refs + .read_branch(rev)? + .ok_or_else(|| MemoraError::CommitNotFound(rev.to_string())); + } + + Err(MemoraError::CommitNotFound(rev.to_string())) + } + + fn nth_ancestor(&self, commit_id: &str, n: usize) -> Result { + let mut current = commit_id.to_string(); + for step in 0..n { + let c = self + .store + .get_commit(¤t)? + .ok_or_else(|| MemoraError::CommitNotFound(current.clone()))?; + match c.parent { + Some(p) => current = p, + None => { + return Err(MemoraError::Invalid(format!( + "revision walks past root commit (only {step} ancestors available)" + ))); + } + } + } + Ok(current) + } +} + +// --------------------------------------------------------------------------- +// Free helpers + supporting types used by promote/diff above. +// --------------------------------------------------------------------------- + +/// Caller intent for [`Repository::promote`]. +#[derive(Debug, Clone)] +pub enum PromotePlan { + /// Promote a specific list of node ids. + Ids(Vec), + /// Promote every ephemeral node of a given kind. + Kind(MemoryKind), + /// Promote every ephemeral node whose confidence is at least + /// `min_confidence`. + AllConfirmed { + /// Confidence floor (clamped into `[0.0, 1.0]`). + min_confidence: f32, + }, +} + +/// Detail-level shape of a node change between two snapshots. +#[derive(Debug, Clone, PartialEq)] +pub enum NodeChange { + /// Memory status flipped (e.g. Ephemeral → Stable). + Status { + /// Previous status. + from: MemoryStatus, + /// New status. + to: MemoryStatus, + }, + /// Free-form content was edited. + Content, + /// Confidence value changed by more than 0.001. + Confidence { + /// Previous confidence. + from: f32, + /// New confidence. + to: f32, + }, + /// Source / provenance changed. + Source, + /// Evidence pointer changed. + Evidence, +} + +/// One entry in a [`DiffReport`] for a node that exists in both snapshots +/// but with different state. +#[derive(Debug, Clone)] +pub struct ModifiedNode { + /// Snapshot of the node from the `from` side. + pub before: MemoryNode, + /// Snapshot of the node from the `to` side. + pub after: MemoryNode, + /// One or more concrete changes between the two. + pub changes: Vec, +} + +/// Result of [`Repository::diff`]. Friendly buckets the CLI can render. +#[derive(Debug, Clone)] +pub struct DiffReport { + /// Resolved id of the `from` side. + pub from_id: String, + /// Identifier of the `to` side (commit id, or `"(working set)"`). + pub to_label: String, + /// Nodes present in `to` but not in `from`. + pub added: Vec, + /// Nodes present in `from` but not in `to`. + pub removed: Vec, + /// Nodes present in both sides but with different state. + pub modified: Vec, +} + +impl DiffReport { + fn compute( + from_id: String, + to_label: String, + from_nodes: &[MemoryNode], + to_nodes: &[MemoryNode], + ) -> Self { + use std::collections::HashMap; + let from_map: HashMap<&str, &MemoryNode> = + from_nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + let to_map: HashMap<&str, &MemoryNode> = + to_nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + + let mut added = Vec::new(); + let mut modified = Vec::new(); + let mut removed = Vec::new(); + + for n in to_nodes { + match from_map.get(n.id.as_str()) { + None => added.push(n.clone()), + Some(prev) => { + let changes = diff_node(prev, n); + if !changes.is_empty() { + modified.push(ModifiedNode { + before: (*prev).clone(), + after: n.clone(), + changes, + }); + } + } + } + } + for n in from_nodes { + if !to_map.contains_key(n.id.as_str()) { + removed.push(n.clone()); + } + } + + added.sort_by(|a, b| a.id.cmp(&b.id)); + removed.sort_by(|a, b| a.id.cmp(&b.id)); + modified.sort_by(|a, b| a.after.id.cmp(&b.after.id)); + + DiffReport { + from_id, + to_label, + added, + removed, + modified, + } + } + + /// True if `added`, `removed`, and `modified` are all empty. + pub fn is_empty(&self) -> bool { + self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty() + } + + /// Render a short, human-readable summary line per change. Used by + /// `memora diff --semantic`. + pub fn semantic_lines(&self) -> Vec { + let mut out = Vec::new(); + for n in &self.added { + out.push(format!( + "+ [{}] new {} memory: {}", + n.kind, + n.status, + short(&n.content, 80), + )); + } + for m in &self.modified { + for ch in &m.changes { + let line = match ch { + NodeChange::Status { from, to } => format!( + "~ [{}] {} → {}: {}", + m.after.kind, + from, + to, + short(&m.after.content, 80), + ), + NodeChange::Content => format!( + "~ [{}] content updated: {}", + m.after.kind, + short(&m.after.content, 80), + ), + NodeChange::Confidence { from, to } => format!( + "~ [{}] confidence {:.2} → {:.2}: {}", + m.after.kind, + from, + to, + short(&m.after.content, 80), + ), + NodeChange::Source => format!( + "~ [{}] source changed: {}", + m.after.kind, + short(&m.after.content, 80), + ), + NodeChange::Evidence => format!( + "~ [{}] evidence updated: {}", + m.after.kind, + short(&m.after.content, 80), + ), + }; + out.push(line); + } + } + for n in &self.removed { + out.push(format!( + "- [{}] removed: {}", + n.kind, + short(&n.content, 80), + )); + } + out + } +} + +fn parse_tilde(rest: &str) -> Option { + if rest.is_empty() { + return None; + } + let stripped = rest.strip_prefix('~')?; + if stripped.is_empty() { + Some(1) + } else { + stripped.parse::().ok() + } +} + +fn state_differs(a: &MemoryNode, b: &MemoryNode) -> bool { + !diff_node(a, b).is_empty() +} + +fn diff_node(a: &MemoryNode, b: &MemoryNode) -> Vec { + let mut out = Vec::new(); + if a.status != b.status { + out.push(NodeChange::Status { + from: a.status, + to: b.status, + }); + } + if a.content != b.content { + out.push(NodeChange::Content); + } + if (a.confidence - b.confidence).abs() > 0.001 { + out.push(NodeChange::Confidence { + from: a.confidence, + to: b.confidence, + }); + } + if a.source != b.source { + out.push(NodeChange::Source); + } + if a.evidence != b.evidence { + out.push(NodeChange::Evidence); + } + out +} + +fn short(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut out: String = s.chars().take(max - 1).collect(); + out.push('…'); + out + } } #[cfg(test)] @@ -448,4 +813,153 @@ mod tests { assert_eq!(rolled.id, first.id); assert_eq!(repo.head_commit_id().unwrap().as_deref(), Some(first.id.as_str())); } + + #[test] + fn promote_by_id_marks_node_stable() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + let node = repo + .add_node(NewNode::new( + MemoryKind::Assumption, + "redis is the cache", + MemorySource::ModelInference, + )) + .unwrap(); + let promoted = repo.promote(PromotePlan::Ids(vec![node.id.clone()])).unwrap(); + assert_eq!(promoted, vec![node.id.clone()]); + let after = repo.store().get_node(&node.id).unwrap().unwrap(); + assert_eq!(after.status, MemoryStatus::Stable); + } + + #[test] + fn promote_by_kind_only_touches_matching_ephemeral_nodes() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + repo.add_node(NewNode::new( + MemoryKind::Assumption, + "a", + MemorySource::ModelInference, + )) + .unwrap(); + let other = repo + .add_node(NewNode::new( + MemoryKind::Project, + "p", + MemorySource::CodeRead, + )) + .unwrap(); + let promoted = repo.promote(PromotePlan::Kind(MemoryKind::Assumption)).unwrap(); + assert_eq!(promoted.len(), 1); + assert_eq!( + repo.store().get_node(&other.id).unwrap().unwrap().status, + MemoryStatus::Ephemeral + ); + } + + #[test] + fn promote_all_confirmed_respects_threshold() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + let high = repo + .add_node(NewNode::new( + MemoryKind::Project, + "high", + MemorySource::CodeRead, + )) + .unwrap(); + let low = repo + .add_node(NewNode { + confidence: Some(0.4), + ..NewNode::new( + MemoryKind::Assumption, + "low", + MemorySource::ModelInference, + ) + }) + .unwrap(); + let promoted = repo + .promote(PromotePlan::AllConfirmed { min_confidence: 0.8 }) + .unwrap(); + assert_eq!(promoted, vec![high.id.clone()]); + assert_eq!( + repo.store().get_node(&low.id).unwrap().unwrap().status, + MemoryStatus::Ephemeral + ); + } + + #[test] + fn promote_then_commit_records_promotion_stat() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + let n = repo + .add_node(NewNode::new( + MemoryKind::Assumption, + "x", + MemorySource::ModelInference, + )) + .unwrap(); + repo.commit("first", "human").unwrap(); + repo.promote(PromotePlan::Ids(vec![n.id.clone()])).unwrap(); + let c = repo.commit("promote it", "human").unwrap().commit.unwrap(); + assert_eq!(c.stats.promoted, 1); + assert_eq!(c.stats.modified, 1); + assert_eq!(c.stats.added, 0); + } + + #[test] + fn diff_between_commits_reports_added_and_promoted() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + let n = repo + .add_node(NewNode::new( + MemoryKind::Assumption, + "redis is the cache", + MemorySource::ModelInference, + )) + .unwrap(); + let c1 = repo.commit("first", "human").unwrap().commit.unwrap(); + repo.promote(PromotePlan::Ids(vec![n.id.clone()])).unwrap(); + repo.add_node(NewNode::new( + MemoryKind::Project, + "rust workspace", + MemorySource::CodeRead, + )) + .unwrap(); + let c2 = repo.commit("promote and add", "human").unwrap().commit.unwrap(); + + let diff = repo.diff(&c1.id, Some(&c2.id)).unwrap(); + assert_eq!(diff.added.len(), 1); + assert_eq!(diff.modified.len(), 1); + assert!(matches!( + diff.modified[0].changes[0], + NodeChange::Status { + from: MemoryStatus::Ephemeral, + to: MemoryStatus::Stable + } + )); + } + + #[test] + fn diff_understands_head_tilde() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + repo.add_node(NewNode::new( + MemoryKind::Project, + "v1", + MemorySource::CodeRead, + )) + .unwrap(); + let _c1 = repo.commit("c1", "human").unwrap(); + repo.add_node(NewNode::new( + MemoryKind::Project, + "v2", + MemorySource::CodeRead, + )) + .unwrap(); + let _c2 = repo.commit("c2", "human").unwrap(); + let diff = repo.diff("HEAD~1", Some("HEAD")).unwrap(); + assert_eq!(diff.added.len(), 1); + assert_eq!(diff.modified.len(), 0); + assert_eq!(diff.removed.len(), 0); + } } diff --git a/crates/memora-core/src/store/db.rs b/crates/memora-core/src/store/db.rs index 5f7c49d..99bc748 100644 --- a/crates/memora-core/src/store/db.rs +++ b/crates/memora-core/src/store/db.rs @@ -160,6 +160,46 @@ impl Store { Ok(count) } + /// Find candidate node ids for `memora promote`. The caller decides + /// what to do with the result; this just runs the SQL filter. + pub fn find_promotion_candidates( + &self, + kind: Option, + min_confidence: Option, + ) -> Result> { + let mut sql = String::from( + "SELECT id FROM nodes WHERE status = 'ephemeral'", + ); + let mut bindings: Vec = Vec::new(); + if let Some(k) = kind { + sql.push_str(" AND kind = ?"); + bindings.push(k.as_str().to_string().into()); + } + if let Some(min) = min_confidence { + sql.push_str(" AND confidence >= ?"); + bindings.push((min as f64).into()); + } + let mut stmt = self.conn.prepare(&sql)?; + let mut rows = stmt.query(rusqlite::params_from_iter(bindings.iter()))?; + let mut out = Vec::new(); + while let Some(row) = rows.next()? { + out.push(row.get::<_, String>(0)?); + } + Ok(out) + } + + /// Update a node's `status` and `updated_at` in-place. + pub fn set_status(&self, id: &str, status: MemoryStatus, now: i64) -> Result<()> { + let changed = self.conn.execute( + "UPDATE nodes SET status = ?1, updated_at = ?2 WHERE id = ?3", + params![status.as_str(), now, id], + )?; + if changed == 0 { + return Err(MemoraError::NodeNotFound(id.to_string())); + } + Ok(()) + } + // --- commit CRUD ------------------------------------------------------ /// Persist a commit row. @@ -195,6 +235,57 @@ impl Store { Ok(()) } + /// Persist the full per-node state snapshot for a commit. Called once + /// per commit alongside [`Self::insert_commit_nodes`]. + pub fn insert_node_versions(&self, commit_id: &str, nodes: &[MemoryNode]) -> Result<()> { + let tx = self.conn.unchecked_transaction()?; + { + let mut stmt = tx.prepare( + "INSERT OR REPLACE INTO node_versions ( + commit_id, node_id, kind, content, confidence, status, source, + evidence, tags_json, related_to_json, + created_at, updated_at, expires_at + ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)", + )?; + for n in nodes { + let tags = serde_json::to_string(&n.tags)?; + let related = serde_json::to_string(&n.related_to)?; + stmt.execute(params![ + commit_id, + n.id, + n.kind.as_str(), + n.content, + n.confidence as f64, + n.status.as_str(), + n.source.as_str(), + n.evidence, + tags, + related, + n.created_at, + n.updated_at, + n.expires_at, + ])?; + } + } + tx.commit()?; + Ok(()) + } + + /// Read the per-node snapshot rows for a commit. + pub fn commit_node_versions(&self, commit_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT node_id, kind, content, confidence, status, source, evidence, + tags_json, related_to_json, created_at, updated_at, expires_at + FROM node_versions WHERE commit_id = ?1", + )?; + let rows = stmt.query_map(params![commit_id], row_to_versioned_node)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + /// Fetch a commit by id. pub fn get_commit(&self, id: &str) -> Result> { let mut stmt = self.conn.prepare( @@ -349,6 +440,40 @@ fn row_to_commit(row: &rusqlite::Row<'_>) -> rusqlite::Result { }) } +fn row_to_versioned_node(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let kind_str: String = row.get(1)?; + let status_str: String = row.get(4)?; + let source_str: String = row.get(5)?; + let tags_json: String = row.get(7)?; + let related_json: String = row.get(8)?; + + let kind = MemoryKind::from_str(&kind_str).map_err(to_sqlite_err)?; + let status = MemoryStatus::from_str(&status_str).map_err(to_sqlite_err)?; + let source = MemorySource::from_str(&source_str).map_err(to_sqlite_err)?; + let tags: Vec = serde_json::from_str(&tags_json).map_err(serde_to_sqlite_err)?; + let related: Vec = + serde_json::from_str(&related_json).map_err(serde_to_sqlite_err)?; + let updated_at: i64 = row.get(10)?; + + Ok(MemoryNode { + id: row.get(0)?, + kind, + content: row.get(2)?, + confidence: row.get::<_, f64>(3)? as f32, + status, + source, + evidence: row.get(6)?, + tags, + related_to: related, + created_at: row.get(9)?, + updated_at, + // node_versions doesn't track read counters — reconstruct sane defaults. + last_accessed: updated_at, + access_count: 0, + expires_at: row.get(11)?, + }) +} + fn to_sqlite_err(err: MemoraError) -> rusqlite::Error { rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(err)) } diff --git a/crates/memora-core/src/store/schema.sql b/crates/memora-core/src/store/schema.sql index f82803f..5417ad0 100644 --- a/crates/memora-core/src/store/schema.sql +++ b/crates/memora-core/src/store/schema.sql @@ -48,6 +48,28 @@ CREATE TABLE IF NOT EXISTS commit_nodes ( CREATE INDEX IF NOT EXISTS idx_commit_nodes_node ON commit_nodes (node_id); +-- Per-commit snapshot of each node's full state. Lets us diff two commits +-- and see what changed inside a node (status, content, confidence, …), +-- not just which ids appeared or disappeared. +CREATE TABLE IF NOT EXISTS node_versions ( + commit_id TEXT NOT NULL REFERENCES commits(id) ON DELETE CASCADE, + node_id TEXT NOT NULL, + kind TEXT NOT NULL, + content TEXT NOT NULL, + confidence REAL NOT NULL, + status TEXT NOT NULL, + source TEXT NOT NULL, + evidence TEXT, + tags_json TEXT NOT NULL DEFAULT '[]', + related_to_json TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (commit_id, node_id) +); + +CREATE INDEX IF NOT EXISTS idx_node_versions_node ON node_versions (node_id); + -- Replay infrastructure. Sessions and their event streams are written here -- so future `memora replay` can step through context evolution. CREATE TABLE IF NOT EXISTS sessions ( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8cb22f7..9c2dd6b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -70,6 +70,15 @@ timestamps deterministically. - `add_node`, `status`, `commit`, `log` - `create_branch`, `list_branches`, `switch_branch` - `rollback_to` (auto-checkpoints before moving HEAD) +- `promote` (ephemeral → stable, by id / kind / confidence threshold) +- `diff` (graph diff between two revspecs, with optional semantic summary) +- `resolve_revision` (`HEAD`, `HEAD~N`, branch names, hex prefixes) + +The diff engine compares two `node_versions` snapshots from the SQLite +store and produces a `DiffReport` with `added` / `removed` / `modified` +buckets. `ModifiedNode` carries a list of typed `NodeChange` deltas +(`Status`, `Content`, `Confidence`, `Source`, `Evidence`) so callers can +render high-level summaries without parsing strings. ## Crate: `memora-cli` @@ -100,15 +109,14 @@ Centralised printing helpers (timestamps, short ids, error formatter). ## What's not built yet -The roadmap in `README.md` calls out Phase 2 → Phase 5. Notable gaps: +The roadmap in `README.md` calls out Phase 3 → Phase 5. Notable gaps: -- Semantic graph diff (`memora diff --semantic`). -- Promotion command (`memora promote`). - CRDT merge (`memora merge`). - Replay (`memora replay`, session event recording). - Export / import adapters (`memora export --to=claude-code`, etc.). - GC + remote sync. The internal types and SQLite tables already make room for these (see -`sessions` / `session_events`, `MemoryStatus::Conflicted`, -`commit_nodes`); we'll layer the workflows on top in subsequent commits. +`sessions` / `session_events`, `MemoryStatus::Conflicted`, the per-commit +`node_versions` snapshot table); we'll layer the workflows on top in +subsequent commits.