Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,12 @@ Ephemeral ──promote──▶ Stable ──gc──▶ Deprecated
| `memora promote --id <NODE> \| --type <KIND> \| --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 <FORMAT> [...]` | 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.

---

Expand Down
25 changes: 25 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions crates/memora-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ chrono.workspace = true
tempfile = "3"
assert_cmd = "2"
predicates = "3"
serde_json.workspace = true
93 changes: 93 additions & 0 deletions crates/memora-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -254,3 +263,87 @@ impl From<MergeStrategyArg> 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<usize>,
}

/// 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<String>,

/// 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<std::path::PathBuf>,

/// 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<usize>,

/// Restrict to one memory kind. Repeatable.
#[arg(long = "kind", value_name = "KIND")]
pub kinds: Vec<String>,

/// Restrict to one status. Repeatable. Default behaviour drops
/// `deprecated`.
#[arg(long = "status", value_name = "STATUS")]
pub statuses: Vec<String>,

/// Drop nodes whose confidence is below this threshold.
#[arg(long, value_name = "T")]
pub min_confidence: Option<f32>,
}
66 changes: 66 additions & 0 deletions crates/memora-cli/src/commands/export.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
6 changes: 6 additions & 0 deletions crates/memora-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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),
}
}
144 changes: 144 additions & 0 deletions crates/memora-cli/src/commands/replay.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>()
.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
}
}
Loading
Loading