Summary
icm recall and other read-like CLI commands currently require writable SQLite access. In a read-only automation/sandbox environment, this means ICM recall cannot be used for read-only audits even when the database already exists and the user only wants to inspect memory.
Environment
icm 0.10.50
- Codex scheduled automation with read-only filesystem access and network enabled
- Existing ICM SQLite database
Reproduction
-
Run in an environment where the ICM database already exists, but the filesystem/database cannot be written to.
-
Run a read-like command such as:
Similar symptoms can affect other read-like commands such as recall-context, stats, or health if they go through the normal store open path.
Observed behavior
The command fails while opening/using the database, with errors along the lines of failed to open database / attempt to write a readonly database.
For read-only Codex automations, this means the ICM CLI cannot be used safely for recall-only inspection. The workaround is to bypass the CLI and inspect SQLite directly with a read-only/immutable connection, which loses the normal ICM recall behavior.
Expected behavior
Read-like commands should be usable against an existing database in a read-only environment, or there should be an explicit read-only mode such as --read-only / ICM_READONLY=1.
In read-only mode, I would expect ICM to:
- Open the database read-only/open-existing.
- Skip parent directory creation and schema initialization.
- Avoid
PRAGMA journal_mode=WAL or any other operation that requires writes.
- Skip auto-decay and
last_accessed / access_count updates.
- Fail clearly if the database is missing or the schema is incompatible.
Source pointers
At 804dac22ffefe00f6e81c327f99d435d176db415:
open_store is called before command dispatch, so recall uses the normal writable store path:
|
fn open_store(db: Option<PathBuf>, embedding_dims: usize) -> Result<SqliteStore> { |
|
let path = db.unwrap_or_else(default_db_path); |
|
SqliteStore::with_dims(&path, embedding_dims).context("failed to open database") |
|
} |
|
|
|
#[cfg(feature = "embeddings")] |
|
fn init_embedder(model: &str) -> Option<icm_core::FastEmbedder> { |
|
Some(icm_core::FastEmbedder::with_model(model)) |
|
} |
|
|
|
#[cfg(not(feature = "embeddings"))] |
|
fn init_embedder(_model: &str) -> Option<()> { |
|
None |
|
} |
|
|
|
fn main() -> Result<()> { |
|
// Reset SIGPIPE to default so piped commands (e.g. `icm export | head`) |
|
// don't panic on broken pipe. |
|
#[cfg(unix)] |
|
{ |
|
unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) }; |
|
} |
|
|
|
tracing_subscriber::fmt() |
|
.with_env_filter( |
|
tracing_subscriber::EnvFilter::from_default_env() |
|
.add_directive(tracing_subscriber::filter::LevelFilter::WARN.into()), |
|
) |
|
.init(); |
|
|
|
let cli = Cli::parse(); |
|
let cfg = config::load_config()?; |
|
let embeddings_enabled = |
|
cfg.embeddings.enabled && !cli.no_embeddings && std::env::var("ICM_NO_EMBEDDINGS").is_err(); |
|
#[allow(unused_variables)] |
|
let embedder = if embeddings_enabled { |
|
init_embedder(&cfg.embeddings.model) |
|
} else { |
|
None |
|
}; |
|
let embedding_dims = embedder |
|
.as_ref() |
|
.map(|e| { |
|
use icm_core::Embedder; |
|
e.dimensions() |
|
}) |
|
.unwrap_or(icm_core::DEFAULT_EMBEDDING_DIMS); |
|
// Audit #185 medium: reject `--db A ... --db B` (or with `=`) |
|
// instead of silently letting the last occurrence win. Clap |
|
// alone doesn't catch the parent+subcommand split case (the |
|
// `global = true` flag silently overrides across command |
|
// levels), so we scan raw argv pre-parse: any flag that starts |
|
// with `--db` (whether `--db PATH` or `--db=PATH`) counts as one |
|
// occurrence. The user is most likely passing the wrong DB by |
|
// accident; saying so is safer than writing to the unintended |
|
// path. |
|
{ |
|
let argv: Vec<String> = std::env::args().collect(); |
|
let db_count = argv |
|
.iter() |
|
.skip(1) |
|
.filter(|a| *a == "--db" || a.starts_with("--db=")) |
|
.count(); |
|
if db_count > 1 { |
|
anyhow::bail!("--db can only be specified once; got {db_count} occurrences"); |
|
} |
|
} |
|
let cli_db: Option<PathBuf> = cli.db.into_iter().next(); |
|
let db_path = cli_db.clone().unwrap_or_else(default_db_path); |
|
|
|
// `icm uninstall` must NOT open the SQLite store: a default |
|
// `open_store` call would recreate the DB directory and WAL/SHM files |
|
// immediately after `--purge-data` removed them, leaving the user's |
|
// data dir non-empty even though the run reported success. Dispatch |
|
// it before `open_store` runs. |
|
let command = cli.command; |
|
if let Commands::Uninstall(opts) = command { |
|
let code = uninstall::run(opts)?; |
|
std::process::exit(code); |
|
} |
|
|
|
let store = open_store(cli_db, embedding_dims)?; |
SqliteStore::with_dims creates parent directories, uses Connection::open, sets WAL, and initializes the schema:
|
impl SqliteStore { |
|
pub fn new(path: &Path) -> IcmResult<Self> { |
|
Self::with_dims(path, icm_core::DEFAULT_EMBEDDING_DIMS) |
|
} |
|
|
|
/// Open or create a store with a specific embedding dimension. |
|
pub fn with_dims(path: &Path, embedding_dims: usize) -> IcmResult<Self> { |
|
ensure_sqlite_vec(); |
|
if let Some(parent) = path.parent() { |
|
std::fs::create_dir_all(parent) |
|
.map_err(|e| IcmError::Database(format!("cannot create db directory: {e}")))?; |
|
} |
|
let conn = Connection::open(path) |
|
.map_err(|e| IcmError::Database(format!("cannot open database: {e}")))?; |
|
conn.execute_batch( |
|
"PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=30000;", |
|
) |
|
.map_err(db_err)?; |
|
init_db_with_dims(&conn, embedding_dims)?; |
|
Ok(Self { |
|
conn, |
|
cache: Mutex::new(new_cache()), |
|
}) |
|
} |
- Recall can perform auto-decay metadata writes:
|
/// Apply decay if more than 24 hours since last decay. |
|
/// Called automatically on recall to avoid manual `icm decay` cron. |
|
pub fn maybe_auto_decay(&self) -> IcmResult<()> { |
|
let now = Utc::now(); |
|
let now_str = now.to_rfc3339(); |
|
|
|
// Atomic check-and-update: only one caller wins the race. |
|
// First, try to claim the decay slot by inserting or conditionally updating. |
|
let changed = self |
|
.conn |
|
.execute( |
|
"INSERT INTO icm_metadata (key, value) VALUES ('last_decay_at', ?1) |
|
ON CONFLICT(key) DO UPDATE SET value = ?1 |
|
WHERE value IS NULL OR julianday(?1) - julianday(value) >= 1.0", |
|
params![now_str], |
|
) |
|
.map_err(db_err)?; |
|
|
|
if changed > 0 { |
|
self.apply_decay(0.95)?; |
|
} |
|
|
|
Ok(()) |
- Recall also updates access metadata:
|
fn update_access(&self, id: &str) -> IcmResult<()> { |
|
let now = Utc::now().to_rfc3339(); |
|
let changed = self |
|
.conn |
|
.execute( |
|
"UPDATE memories SET last_accessed = ?1, access_count = access_count + 1 WHERE id = ?2", |
|
params![now, id], |
|
) |
|
.map_err(db_err)?; |
|
|
|
if changed == 0 { |
|
return Err(IcmError::NotFound(id.to_string())); |
|
} |
|
self.cache_invalidate(id); |
|
Ok(()) |
|
} |
|
|
|
fn batch_update_access(&self, ids: &[&str]) -> IcmResult<usize> { |
|
if ids.is_empty() { |
|
return Ok(0); |
|
} |
|
let now = Utc::now().to_rfc3339(); |
|
let placeholders: Vec<String> = (2..=ids.len() + 1).map(|i| format!("?{i}")).collect(); |
|
let sql = format!( |
|
"UPDATE memories SET last_accessed = ?1, access_count = access_count + 1 WHERE id IN ({})", |
|
placeholders.join(", ") |
|
); |
Why this matters
Codex scheduled automations can be intentionally read-only but still useful for reports/audits. Those automations should be able to recall ICM context without requiring write access to the memory database.
Summary
icm recalland other read-like CLI commands currently require writable SQLite access. In a read-only automation/sandbox environment, this means ICM recall cannot be used for read-only audits even when the database already exists and the user only wants to inspect memory.Environment
icm 0.10.50Reproduction
Run in an environment where the ICM database already exists, but the filesystem/database cannot be written to.
Run a read-like command such as:
icm recall "test query"Similar symptoms can affect other read-like commands such as
recall-context,stats, orhealthif they go through the normal store open path.Observed behavior
The command fails while opening/using the database, with errors along the lines of
failed to open database/attempt to write a readonly database.For read-only Codex automations, this means the ICM CLI cannot be used safely for recall-only inspection. The workaround is to bypass the CLI and inspect SQLite directly with a read-only/immutable connection, which loses the normal ICM recall behavior.
Expected behavior
Read-like commands should be usable against an existing database in a read-only environment, or there should be an explicit read-only mode such as
--read-only/ICM_READONLY=1.In read-only mode, I would expect ICM to:
PRAGMA journal_mode=WALor any other operation that requires writes.last_accessed/access_countupdates.Source pointers
At
804dac22ffefe00f6e81c327f99d435d176db415:open_storeis called before command dispatch, sorecalluses the normal writable store path:icm/crates/icm-cli/src/main.rs
Lines 1047 to 1128 in 804dac2
SqliteStore::with_dimscreates parent directories, usesConnection::open, sets WAL, and initializes the schema:icm/crates/icm-store/src/store.rs
Lines 103 to 126 in 804dac2
icm/crates/icm-store/src/store.rs
Lines 128 to 150 in 804dac2
icm/crates/icm-store/src/store.rs
Lines 1227 to 1253 in 804dac2
Why this matters
Codex scheduled automations can be intentionally read-only but still useful for reports/audits. Those automations should be able to recall ICM context without requiring write access to the memory database.