diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 152990aa479..c7752987b2f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1170,6 +1170,7 @@ name = "codex-cli" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "clap", "clap_complete", "codex-arg0", @@ -1179,13 +1180,16 @@ dependencies = [ "codex-exec", "codex-login", "codex-mcp-server", + "codex-memory", "codex-protocol", "codex-protocol-ts", "codex-tui", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index f7af3349e0a..23abe3be3a6 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -27,6 +27,9 @@ codex-login = { path = "../login" } codex-mcp-server = { path = "../mcp-server" } codex-protocol = { path = "../protocol" } codex-tui = { path = "../tui" } +codex-memory = { path = "../memory", features = ["sqlite"] } +chrono = "0.4" +uuid = { version = "1", features = ["v4"] } serde_json = "1" tokio = { version = "1", features = [ "io-std", @@ -38,3 +41,6 @@ tokio = { version = "1", features = [ tracing = "0.1.41" tracing-subscriber = "0.3.19" codex-protocol-ts = { path = "../protocol-ts" } + +[dev-dependencies] +tempfile = "3" diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index c6d80c0adfa..f00b5466dcf 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -1,6 +1,7 @@ pub mod debug_sandbox; mod exit_status; pub mod login; +pub mod memory; pub mod proto; use clap::Parser; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2acc3d84c50..bac7f49b98e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -18,6 +18,7 @@ use codex_tui::Cli as TuiCli; use std::path::PathBuf; use crate::proto::ProtoCli; +use codex_cli::memory::MemoryCli; /// Codex CLI /// @@ -73,6 +74,9 @@ enum Subcommand { #[clap(visible_alias = "a")] Apply(ApplyCommand), + /// Manage persistent memory items. + Memory(MemoryCli), + /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), @@ -209,6 +213,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() prepend_config_flags(&mut apply_cli.config_overrides, cli.config_overrides); run_apply_command(apply_cli, None).await?; } + Some(Subcommand::Memory(memory_cli)) => { + codex_cli::memory::run(memory_cli)?; + } Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } diff --git a/codex-rs/cli/src/memory.rs b/codex-rs/cli/src/memory.rs new file mode 100644 index 00000000000..a007aeb25b6 --- /dev/null +++ b/codex-rs/cli/src/memory.rs @@ -0,0 +1,139 @@ +use chrono::Utc; +use clap::Parser; +use codex_memory::factory; +use codex_memory::types::{Counters, Kind, MemoryItem, RelevanceHints, Scope, Status}; +use std::path::PathBuf; +use uuid::Uuid; + +/// CLI for memory management commands. +#[derive(Debug, Parser)] +pub struct MemoryCli { + #[command(subcommand)] + pub cmd: MemoryCommand, +} + +/// Memory subcommands. +#[derive(Debug, clap::Subcommand)] +pub enum MemoryCommand { + /// Add a new memory item with given content. + Add { content: String }, + /// List memory items. + List, + /// Edit an existing memory item. + Edit { id: String, content: String }, + /// Remove a memory item by id. + Rm { id: String }, + /// Archive a memory item. + Archive { id: String }, + /// Unarchive a memory item. + Unarchive { id: String }, + /// Export memory items to stdout. + Export, + /// Import memory items from stdin. + Import, + /// Migrate a JSONL file to a SQLite database. + Migrate { jsonl: PathBuf, sqlite: PathBuf }, + /// Show basic statistics about stored memories. + Stats, + /// Recall memories for a given prompt. + Recall { + #[arg(long = "for")] + query: String, + }, +} + +/// Execute the memory command. +pub fn run(cli: MemoryCli) -> anyhow::Result<()> { + match cli.cmd { + MemoryCommand::Migrate { jsonl, sqlite } => { + codex_memory::migrate::migrate_jsonl_to_sqlite(&jsonl, &sqlite)?; + } + cmd => { + let repo_root = std::env::current_dir()?; + let store = factory::open_repo_store(&repo_root, None)?; + match cmd { + MemoryCommand::Add { content } => { + let now = Utc::now().to_rfc3339(); + let item = MemoryItem { + id: Uuid::new_v4().to_string(), + created_at: now.clone(), + updated_at: now, + schema_version: 1, + source: "codex-cli".into(), + scope: Scope::Repo, + status: Status::Active, + kind: Kind::Note, + content, + tags: Vec::new(), + relevance_hints: RelevanceHints { + files: Vec::new(), + crates: Vec::new(), + languages: Vec::new(), + commands: Vec::new(), + }, + counters: Counters { + seen_count: 0, + used_count: 0, + last_used_at: None, + }, + expiry: None, + }; + store.add(item)?; + } + MemoryCommand::List => { + for item in store.list(None, None)? { + println!("{}", item.content); + } + } + MemoryCommand::Edit { id, content } => { + if let Some(mut item) = store.get(&id)? { + item.content = content; + item.updated_at = Utc::now().to_rfc3339(); + store.update(&item)?; + } else { + anyhow::bail!("memory id not found: {id}"); + } + } + MemoryCommand::Rm { id } => { + store.delete(&id)?; + } + MemoryCommand::Archive { id } => { + store.archive(&id, true)?; + } + MemoryCommand::Unarchive { id } => { + store.archive(&id, false)?; + } + MemoryCommand::Export => { + let mut out = std::io::stdout(); + store.export(&mut out)?; + } + MemoryCommand::Import => { + let mut input = std::io::stdin(); + let n = store.import(&mut input)?; + println!("Imported {n} items"); + } + MemoryCommand::Stats => { + let stats = store.stats()?; + println!("{stats}"); + } + MemoryCommand::Recall { query } => { + let ctx = codex_memory::recall::RecallContext { + repo_root: Some(repo_root), + dir: None, + current_file: None, + crate_name: None, + language: None, + command: None, + now_rfc3339: Utc::now().to_rfc3339(), + item_cap: 0, + token_cap: 0, + }; + let items = codex_memory::recall::recall(store.as_ref(), &query, &ctx)?; + println!("{}", serde_json::to_string(&items)?); + } + MemoryCommand::Migrate { .. } => unreachable!(), + } + } + } + Ok(()) +} diff --git a/codex-rs/cli/tests/memory.rs b/codex-rs/cli/tests/memory.rs new file mode 100644 index 00000000000..09dc97adb77 --- /dev/null +++ b/codex-rs/cli/tests/memory.rs @@ -0,0 +1,85 @@ +use chrono::Utc; +use clap::Parser; +use codex_cli::memory::{MemoryCli, MemoryCommand, run}; +use codex_memory::{ + factory, + store::MemoryStore, + types::{Counters, Kind, MemoryItem, RelevanceHints, Scope, Status}, +}; +use tempfile::tempdir; +use uuid::Uuid; + +#[test] +fn parses_recall_for() { + let cli = MemoryCli::parse_from(["memory", "recall", "--for", "hello"]); + match cli.cmd { + MemoryCommand::Recall { query } => assert_eq!(query, "hello"), + _ => panic!("expected recall"), + } +} + +#[test] +fn sqlite_add_and_list() -> anyhow::Result<()> { + let dir = tempdir()?; + let prev = std::env::current_dir()?; + std::env::set_current_dir(dir.path())?; + std::fs::create_dir_all(dir.path().join(".codex/memory"))?; + unsafe { std::env::set_var("CODEX_MEMORY_BACKEND", "sqlite") }; + run(MemoryCli { + cmd: MemoryCommand::Add { + content: "hello".into(), + }, + })?; + let store = factory::open_repo_store(dir.path(), Some(factory::Backend::Sqlite))?; + let items = store.list(None, None)?; + assert_eq!(items.len(), 1); + assert_eq!(items[0].content, "hello"); + std::env::set_current_dir(prev)?; + unsafe { std::env::remove_var("CODEX_MEMORY_BACKEND") }; + Ok(()) +} + +#[test] +fn migrate_jsonl_to_sqlite() -> anyhow::Result<()> { + let dir = tempdir()?; + let jsonl_path = dir.path().join("mem.jsonl"); + let sqlite_path = dir.path().join("mem.db"); + let now = Utc::now().to_rfc3339(); + let item = MemoryItem { + id: Uuid::new_v4().to_string(), + created_at: now.clone(), + updated_at: now, + schema_version: 1, + source: "test".into(), + scope: Scope::Repo, + status: Status::Active, + kind: Kind::Note, + content: "hello".into(), + tags: Vec::new(), + relevance_hints: RelevanceHints { + files: Vec::new(), + crates: Vec::new(), + languages: Vec::new(), + commands: Vec::new(), + }, + counters: Counters { + seen_count: 0, + used_count: 0, + last_used_at: None, + }, + expiry: None, + }; + let line = serde_json::to_string(&item)?; + std::fs::write(&jsonl_path, format!("{line}\n"))?; + run(MemoryCli { + cmd: MemoryCommand::Migrate { + jsonl: jsonl_path.clone(), + sqlite: sqlite_path.clone(), + }, + })?; + let store = codex_memory::store::sqlite::SqliteMemoryStore::new(sqlite_path); + let items = store.list(None, None)?; + assert_eq!(items.len(), 1); + assert_eq!(items[0].content, "hello"); + Ok(()) +}