diff --git a/README.md b/README.md index 3ad44a7..5402e53 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,12 @@ Ephemeral ──promote──▶ Stable ──gc──▶ Deprecated | `memora promote --id \| --type \| --all-confirmed [T]` | Promote ephemeral nodes to stable. | | `memora diff [FROM] [TO] [--working] [--semantic]` | Show belief changes between two revisions. | | `memora merge BRANCH [--strategy auto\|ours\|theirs] [--no-ff] [--no-commit] [--dry-run]` | Three-way merge another branch into HEAD. | +| `memora session start \| end \| current \| list` | Bracket a tool's run so events are recorded for replay. | +| `memora replay [--session ID] [--step]` | Walk through a recorded session's event stream. | +| `memora export --to [...]` | Render the working set to `claude-code`, `cursor`, `cline`, `openai-assistant`, or `json`. | -Future phases add `replay`, `export`, `import`, `gc`, `push`, `pull`. See -`SPEC.md` for the full roadmap. +Future phases add `import`, `gc`, `push`, `pull`. See `SPEC.md` for the +full roadmap. --- diff --git a/SPEC.md b/SPEC.md index b806662..6e9945e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -151,6 +151,31 @@ keeps working. Any additional parents live in `merge_parents`, ordered by `sequence`. The full parent set of a commit is therefore `{commits.parent_id} ∪ {merge_parents.parent_id WHERE commit_id = …}`. +### Session marker + +`.memora/sessions/CURRENT` is a single-line plain text file that, when +present and non-empty, names the active recording session. While it +exists, every `add_node`, `commit`, `promote`, and `merge` operation +appends a row to `session_events` (kind `node_added`, `commit_created`, +`node_promoted`, or `merge_completed`) keyed by the active id. A session +without an active marker is closed; replay reads its rows in append +order. + +### `session_events.event_type` canonical values + +| Value | When | +| ------------------ | ------------------------------------------ | +| `session_started` | First event of every session. | +| `session_ended` | Last event when the session is closed. | +| `node_added` | A new node was added via `Repository::add_node`. | +| `node_promoted` | One or more nodes promoted (ephemeral → stable). | +| `commit_created` | A commit (regular or merge) was recorded. | +| `merge_completed` | `Repository::merge` returned an outcome. | + +`data_json` carries free-form JSON whose shape is documented in +`crates/memora-core/src/repo.rs`. Tools should ignore unknown keys for +forward compatibility. + `PRAGMA foreign_keys = ON` and `PRAGMA journal_mode = WAL` are required for correctness and concurrency. diff --git a/crates/memora-cli/Cargo.toml b/crates/memora-cli/Cargo.toml index d7d34b8..2e8e402 100644 --- a/crates/memora-cli/Cargo.toml +++ b/crates/memora-cli/Cargo.toml @@ -25,3 +25,4 @@ chrono.workspace = true tempfile = "3" assert_cmd = "2" predicates = "3" +serde_json.workspace = true diff --git a/crates/memora-cli/src/cli.rs b/crates/memora-cli/src/cli.rs index 28d6fe1..85b7fba 100644 --- a/crates/memora-cli/src/cli.rs +++ b/crates/memora-cli/src/cli.rs @@ -56,6 +56,15 @@ pub enum Command { /// Merge another branch (or commit) into HEAD. Merge(MergeArgs), + + /// Manage recording sessions (`start`, `end`, `current`, `list`). + Session(SessionArgs), + + /// Replay a recorded session as a step-through event log. + Replay(ReplayArgs), + + /// Export the working set into another tool's format. + Export(ExportArgs), } /// Arguments for `memora init`. @@ -254,3 +263,87 @@ impl From for memora_core::MergeStrategy { } } } + +/// Arguments for `memora session`. +#[derive(Debug, clap::Args)] +pub struct SessionArgs { + #[command(subcommand)] + pub command: SessionCommand, +} + +/// `memora session` subcommands. +#[derive(Debug, clap::Subcommand)] +pub enum SessionCommand { + /// Start a new recording session. + Start(SessionStartArgs), + /// End the active session. + End, + /// Print the active session id, if any. + Current, + /// List recent sessions. + List(SessionListArgs), +} + +/// Arguments for `memora session start`. +#[derive(Debug, clap::Args)] +pub struct SessionStartArgs { + /// Tool / actor that owns this session. Free-form string, but + /// `claude_code`, `cursor`, `cline`, `openhands`, `manual` are the + /// canonical values. + #[arg(long, default_value = "manual")] + pub source: String, +} + +/// Arguments for `memora session list`. +#[derive(Debug, clap::Args)] +pub struct SessionListArgs { + /// Limit to the N most recent sessions. + #[arg(short = 'n', long)] + pub limit: Option, +} + +/// Arguments for `memora replay`. +#[derive(Debug, clap::Args)] +pub struct ReplayArgs { + /// Session id (full or short prefix). Defaults to the active session. + #[arg(long = "session", value_name = "ID")] + pub session: Option, + + /// Pause after each event and wait for Enter before continuing. + #[arg(long)] + pub step: bool, +} + +/// Arguments for `memora export`. +#[derive(Debug, clap::Args)] +pub struct ExportArgs { + /// Target format: `claude-code`, `cursor`, `cline`, `openai-assistant`, `json`. + #[arg(long = "to", value_name = "FORMAT")] + pub to: String, + + /// Write to this file instead of stdout. When absent, the format's + /// conventional filename is used (e.g. `CLAUDE.md`). + #[arg(long, short = 'o', value_name = "PATH")] + pub output: Option, + + /// Print to stdout instead of writing a file. + #[arg(long)] + pub stdout: bool, + + /// Keep at most N nodes (after ranking by importance). + #[arg(long, value_name = "N")] + pub top: Option, + + /// Restrict to one memory kind. Repeatable. + #[arg(long = "kind", value_name = "KIND")] + pub kinds: Vec, + + /// Restrict to one status. Repeatable. Default behaviour drops + /// `deprecated`. + #[arg(long = "status", value_name = "STATUS")] + pub statuses: Vec, + + /// Drop nodes whose confidence is below this threshold. + #[arg(long, value_name = "T")] + pub min_confidence: Option, +} diff --git a/crates/memora-cli/src/commands/export.rs b/crates/memora-cli/src/commands/export.rs new file mode 100644 index 0000000..21d9285 --- /dev/null +++ b/crates/memora-cli/src/commands/export.rs @@ -0,0 +1,66 @@ +//! `memora export` — render the working set into another tool's format. + +use std::env; +use std::fs; +use std::str::FromStr; + +use anyhow::{Context, Result}; + +use memora_core::node::{MemoryKind, MemoryStatus}; +use memora_core::{ExportFilter, ExportFormat, ImportanceWeights, Repository}; + +use crate::cli::ExportArgs; +use crate::ui::{bold, dim, green}; + +/// Entry point for the `export` subcommand. +pub fn run(args: ExportArgs) -> Result<()> { + let format = ExportFormat::parse(&args.to) + .with_context(|| format!("unknown export format '{}'", args.to))?; + + let mut filter = ExportFilter { + weights: ImportanceWeights::default(), + top: args.top, + kinds: Vec::new(), + statuses: Vec::new(), + min_confidence: args.min_confidence, + }; + for k in &args.kinds { + filter + .kinds + .push(MemoryKind::from_str(k).with_context(|| format!("invalid --kind '{k}'"))?); + } + for s in &args.statuses { + filter + .statuses + .push(MemoryStatus::from_str(s).with_context(|| format!("invalid --status '{s}'"))?); + } + if let Some(c) = filter.min_confidence { + anyhow::ensure!( + (0.0..=1.0).contains(&c), + "--min-confidence must be between 0.0 and 1.0 (got {c})" + ); + } + + let cwd = env::current_dir()?; + let repo = Repository::open_from(&cwd)?; + let body = repo.export(format, filter)?; + + if args.stdout { + print!("{body}"); + return Ok(()); + } + + let path = match args.output { + Some(p) => p, + None => cwd.join(format.default_filename()), + }; + fs::write(&path, &body).with_context(|| format!("writing {}", path.display()))?; + println!( + "{} {} ({} bytes) → {}", + bold(green("Exported")), + args.to, + body.len(), + dim(path.display().to_string()) + ); + Ok(()) +} diff --git a/crates/memora-cli/src/commands/mod.rs b/crates/memora-cli/src/commands/mod.rs index 10c7e55..440a0e5 100644 --- a/crates/memora-cli/src/commands/mod.rs +++ b/crates/memora-cli/src/commands/mod.rs @@ -12,11 +12,14 @@ mod add; mod branch; mod commit; mod diff; +mod export; mod init; mod log; mod merge; mod promote; +mod replay; mod rollback; +mod session; mod status; mod switch; @@ -34,5 +37,8 @@ pub fn dispatch(cli: Cli) -> Result<()> { Command::Promote(args) => promote::run(args), Command::Diff(args) => diff::run(args), Command::Merge(args) => merge::run(args), + Command::Session(args) => session::run(args), + Command::Replay(args) => replay::run(args), + Command::Export(args) => export::run(args), } } diff --git a/crates/memora-cli/src/commands/replay.rs b/crates/memora-cli/src/commands/replay.rs new file mode 100644 index 0000000..1997351 --- /dev/null +++ b/crates/memora-cli/src/commands/replay.rs @@ -0,0 +1,144 @@ +//! `memora replay` — walk through a session's recorded events. + +use std::env; +use std::io::{self, BufRead}; + +use anyhow::Result; + +use memora_core::session::SessionEventKind; +use memora_core::Repository; + +use crate::cli::ReplayArgs; +use crate::ui::{bold, cyan, dim, fmt_timestamp, green, red, short_id, yellow}; + +/// Entry point for the `replay` subcommand. +pub fn run(args: ReplayArgs) -> Result<()> { + let cwd = env::current_dir()?; + let repo = Repository::open_from(&cwd)?; + + let session_id = match args.session { + Some(id) => id, + None => match repo.current_session_id()? { + Some(id) => id, + None => { + // Fall back to the most recent session. + let recent = repo.list_sessions(Some(1))?; + recent + .into_iter() + .next() + .map(|s| s.id) + .ok_or_else(|| anyhow::anyhow!( + "no session id given and no sessions recorded yet; run `memora session start` first" + ))? + } + }, + }; + + let events = repo.session_events(&session_id)?; + if events.is_empty() { + println!("{}", dim("session has no recorded events.")); + return Ok(()); + } + let resolved_id = events.first().unwrap().session_id.clone(); + println!( + "{} session {} ({} events)", + bold("replay"), + bold(short_id(&resolved_id)), + events.len(), + ); + println!(); + + let stdin = io::stdin(); + let mut lock = stdin.lock(); + let mut buf = String::new(); + + for (idx, event) in events.iter().enumerate() { + let header_color = match event.kind { + SessionEventKind::SessionStarted => green("▶ session_started"), + SessionEventKind::SessionEnded => yellow("■ session_ended"), + SessionEventKind::NodeAdded => green("+ node_added"), + SessionEventKind::NodePromoted => cyan("⇧ node_promoted"), + SessionEventKind::CommitCreated => bold("● commit_created"), + SessionEventKind::MergeCompleted => red("⇆ merge_completed"), + }; + println!( + "[{:>3}] {} {} {}", + idx + 1, + dim(fmt_timestamp(event.timestamp)), + header_color, + describe_event(event), + ); + + if args.step && idx + 1 < events.len() { + buf.clear(); + // Read one line; if stdin is closed (e.g. piped, no more input) + // just continue rather than exiting noisily. + if lock.read_line(&mut buf).is_err() { + break; + } + } + } + Ok(()) +} + +fn describe_event(event: &memora_core::SessionEvent) -> String { + let data = &event.data; + match event.kind { + SessionEventKind::SessionStarted => { + let source = data.get("source").and_then(|v| v.as_str()).unwrap_or("?"); + format!("source = {source}") + } + SessionEventKind::SessionEnded => "session closed".to_string(), + SessionEventKind::NodeAdded => { + let kind = data.get("kind").and_then(|v| v.as_str()).unwrap_or("?"); + let id = data.get("node_id").and_then(|v| v.as_str()).unwrap_or("?"); + let content = data + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or(""); + format!("[{}] {} {}", kind, short_id(id), truncate(content, 80)) + } + SessionEventKind::NodePromoted => { + let ids = data + .get("node_ids") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| short_id(s).to_string())) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + format!("ephemeral → stable [{ids}]") + } + SessionEventKind::CommitCreated => { + let id = data + .get("commit_id") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let msg = data.get("message").and_then(|v| v.as_str()).unwrap_or(""); + format!("{} {}", short_id(id), truncate(msg, 80)) + } + SessionEventKind::MergeCompleted => { + let kind = data.get("kind").and_then(|v| v.as_str()).unwrap_or("?"); + let theirs = data.get("theirs").and_then(|v| v.as_str()).unwrap_or(""); + let ours = data.get("ours").and_then(|v| v.as_str()).unwrap_or(""); + format!( + "{} {} ← {}", + kind, + short_id(ours), + short_id(theirs) + ) + } + } +} + +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/session.rs b/crates/memora-cli/src/commands/session.rs new file mode 100644 index 0000000..278cdac --- /dev/null +++ b/crates/memora-cli/src/commands/session.rs @@ -0,0 +1,92 @@ +//! `memora session` — start, end, query recording sessions. + +use std::env; + +use anyhow::Result; + +use memora_core::Repository; + +use crate::cli::{SessionArgs, SessionCommand, SessionListArgs, SessionStartArgs}; +use crate::ui::{bold, dim, green, short_id, yellow}; + +/// Entry point for the `session` subcommand. +pub fn run(args: SessionArgs) -> Result<()> { + let cwd = env::current_dir()?; + let repo = Repository::open_from(&cwd)?; + match args.command { + SessionCommand::Start(a) => start(&repo, a), + SessionCommand::End => end(&repo), + SessionCommand::Current => current(&repo), + SessionCommand::List(a) => list(&repo, a), + } +} + +fn start(repo: &Repository, args: SessionStartArgs) -> Result<()> { + let session = repo.start_session(&args.source)?; + println!( + "{} session {} (source: {})", + bold(green("Started")), + bold(short_id(&session.id)), + session.source, + ); + println!( + "{}", + dim("subsequent add / commit / promote / merge will be recorded; run `memora session end` when done") + ); + Ok(()) +} + +fn end(repo: &Repository) -> Result<()> { + match repo.end_session()? { + None => println!("{}", dim("no active session.")), + Some(s) => { + println!( + "{} session {} ({} events)", + bold(yellow("Ended")), + bold(short_id(&s.id)), + s.event_count + ); + } + } + Ok(()) +} + +fn current(repo: &Repository) -> Result<()> { + match repo.current_session_id()? { + None => println!("{}", dim("no active session.")), + Some(id) => { + let session = repo.store().get_session(&id)?; + match session { + None => println!("{id}"), + Some(s) => println!( + "{} ({} events, source {})", + bold(short_id(&s.id)), + s.event_count, + s.source + ), + } + } + } + Ok(()) +} + +fn list(repo: &Repository, args: SessionListArgs) -> Result<()> { + let sessions = repo.list_sessions(args.limit)?; + if sessions.is_empty() { + println!("{}", dim("no sessions recorded yet.")); + return Ok(()); + } + for s in sessions { + let active = s.ended_at.is_none(); + let marker = if active { yellow("●") } else { dim(" ") }; + println!( + "{} {} {} {} events source={}", + marker, + bold(short_id(&s.id)), + crate::ui::fmt_timestamp(s.started_at), + s.event_count, + s.source + ); + } + Ok(()) +} diff --git a/crates/memora-cli/tests/cli.rs b/crates/memora-cli/tests/cli.rs index fc49f60..5a3cc50 100644 --- a/crates/memora-cli/tests/cli.rs +++ b/crates/memora-cli/tests/cli.rs @@ -412,3 +412,131 @@ fn merge_already_up_to_date_via_cli() { .success() .stdout(predicate::str::contains("Already up to date")); } + + +#[test] +fn session_lifecycle_via_cli() { + let tmp = tempdir().unwrap(); + let path = tmp.path(); + memora().arg("init").current_dir(path).assert().success(); + + memora() + .args(["session", "start", "--source", "claude_code"]) + .current_dir(path) + .assert() + .success() + .stdout(predicate::str::contains("Started session")); + memora() + .args(["add", "--type", "project", "--content", "uses Rust", "--source", "code-read"]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["commit", "-m", "first"]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["session", "end"]) + .current_dir(path) + .assert() + .success() + .stdout(predicate::str::contains("Ended session")); + + // list shows the session, replay walks events. + memora() + .args(["session", "list"]) + .current_dir(path) + .assert() + .success(); +} + +#[test] +fn replay_streams_events() { + let tmp = tempdir().unwrap(); + let path = tmp.path(); + memora().arg("init").current_dir(path).assert().success(); + memora() + .args(["session", "start", "--source", "manual"]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["add", "--type", "project", "--content", "x", "--source", "code-read"]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["commit", "-m", "c"]) + .current_dir(path) + .assert() + .success(); + // Replay before ending the session — should still show the events so far. + memora() + .args(["replay"]) + .current_dir(path) + .assert() + .success() + .stdout(predicate::str::contains("session_started")) + .stdout(predicate::str::contains("node_added")) + .stdout(predicate::str::contains("commit_created")); +} + +#[test] +fn export_claude_code_writes_file() { + let tmp = tempdir().unwrap(); + let path = tmp.path(); + memora().arg("init").current_dir(path).assert().success(); + memora() + .args([ + "add", + "--type", + "semantic", + "--content", + "auth uses jwt rs256", + "--source", + "code-read", + ]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["commit", "-m", "first"]) + .current_dir(path) + .assert() + .success(); + memora() + .args(["export", "--to", "claude-code"]) + .current_dir(path) + .assert() + .success() + .stdout(predicate::str::contains("Exported")); + let body = std::fs::read_to_string(path.join("CLAUDE.md")).unwrap(); + assert!(body.contains("auth uses jwt rs256")); + assert!(body.contains("## Semantic")); +} + +#[test] +fn export_json_to_stdout() { + let tmp = tempdir().unwrap(); + let path = tmp.path(); + memora().arg("init").current_dir(path).assert().success(); + memora() + .args(["add", "--type", "project", "--content", "rust", "--source", "code-read"]) + .current_dir(path) + .assert() + .success(); + let out = memora() + .args(["export", "--to", "json", "--stdout"]) + .current_dir(path) + .assert() + .success() + .get_output() + .stdout + .clone(); + let body = String::from_utf8(out).unwrap(); + let cleaned = strip_ansi(&body); + let parsed: serde_json::Value = serde_json::from_str(cleaned.trim()).unwrap(); + assert!(parsed.is_array()); + assert_eq!(parsed.as_array().unwrap().len(), 1); +} diff --git a/crates/memora-core/src/export.rs b/crates/memora-core/src/export.rs new file mode 100644 index 0000000..06ee323 --- /dev/null +++ b/crates/memora-core/src/export.rs @@ -0,0 +1,194 @@ +//! Export adapters — render the working set into formats other agent +//! tools understand. +//! +//! Every adapter is a pure function `&[MemoryNode] → String`. The +//! [`crate::repo::Repository`] handles ranking and filtering before +//! handing the nodes here, so the adapters stay focused on formatting. + +use std::fmt::Write as _; + +use serde::Serialize; + +use crate::node::{MemoryKind, MemoryNode, MemoryStatus}; + +/// Supported export targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + /// `CLAUDE.md` — long-form markdown, project-aware sections. + ClaudeCode, + /// `.cursorrules` — terse rule list, no headings. + Cursor, + /// `.clinerules` — markdown bullet list with kind tags. + Cline, + /// JSON object suitable for the OpenAI Assistants API `instructions` + /// field, plus a structured `memories` array. + OpenAiAssistant, + /// Raw JSON — every node serialised in full. Round-trippable. + Json, +} + +impl ExportFormat { + /// Parse a CLI string. Accepts both kebab and snake case. + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "claude-code" | "claude_code" | "claudecode" => ExportFormat::ClaudeCode, + "cursor" | "cursorrules" => ExportFormat::Cursor, + "cline" | "clinerules" => ExportFormat::Cline, + "openai-assistant" | "openai_assistant" | "openai" => ExportFormat::OpenAiAssistant, + "json" => ExportFormat::Json, + _ => return None, + }) + } + + /// Conventional output filename for this format. + pub fn default_filename(self) -> &'static str { + match self { + ExportFormat::ClaudeCode => "CLAUDE.md", + ExportFormat::Cursor => ".cursorrules", + ExportFormat::Cline => ".clinerules", + ExportFormat::OpenAiAssistant => "openai-assistant.json", + ExportFormat::Json => "memora-export.json", + } + } +} + +/// Render a sorted set of nodes into the requested format. The caller is +/// responsible for sorting / filtering / capping before this is called. +pub fn render(format: ExportFormat, nodes: &[MemoryNode]) -> String { + match format { + ExportFormat::ClaudeCode => render_claude_code(nodes), + ExportFormat::Cursor => render_cursor(nodes), + ExportFormat::Cline => render_cline(nodes), + ExportFormat::OpenAiAssistant => render_openai_assistant(nodes), + ExportFormat::Json => render_json(nodes), + } +} + +fn render_claude_code(nodes: &[MemoryNode]) -> String { + let mut out = String::new(); + out.push_str("# Project memory (exported from memora)\n\n"); + out.push_str( + "_This file is generated by `memora export --to=claude-code`. Edit your memora\n\ + store and re-export rather than editing this file by hand._\n\n", + ); + + if nodes.is_empty() { + out.push_str("_(no memories yet)_\n"); + return out; + } + + for kind in MemoryKind::ALL { + let bucket: Vec<&MemoryNode> = nodes.iter().filter(|n| n.kind == kind).collect(); + if bucket.is_empty() { + continue; + } + let _ = writeln!(out, "## {}\n", title_case(kind.as_str())); + for n in bucket { + let _ = writeln!(out, "- {}", n.content); + let mut footer = format!( + " _confidence {:.2} · {} · {}", + n.confidence, + n.status, + n.source + ); + if let Some(ev) = &n.evidence { + footer.push_str(&format!(" · evidence: {ev}")); + } + if !n.tags.is_empty() { + footer.push_str(&format!(" · tags: {}", n.tags.join(", "))); + } + footer.push('_'); + let _ = writeln!(out, "{footer}"); + } + out.push('\n'); + } + out +} + +fn render_cursor(nodes: &[MemoryNode]) -> String { + // .cursorrules is just a flat instruction list. We dump every + // stable / ephemeral fact, drop deprecated and conflicted entries + // (the user shouldn't be feeding those to a coding agent verbatim). + let mut out = String::new(); + out.push_str("# memora export — cursor rules\n\n"); + for n in nodes { + if matches!(n.status, MemoryStatus::Deprecated | MemoryStatus::Conflicted) { + continue; + } + let _ = writeln!(out, "- [{}] {}", n.kind, n.content); + } + out +} + +fn render_cline(nodes: &[MemoryNode]) -> String { + let mut out = String::new(); + out.push_str("# memora export — cline rules\n\n"); + for n in nodes { + let _ = writeln!( + out, + "- **{}** _(confidence {:.2}, {})_: {}", + title_case(n.kind.as_str()), + n.confidence, + n.status, + n.content, + ); + } + out +} + +fn render_openai_assistant(nodes: &[MemoryNode]) -> String { + #[derive(Serialize)] + struct OpenAiExport<'a> { + instructions: String, + memories: Vec>, + } + #[derive(Serialize)] + struct OpenAiMemory<'a> { + kind: &'a str, + content: &'a str, + confidence: f32, + status: &'a str, + source: String, + evidence: Option<&'a String>, + tags: &'a [String], + } + + let mut instructions = String::from( + "You have access to typed project memory. Treat `stable` items as confirmed facts and \ + `ephemeral` items as working assumptions to verify before acting on.\n\n", + ); + for n in nodes.iter().take(8) { + let _ = writeln!(instructions, "- [{}] {}", n.kind, n.content); + } + + let memories: Vec> = nodes + .iter() + .map(|n| OpenAiMemory { + kind: n.kind.as_str(), + content: &n.content, + confidence: n.confidence, + status: n.status.as_str(), + source: n.source.as_str(), + evidence: n.evidence.as_ref(), + tags: &n.tags, + }) + .collect(); + + let payload = OpenAiExport { + instructions, + memories, + }; + serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")) +} + +fn render_json(nodes: &[MemoryNode]) -> String { + serde_json::to_string_pretty(nodes).unwrap_or_else(|e| format!("[\"error\",\"{e}\"]")) +} + +fn title_case(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } +} diff --git a/crates/memora-core/src/lib.rs b/crates/memora-core/src/lib.rs index f3ff1ce..81f2355 100644 --- a/crates/memora-core/src/lib.rs +++ b/crates/memora-core/src/lib.rs @@ -30,21 +30,25 @@ pub mod commit; pub mod error; +pub mod export; pub mod hash; pub mod merge; pub mod node; pub mod repo; +pub mod session; pub mod store; pub mod time; pub use commit::{CommitStats, MemoryCommit}; pub use error::{MemoraError, Result}; +pub use export::{render as render_export, ExportFormat}; pub use merge::{MergeEntry, MergePlan, MergeStrategy, NodeDecision}; pub use node::{MemoryKind, MemoryNode, MemorySource, MemoryStatus}; pub use repo::{ - DiffReport, MergeKind, MergeOptions, MergeOutcome, ModifiedNode, NodeChange, PromotePlan, - Repository, + DiffReport, ExportFilter, ImportanceWeights, MergeKind, MergeOptions, MergeOutcome, + ModifiedNode, NodeChange, PromotePlan, Repository, }; +pub use session::{Session, SessionEvent, SessionEventKind}; /// 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 d8ffd10..4c80a44 100644 --- a/crates/memora-core/src/repo.rs +++ b/crates/memora-core/src/repo.rs @@ -8,10 +8,14 @@ use std::fs; use std::path::{Path, PathBuf}; +use uuid::Uuid; + use crate::commit::{commit_id_with_parents, tree_id_for_nodes, CommitStats, MemoryCommit}; use crate::error::{MemoraError, Result}; +use crate::export::{render as render_export, ExportFormat}; use crate::merge::{plan_merge, MergePlan, MergeStrategy, NodeDecision}; use crate::node::{MemoryKind, MemoryNode, MemoryStatus, NewNode}; +use crate::session::{Session, SessionEvent, SessionEventKind}; use crate::store::{HeadRef, Refs, Store, UnstagedSummary}; use crate::time::{Clock, SystemClock}; use crate::{DEFAULT_BRANCH, FORMAT_VERSION, STORE_DIR}; @@ -166,6 +170,17 @@ impl Repository { let now = self.clock.now(); let node = MemoryNode::from_new(req, now); self.store.upsert_node(&node)?; + self.record_event( + SessionEventKind::NodeAdded, + serde_json::json!({ + "node_id": node.id, + "kind": node.kind.as_str(), + "status": node.status.as_str(), + "source": node.source.as_str(), + "confidence": node.confidence, + "content": node.content, + }), + )?; Ok(node) } @@ -291,10 +306,23 @@ impl Repository { } } - Ok(CommitOutcome { - commit: Some(commit), + let outcome = CommitOutcome { + commit: Some(commit.clone()), branch: branch_name, - }) + }; + self.record_event( + SessionEventKind::CommitCreated, + serde_json::json!({ + "commit_id": commit.id, + "parent": commit.parent, + "extra_parents": extra_parents, + "branch": outcome.branch, + "message": commit.message, + "tree_id": commit.tree_id, + "stats": commit.stats, + }), + )?; + Ok(outcome) } /// Walk the commit history starting from HEAD. @@ -471,11 +499,29 @@ impl Repository { } else { MergeKind::Merged }; - Ok(MergeOutcome { + let result = MergeOutcome { kind, plan, commit: outcome.commit, - }) + }; + self.record_event( + SessionEventKind::MergeCompleted, + serde_json::json!({ + "ours": result.plan.ours, + "theirs": result.plan.theirs, + "base": result.plan.base, + "kind": match result.kind { + MergeKind::AlreadyUpToDate => "already_up_to_date", + MergeKind::FastForward => "fast_forward", + MergeKind::Merged => "merged", + MergeKind::Conflicts => "conflicts", + MergeKind::NoCommit => "no_commit", + }, + "commit_id": result.commit.as_ref().map(|c| c.id.clone()), + "conflicts": result.plan.conflicts().len(), + }), + )?; + Ok(result) } /// Replace the live `nodes` table with the contents of `target`. @@ -544,6 +590,16 @@ impl Repository { for id in &candidate_ids { self.store.set_status(id, MemoryStatus::Stable, now)?; } + if !candidate_ids.is_empty() { + self.record_event( + SessionEventKind::NodePromoted, + serde_json::json!({ + "node_ids": candidate_ids, + "from": "ephemeral", + "to": "stable", + }), + )?; + } Ok(candidate_ids) } @@ -630,6 +686,163 @@ impl Repository { } Ok(current) } + + // --- session bracketing --------------------------------------------- + + /// Path to the marker file recording the active session id, if any. + fn current_session_path(&self) -> PathBuf { + self.memora_dir.join("sessions").join("CURRENT") + } + + /// Read the active session id (the contents of `.memora/sessions/CURRENT`). + pub fn current_session_id(&self) -> Result> { + let path = self.current_session_path(); + if !path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(&path)?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } + } + + /// Start a fresh recording session. Sets the `CURRENT` marker so that + /// subsequent operations append events to it. Returns the new session. + pub fn start_session(&self, source: &str) -> Result { + if let Some(existing) = self.current_session_id()? { + return Err(MemoraError::Invalid(format!( + "a session is already active: {existing}; run `memora session end` first" + ))); + } + let now = self.clock.now(); + let session = Session { + id: Uuid::new_v4().to_string(), + source: source.to_string(), + started_at: now, + ended_at: None, + event_count: 0, + }; + self.store.insert_session(&session)?; + // Make sure `.memora/sessions/` exists; init does this but a hand- + // crafted store might not. + fs::create_dir_all(self.memora_dir.join("sessions"))?; + fs::write(self.current_session_path(), &session.id)?; + // Record the start event itself. + self.store.append_session_event( + &session.id, + now, + SessionEventKind::SessionStarted, + &serde_json::json!({ "source": source }), + )?; + Ok(session) + } + + /// End the active session. Returns the closed session, or `None` if + /// none was active. + pub fn end_session(&self) -> Result> { + let id = match self.current_session_id()? { + Some(id) => id, + None => return Ok(None), + }; + let now = self.clock.now(); + self.store.append_session_event( + &id, + now, + SessionEventKind::SessionEnded, + &serde_json::json!({}), + )?; + let mut session = self + .store + .get_session(&id)? + .ok_or_else(|| MemoraError::Invalid(format!("session not found: {id}")))?; + session.ended_at = Some(now); + // event_count was incremented inside append_session_event. + if let Some(updated) = self.store.get_session(&id)? { + session.event_count = updated.event_count; + } + self.store.update_session(&session)?; + let _ = fs::remove_file(self.current_session_path()); + Ok(Some(session)) + } + + /// Record an arbitrary event against the active session, if any. No-op + /// when no session is active. + pub fn record_event(&self, kind: SessionEventKind, data: serde_json::Value) -> Result<()> { + let id = match self.current_session_id()? { + Some(id) => id, + None => return Ok(()), + }; + let now = self.clock.now(); + self.store.append_session_event(&id, now, kind, &data)?; + Ok(()) + } + + /// Read every event from a session, in append order. + pub fn session_events(&self, session_id: &str) -> Result> { + let resolved = self.store.resolve_session_prefix(session_id)?; + self.store.session_events(&resolved) + } + + /// List sessions, newest first. + pub fn list_sessions(&self, limit: Option) -> Result> { + self.store.list_sessions(limit) + } + + // --- export --------------------------------------------------------- + + /// Score every node in the working set with the standard importance + /// formula and return them sorted by score (highest first). Used by + /// [`Self::export`]. + pub fn ranked_nodes(&self, weights: ImportanceWeights) -> Result> { + let nodes = self.store.all_nodes()?; + let now = self.clock.now(); + let max_age = nodes + .iter() + .map(|n| (now - n.last_accessed).max(1)) + .max() + .unwrap_or(1); + let max_count = nodes.iter().map(|n| n.access_count).max().unwrap_or(0); + + let mut scored: Vec<(MemoryNode, f32)> = nodes + .into_iter() + .map(|n| { + let recency = if max_age > 0 { + 1.0 - ((now - n.last_accessed).max(0) as f32 / max_age as f32) + } else { + 1.0 + }; + let access = if max_count == 0 { + 0.0 + } else { + n.access_count as f32 / max_count as f32 + }; + let score = (n.confidence * weights.confidence) + + (recency * weights.recency) + + (access * weights.access); + (n, score) + }) + .collect(); + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + Ok(scored) + } + + /// Render the working set into the requested export format. The + /// optional [`ExportFilter`] caps and filters the candidate set first. + pub fn export(&self, format: ExportFormat, filter: ExportFilter) -> Result { + let scored = self.ranked_nodes(filter.weights)?; + let mut nodes: Vec = scored + .into_iter() + .map(|(n, _)| n) + .filter(|n| filter.matches(n)) + .collect(); + if let Some(top) = filter.top { + nodes.truncate(top); + } + Ok(render_export(format, &nodes)) + } } // --------------------------------------------------------------------------- @@ -695,6 +908,69 @@ fn short_for_display(id: &str) -> String { id.chars().take(7).collect() } +/// Weights for the importance score used by [`Repository::ranked_nodes`]. +/// They should sum to 1.0 for a normalised score, but no validation is +/// performed. +#[derive(Debug, Clone, Copy)] +pub struct ImportanceWeights { + /// Weight of `confidence` (0.0 – 1.0). + pub confidence: f32, + /// Weight of recency (0.0 – 1.0, freshest = 1.0). + pub recency: f32, + /// Weight of access frequency (0.0 – 1.0, hottest = 1.0). + pub access: f32, +} + +impl Default for ImportanceWeights { + fn default() -> Self { + // Matches the formula in `docs/MEMORY_TYPES.md`. + Self { + confidence: 0.4, + recency: 0.3, + access: 0.3, + } + } +} + +/// Filter / cap applied to the working set before [`Repository::export`] +/// renders it. +#[derive(Debug, Clone, Default)] +pub struct ExportFilter { + /// Importance score weights. + pub weights: ImportanceWeights, + /// Keep at most this many nodes after ranking. + pub top: Option, + /// Restrict to specific kinds. Empty means "all kinds". + pub kinds: Vec, + /// Restrict to specific statuses. Empty means "all statuses except + /// deprecated". + pub statuses: Vec, + /// Drop nodes with confidence below this threshold. + pub min_confidence: Option, +} + +impl ExportFilter { + /// Decide whether a single node passes the filter. + pub fn matches(&self, node: &MemoryNode) -> bool { + if !self.kinds.is_empty() && !self.kinds.contains(&node.kind) { + return false; + } + if self.statuses.is_empty() { + if node.status == MemoryStatus::Deprecated { + return false; + } + } else if !self.statuses.contains(&node.status) { + return false; + } + if let Some(min) = self.min_confidence { + if node.confidence < min { + return false; + } + } + true + } +} + /// Caller intent for [`Repository::promote`]. #[derive(Debug, Clone)] pub enum PromotePlan { @@ -1392,3 +1668,209 @@ mod tests { assert_eq!(merged.status, MemoryStatus::Conflicted); } } + + +#[cfg(test)] +mod phase4_tests { + use super::*; + use crate::node::{MemoryKind, MemorySource, NewNode}; + use std::path::Path; + use std::sync::atomic::{AtomicI64, Ordering}; + use tempfile::tempdir; + + struct StepClock(AtomicI64); + impl Clock for StepClock { + fn now(&self) -> i64 { + self.0.fetch_add(1, Ordering::SeqCst) + } + } + + fn new_repo(path: &Path) -> Repository { + Repository::init(path) + .unwrap() + .with_clock(Box::new(StepClock(AtomicI64::new(2_000)))) + } + + #[test] + fn session_records_add_and_commit_events() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + let session = repo.start_session("claude_code").unwrap(); + + repo.add_node(NewNode::new( + MemoryKind::Project, + "uses Rust", + MemorySource::CodeRead, + )) + .unwrap(); + repo.commit("first", "human").unwrap(); + + let ended = repo.end_session().unwrap().expect("session was active"); + let events = repo.session_events(&session.id).unwrap(); + // Expect: started, node_added, commit_created, ended. + assert_eq!(events.len(), 4); + assert_eq!(events[0].kind, SessionEventKind::SessionStarted); + assert_eq!(events[1].kind, SessionEventKind::NodeAdded); + assert_eq!(events[2].kind, SessionEventKind::CommitCreated); + assert_eq!(events[3].kind, SessionEventKind::SessionEnded); + assert_eq!(ended.event_count, 4); + // After end_session, CURRENT marker is gone. + assert!(repo.current_session_id().unwrap().is_none()); + } + + #[test] + fn cannot_start_two_sessions() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + let _s = repo.start_session("manual").unwrap(); + let again = repo.start_session("manual"); + assert!(again.is_err()); + } + + #[test] + fn record_event_is_noop_when_no_session() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + // Adding a node without a session should still succeed and not blow up. + repo.add_node(NewNode::new( + MemoryKind::Project, + "no session", + MemorySource::CodeRead, + )) + .unwrap(); + let sessions = repo.list_sessions(None).unwrap(); + assert!(sessions.is_empty()); + } + + #[test] + fn promote_emits_promotion_event() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + let n = repo + .add_node(NewNode::new( + MemoryKind::Assumption, + "redis", + MemorySource::ModelInference, + )) + .unwrap(); + let session = repo.start_session("claude_code").unwrap(); + repo.promote(PromotePlan::Ids(vec![n.id.clone()])).unwrap(); + repo.end_session().unwrap(); + let events = repo.session_events(&session.id).unwrap(); + assert!(events + .iter() + .any(|e| e.kind == SessionEventKind::NodePromoted)); + } + + #[test] + fn export_json_round_trip() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + repo.add_node(NewNode::new( + MemoryKind::Project, + "uses Rust", + MemorySource::CodeRead, + )) + .unwrap(); + repo.add_node(NewNode { + confidence: Some(0.4), + ..NewNode::new( + MemoryKind::Assumption, + "redis is the cache", + MemorySource::ModelInference, + ) + }) + .unwrap(); + let body = repo + .export(ExportFormat::Json, ExportFilter::default()) + .unwrap(); + let parsed: Vec = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed.len(), 2); + } + + #[test] + fn export_filters_drop_below_threshold_and_deprecated() { + 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.2), + ..NewNode::new( + MemoryKind::Assumption, + "low", + MemorySource::ModelInference, + ) + }) + .unwrap(); + let body = repo + .export( + ExportFormat::Json, + ExportFilter { + min_confidence: Some(0.5), + ..ExportFilter::default() + }, + ) + .unwrap(); + let parsed: Vec = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].content, "high"); + } + + #[test] + fn export_claude_code_groups_by_kind() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + repo.add_node(NewNode::new( + MemoryKind::Semantic, + "auth uses jwt", + MemorySource::CodeRead, + )) + .unwrap(); + repo.add_node(NewNode::new( + MemoryKind::Project, + "rust workspace", + MemorySource::CodeRead, + )) + .unwrap(); + let body = repo + .export(ExportFormat::ClaudeCode, ExportFilter::default()) + .unwrap(); + assert!(body.contains("# Project memory")); + assert!(body.contains("## Project")); + assert!(body.contains("## Semantic")); + assert!(body.contains("auth uses jwt")); + assert!(body.contains("rust workspace")); + } + + #[test] + fn export_top_caps_results_after_ranking() { + let tmp = tempdir().unwrap(); + let repo = new_repo(tmp.path()); + for i in 0..5 { + repo.add_node(NewNode::new( + MemoryKind::Project, + format!("entry-{i}"), + MemorySource::CodeRead, + )) + .unwrap(); + } + let body = repo + .export( + ExportFormat::Json, + ExportFilter { + top: Some(2), + ..ExportFilter::default() + }, + ) + .unwrap(); + let parsed: Vec = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed.len(), 2); + } +} diff --git a/crates/memora-core/src/session.rs b/crates/memora-core/src/session.rs new file mode 100644 index 0000000..e56f9b3 --- /dev/null +++ b/crates/memora-core/src/session.rs @@ -0,0 +1,98 @@ +//! Session recording — the "flight recorder" for agent memory. +//! +//! A *session* brackets a sequence of operations performed by one tool +//! (Claude Code, Cursor, a script, a human) so that later, `memora +//! replay --session ` can walk through what happened in the order it +//! happened: which nodes were added, which beliefs were promoted, which +//! commits landed, which merges resolved which way. +//! +//! Sessions are completely optional. If no session is active when a +//! command runs, no events are recorded; the command behaves exactly as +//! it always has. + +use serde::{Deserialize, Serialize}; + +/// Tool / actor that owns the session. Stored as a string so we can +/// accept new sources without a schema change. +pub type SessionSource = String; + +/// One row in the `sessions` table. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Session { + /// Stable id (UUIDv4). Short forms like the first 8 chars are fine + /// for display. + pub id: String, + /// Tool / actor that started the session, e.g. `claude_code`. + pub source: SessionSource, + /// Unix-second timestamp when the session was started. + pub started_at: i64, + /// Unix-second timestamp when the session was ended, or `None` if + /// it's still active. + pub ended_at: Option, + /// Number of events recorded against this session. + pub event_count: u32, +} + +/// Categorical kind of a [`SessionEvent`]. The string form on the wire +/// matches the lowercase variant name (e.g. `node_added`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionEventKind { + /// Session was started. + SessionStarted, + /// Session was ended. + SessionEnded, + /// A new memory node was added. + NodeAdded, + /// An existing node was promoted (e.g. ephemeral → stable). + NodePromoted, + /// A commit was created (regular or merge). + CommitCreated, + /// A merge completed (with or without conflicts). + MergeCompleted, +} + +impl SessionEventKind { + /// Lowercase wire string used in SQLite. + pub fn as_str(self) -> &'static str { + match self { + SessionEventKind::SessionStarted => "session_started", + SessionEventKind::SessionEnded => "session_ended", + SessionEventKind::NodeAdded => "node_added", + SessionEventKind::NodePromoted => "node_promoted", + SessionEventKind::CommitCreated => "commit_created", + SessionEventKind::MergeCompleted => "merge_completed", + } + } + + /// Parse the wire string. Unknown values become `None` so callers can + /// guard against schema drift. + pub fn parse(s: &str) -> Option { + Some(match s { + "session_started" => SessionEventKind::SessionStarted, + "session_ended" => SessionEventKind::SessionEnded, + "node_added" => SessionEventKind::NodeAdded, + "node_promoted" => SessionEventKind::NodePromoted, + "commit_created" => SessionEventKind::CommitCreated, + "merge_completed" => SessionEventKind::MergeCompleted, + _ => return None, + }) + } +} + +/// One row in `session_events`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionEvent { + /// Auto-increment row id. + pub id: i64, + /// Owning session id. + pub session_id: String, + /// Unix-second timestamp. + pub timestamp: i64, + /// Categorical kind. + pub kind: SessionEventKind, + /// Free-form JSON payload. The shape depends on `kind`; see + /// [`crate::repo::Repository::record_event`] for the canonical + /// fields per kind. + pub data: serde_json::Value, +} diff --git a/crates/memora-core/src/store/db.rs b/crates/memora-core/src/store/db.rs index 2304495..725386f 100644 --- a/crates/memora-core/src/store/db.rs +++ b/crates/memora-core/src/store/db.rs @@ -12,6 +12,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use crate::commit::{CommitStats, MemoryCommit}; use crate::error::{MemoraError, Result}; use crate::node::{MemoryKind, MemoryNode, MemorySource, MemoryStatus}; +use crate::session::{Session, SessionEvent, SessionEventKind}; const SCHEMA_SQL: &str = include_str!("schema.sql"); @@ -290,6 +291,138 @@ impl Store { Ok(out) } + // --- session CRUD ---------------------------------------------------- + + /// Insert a brand new session row. + pub fn insert_session(&self, session: &Session) -> Result<()> { + self.conn.execute( + "INSERT INTO sessions (id, started_at, ended_at, source, event_count) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + session.id, + session.started_at, + session.ended_at, + session.source, + session.event_count + ], + )?; + Ok(()) + } + + /// Update an existing session — used to set `ended_at` and the + /// final `event_count`. + pub fn update_session(&self, session: &Session) -> Result<()> { + let changed = self.conn.execute( + "UPDATE sessions SET started_at = ?2, ended_at = ?3, source = ?4, event_count = ?5 WHERE id = ?1", + params![ + session.id, + session.started_at, + session.ended_at, + session.source, + session.event_count + ], + )?; + if changed == 0 { + return Err(MemoraError::Invalid(format!( + "session not found: {}", + session.id + ))); + } + Ok(()) + } + + /// Fetch a session by id (full or short prefix). + pub fn get_session(&self, id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, started_at, ended_at, source, event_count FROM sessions WHERE id = ?1", + )?; + stmt.query_row(params![id], row_to_session) + .optional() + .map_err(Into::into) + } + + /// Resolve a session id prefix to the full id. Returns + /// `Err(Invalid)` for ambiguous prefixes. + pub fn resolve_session_prefix(&self, prefix: &str) -> Result { + if prefix.len() < 4 { + return Err(MemoraError::Invalid( + "session id must be at least 4 characters".into(), + )); + } + let mut stmt = self + .conn + .prepare("SELECT id FROM sessions WHERE id LIKE ?1 || '%' LIMIT 2")?; + let rows = stmt.query_map(params![prefix], |r| r.get::<_, String>(0))?; + let mut matches = Vec::new(); + for r in rows { + matches.push(r?); + } + match matches.len() { + 0 => Err(MemoraError::Invalid(format!( + "no session matches prefix '{prefix}'" + ))), + 1 => Ok(matches.pop().unwrap()), + _ => Err(MemoraError::Invalid(format!( + "ambiguous session prefix '{prefix}'" + ))), + } + } + + /// List sessions, newest first. + pub fn list_sessions(&self, limit: Option) -> Result> { + let sql = match limit { + Some(n) => format!( + "SELECT id, started_at, ended_at, source, event_count FROM sessions ORDER BY started_at DESC LIMIT {n}" + ), + None => "SELECT id, started_at, ended_at, source, event_count FROM sessions ORDER BY started_at DESC".to_string(), + }; + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt.query_map([], row_to_session)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + /// Append an event to a session. Increments `event_count` atomically. + pub fn append_session_event( + &self, + session_id: &str, + timestamp: i64, + kind: SessionEventKind, + data: &serde_json::Value, + ) -> Result { + let tx = self.conn.unchecked_transaction()?; + let payload = serde_json::to_string(data)?; + tx.execute( + "INSERT INTO session_events (session_id, timestamp, event_type, data_json) + VALUES (?1, ?2, ?3, ?4)", + params![session_id, timestamp, kind.as_str(), payload], + )?; + let id = tx.last_insert_rowid(); + tx.execute( + "UPDATE sessions SET event_count = event_count + 1 WHERE id = ?1", + params![session_id], + )?; + tx.commit()?; + Ok(id) + } + + /// Read every event for a session, in append order. + pub fn session_events(&self, session_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, session_id, timestamp, event_type, data_json + FROM session_events WHERE session_id = ?1 ORDER BY id ASC", + )?; + let rows = stmt.query_map(params![session_id], row_to_event)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + /// 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<()> { @@ -557,6 +690,39 @@ fn serde_to_sqlite_err(err: serde_json::Error) -> rusqlite::Error { rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(err)) } +fn row_to_session(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Session { + id: row.get(0)?, + started_at: row.get(1)?, + ended_at: row.get(2)?, + source: row.get(3)?, + event_count: row.get(4)?, + }) +} + +fn row_to_event(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let kind_str: String = row.get(3)?; + let kind = SessionEventKind::parse(&kind_str).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(MemoraError::Invalid(format!( + "unknown session event kind '{kind_str}'" + ))), + ) + })?; + let data_json: String = row.get(4)?; + let data: serde_json::Value = + serde_json::from_str(&data_json).map_err(serde_to_sqlite_err)?; + Ok(SessionEvent { + id: row.get(0)?, + session_id: row.get(1)?, + timestamp: row.get(2)?, + kind, + data, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 912f3ec..e498e37 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -76,6 +76,9 @@ timestamps deterministically. - `resolve_revision` (`HEAD`, `HEAD~N`, branch names, hex prefixes) - `plan_merge` / `merge` (three-way merge with auto / ours / theirs strategies; produces fast-forward, merge commit, or surfaced conflicts) +- `start_session` / `end_session` / `record_event` / `session_events` + / `list_sessions` (the flight recorder) +- `ranked_nodes` / `export` (importance-scored ranking + format adapters) The diff engine compares two `node_versions` snapshots from the SQLite store and produces a `DiffReport` with `added` / `removed` / `modified` @@ -101,6 +104,22 @@ Merge commits store their first parent in `commits.parent_id` (so first-parent log walks keep working) and additional parents in the `merge_parents` table. +### `session.rs` and `export.rs` +Two small modules that round out the agent-tooling story. + +`session.rs` defines `Session` + `SessionEvent` + `SessionEventKind`. +`Repository` writes a marker file `.memora/sessions/CURRENT` containing +the active session id; while a session is active, `add_node`, `commit`, +`promote`, and `merge` all append a typed event to `session_events`. +`memora replay` walks that event stream with optional `--step` pacing. + +`export.rs` is a set of pure renderers (`&[MemoryNode] → String`) for +`CLAUDE.md`, `.cursorrules`, `.clinerules`, OpenAI Assistants JSON, and +raw JSON. The repository pre-ranks nodes using the importance formula +`confidence × 0.4 + recency × 0.3 + access × 0.3` (configurable via +`ImportanceWeights`) and applies an `ExportFilter` (kinds, statuses, +min confidence, top N) before handing the nodes to the renderer. + ## Crate: `memora-cli` ### `cli.rs` @@ -130,11 +149,10 @@ Centralised printing helpers (timestamps, short ids, error formatter). ## What's not built yet -The roadmap in `README.md` calls out Phase 4 → Phase 5. Notable gaps: +The roadmap in `README.md` calls out Phase 5. Notable gaps: -- Replay (`memora replay`, session event recording). -- Export / import adapters (`memora export --to=claude-code`, etc.). -- GC + remote sync. +- `memora import` (round-trip from `CLAUDE.md` / `.cursorrules` files). +- GC + remote sync (`memora gc`, `memora push`, `memora pull`). - Semantic-overlap detection during merge (different ids, same fact). The internal types and SQLite tables already make room for these (see