Skip to content

Commit f5a8e8a

Browse files
committed
feat: add memory CLI utilities
1 parent 0e37094 commit f5a8e8a

5 files changed

Lines changed: 248 additions & 5 deletions

File tree

codex-rs/Cargo.lock

Lines changed: 39 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/cli/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ codex-chatgpt = { path = "../chatgpt" }
2323
codex-common = { path = "../common", features = ["cli"] }
2424
codex-core = { path = "../core" }
2525
codex-exec = { path = "../exec" }
26+
codex-memory = { path = "../memory", features = ["sqlite"] }
2627
codex-login = { path = "../login" }
2728
codex-mcp-server = { path = "../mcp-server" }
2829
codex-protocol = { path = "../protocol" }
@@ -38,3 +39,9 @@ tokio = { version = "1", features = [
3839
tracing = "0.1.41"
3940
tracing-subscriber = "0.3.19"
4041
codex-protocol-ts = { path = "../protocol-ts" }
42+
indicatif = "0.17"
43+
44+
[dev-dependencies]
45+
assert_cmd = "2"
46+
predicates = "3"
47+
tempfile = "3"

codex-rs/cli/src/main.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ enum Subcommand {
5656
/// Remove stored authentication credentials.
5757
Logout(LogoutCommand),
5858

59+
/// Memory utilities.
60+
Memory(MemoryCommand),
61+
5962
/// Experimental: run Codex as an MCP server.
6063
Mcp,
6164

@@ -118,6 +121,43 @@ enum LoginSubcommand {
118121
Status,
119122
}
120123

124+
#[derive(Debug, Parser)]
125+
struct MemoryCommand {
126+
#[command(subcommand)]
127+
action: MemorySubcommand,
128+
}
129+
130+
#[derive(Debug, clap::Subcommand)]
131+
enum MemorySubcommand {
132+
/// Migrate a JSONL memory file to SQLite.
133+
Migrate(MemoryMigrateArgs),
134+
135+
/// Compact a JSONL memory file by removing duplicate ids.
136+
Compact(MemoryCompactArgs),
137+
}
138+
139+
#[derive(Debug, Parser)]
140+
struct MemoryMigrateArgs {
141+
/// Source JSONL file
142+
#[arg(long, value_name = "JSONL")]
143+
jsonl: std::path::PathBuf,
144+
145+
/// Destination SQLite database file
146+
#[arg(long, value_name = "SQLITE")]
147+
sqlite: std::path::PathBuf,
148+
}
149+
150+
#[derive(Debug, Parser)]
151+
struct MemoryCompactArgs {
152+
/// Input JSONL file to compact
153+
#[arg(long, value_name = "INPUT")]
154+
input: std::path::PathBuf,
155+
156+
/// Output JSONL file (defaults to in-place)
157+
#[arg(long, value_name = "OUTPUT")]
158+
output: Option<std::path::PathBuf>,
159+
}
160+
121161
#[derive(Debug, Parser)]
122162
struct LogoutCommand {
123163
#[clap(skip)]
@@ -184,6 +224,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
184224
prepend_config_flags(&mut proto_cli.config_overrides, cli.config_overrides);
185225
proto::run_main(proto_cli).await?;
186226
}
227+
Some(Subcommand::Memory(memory_cli)) => {
228+
run_memory_command(memory_cli).await?;
229+
}
187230
Some(Subcommand::Completion(completion_cli)) => {
188231
print_completion(completion_cli);
189232
}
@@ -217,6 +260,40 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
217260
Ok(())
218261
}
219262

263+
async fn run_memory_command(cmd: MemoryCommand) -> anyhow::Result<()> {
264+
match cmd.action {
265+
MemorySubcommand::Migrate(args) => {
266+
eprintln!(
267+
"Migrating {} -> {}...",
268+
args.jsonl.display(),
269+
args.sqlite.display()
270+
);
271+
let pb = indicatif::ProgressBar::new_spinner();
272+
pb.enable_steady_tick(std::time::Duration::from_millis(100));
273+
let count = codex_memory::migrate::migrate_jsonl_to_sqlite(&args.jsonl, &args.sqlite)?;
274+
pb.finish_and_clear();
275+
println!("Migrated {count} entries");
276+
}
277+
MemorySubcommand::Compact(args) => {
278+
let out = args.output.unwrap_or_else(|| args.input.clone());
279+
eprintln!(
280+
"Compacting {} -> {}...",
281+
args.input.display(),
282+
out.display()
283+
);
284+
let pb = indicatif::ProgressBar::new_spinner();
285+
pb.enable_steady_tick(std::time::Duration::from_millis(100));
286+
let (read, written) = codex_memory::migrate::compact_jsonl(&args.input, &out)?;
287+
pb.finish_and_clear();
288+
println!(
289+
"Read {read} entries, wrote {written} entries (removed {})",
290+
read - written
291+
);
292+
}
293+
}
294+
Ok(())
295+
}
296+
220297
/// Prepend root-level overrides so they have lower precedence than
221298
/// CLI-specific ones specified after the subcommand (if any).
222299
fn prepend_config_flags(

codex-rs/cli/tests/memory.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
use assert_cmd::Command;
2+
use predicates::str::contains;
3+
use std::fs;
4+
use tempfile::tempdir;
5+
6+
fn sample_line(id: &str, content: &str) -> String {
7+
format!(
8+
r#"{{"id":"{id}","created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z","schema_version":1,"source":"test","scope":"Repo","status":"Active","kind":"Note","content":"{content}","tags":[],"relevance_hints":{{"files":[],"crates":[],"languages":[],"commands":[]}},"counters":{{"seen_count":0,"used_count":0,"last_used_at":null}},"expiry":null}}"#
9+
)
10+
}
11+
12+
#[test]
13+
fn memory_compact_removes_duplicates() -> Result<(), Box<dyn std::error::Error>> {
14+
let dir = tempdir()?;
15+
let input = dir.path().join("mem.jsonl");
16+
let output = dir.path().join("out.jsonl");
17+
let data = [
18+
sample_line("1", "one"),
19+
sample_line("2", "two"),
20+
sample_line("1", "one"),
21+
]
22+
.join("\n");
23+
fs::write(&input, data + "\n")?;
24+
25+
Command::cargo_bin("codex")?
26+
.args([
27+
"memory",
28+
"compact",
29+
"--input",
30+
input.to_str().unwrap(),
31+
"--output",
32+
output.to_str().unwrap(),
33+
])
34+
.assert()
35+
.success()
36+
.stdout(contains("Read 3 entries, wrote 2 entries"));
37+
38+
let out_data = fs::read_to_string(&output)?;
39+
assert_eq!(out_data.lines().count(), 2);
40+
Ok(())
41+
}
42+
43+
#[test]
44+
fn memory_migrate_imports_entries() -> Result<(), Box<dyn std::error::Error>> {
45+
let dir = tempdir()?;
46+
let jsonl = dir.path().join("mem.jsonl");
47+
let sqlite = dir.path().join("mem.sqlite");
48+
let data = [sample_line("1", "one"), sample_line("2", "two")].join("\n");
49+
fs::write(&jsonl, data + "\n")?;
50+
51+
Command::cargo_bin("codex")?
52+
.args([
53+
"memory",
54+
"migrate",
55+
"--jsonl",
56+
jsonl.to_str().unwrap(),
57+
"--sqlite",
58+
sqlite.to_str().unwrap(),
59+
])
60+
.assert()
61+
.success()
62+
.stdout(contains("Migrated 2 entries"));
63+
64+
assert!(sqlite.exists());
65+
Ok(())
66+
}

codex-rs/memory/src/migrate.rs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ pub fn migrate_jsonl_to_sqlite(
99
jsonl_path: &std::path::Path,
1010
sqlite_path: &std::path::Path,
1111
) -> anyhow::Result<usize> {
12-
use crate::store::sqlite::SqliteMemoryStore;
1312
use crate::store::MemoryStore;
13+
use crate::store::sqlite::SqliteMemoryStore;
1414
use std::io::Read as _;
1515

1616
let mut data = String::new();
@@ -28,3 +28,61 @@ pub fn migrate_jsonl_to_sqlite(
2828
) -> anyhow::Result<usize> {
2929
anyhow::bail!("sqlite backend not compiled; enable with `--features codex-memory/sqlite`");
3030
}
31+
32+
/// Compact a JSONL file by removing duplicate entries based on the `id` field.
33+
///
34+
/// - `input_path`: source JSONL file
35+
/// - `output_path`: destination JSONL file (may be the same as `input_path`)
36+
///
37+
/// Returns a tuple of `(read_count, written_count)`.
38+
pub fn compact_jsonl(
39+
input_path: &std::path::Path,
40+
output_path: &std::path::Path,
41+
) -> anyhow::Result<(usize, usize)> {
42+
use crate::types::MemoryItem;
43+
use std::collections::HashSet;
44+
use std::io::BufRead as _;
45+
use std::io::BufReader;
46+
use std::io::BufWriter;
47+
use std::io::Write as _;
48+
49+
let infile = std::fs::File::open(input_path)?;
50+
let reader = BufReader::new(infile);
51+
52+
let tmp_path = if output_path == input_path {
53+
let mut p = output_path.to_path_buf();
54+
p.set_extension("jsonl.tmp");
55+
p
56+
} else {
57+
output_path.to_path_buf()
58+
};
59+
if let Some(parent) = tmp_path.parent() {
60+
std::fs::create_dir_all(parent)?;
61+
}
62+
let outfile = std::fs::File::create(&tmp_path)?;
63+
let mut writer = BufWriter::new(outfile);
64+
65+
let mut seen = HashSet::new();
66+
let mut read = 0usize;
67+
let mut written = 0usize;
68+
69+
for line in reader.lines() {
70+
let line = line?;
71+
let trimmed = line.trim();
72+
if trimmed.is_empty() {
73+
continue;
74+
}
75+
read += 1;
76+
if let Ok(item) = serde_json::from_str::<MemoryItem>(trimmed)
77+
&& seen.insert(item.id) {
78+
writer.write_all(trimmed.as_bytes())?;
79+
writer.write_all(b"\n")?;
80+
written += 1;
81+
}
82+
}
83+
writer.flush()?;
84+
if output_path == input_path {
85+
std::fs::rename(tmp_path, output_path)?;
86+
}
87+
Ok((read, written))
88+
}

0 commit comments

Comments
 (0)