poc-memory: POC_MEMORY_DRY_RUN=1 for agent testing

All mutating commands (write, delete, rename, link-add, journal write,
used, wrong, not-useful, gap) check POC_MEMORY_DRY_RUN after argument
validation but before mutation. If set, process exits silently — agent
tool calls are visible in the LLM output so we can see what it tried
to do without applying changes.

Read commands (render, search, graph link, journal tail) work normally
in dry-run mode so agents can still explore the graph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent Overstreet 2026-03-16 18:09:56 -04:00
parent 2ab9b78363
commit 7e131862d6
4 changed files with 18 additions and 0 deletions

View file

@ -134,6 +134,7 @@ pub fn cmd_triangle_close(min_degree: usize, sim_threshold: f32, max_per_hub: us
} }
pub fn cmd_link_add(source: &str, target: &str, reason: &[String]) -> Result<(), String> { pub fn cmd_link_add(source: &str, target: &str, reason: &[String]) -> Result<(), String> {
super::check_dry_run();
let mut store = store::Store::load()?; let mut store = store::Store::load()?;
let source = store.resolve_key(source)?; let source = store.resolve_key(source)?;
let target = store.resolve_key(target)?; let target = store.resolve_key(target)?;

View file

@ -176,6 +176,7 @@ pub fn cmd_journal_write(text: &[String]) -> Result<(), String> {
if text.is_empty() { if text.is_empty() {
return Err("journal-write requires text".into()); return Err("journal-write requires text".into());
} }
super::check_dry_run();
let text = text.join(" "); let text = text.join(" ");
let timestamp = crate::store::format_datetime(crate::store::now_epoch()); let timestamp = crate::store::format_datetime(crate::store::now_epoch());

View file

@ -9,3 +9,10 @@ pub mod agent;
pub mod admin; pub mod admin;
pub mod journal; pub mod journal;
pub mod misc; pub mod misc;
/// Exit silently if POC_MEMORY_DRY_RUN=1.
pub fn check_dry_run() {
if std::env::var("POC_MEMORY_DRY_RUN").map_or(false, |v| v == "1" || v == "true") {
std::process::exit(0);
}
}

View file

@ -11,6 +11,7 @@ pub fn cmd_used(key: &[String]) -> Result<(), String> {
if key.is_empty() { if key.is_empty() {
return Err("used requires a key".into()); return Err("used requires a key".into());
} }
super::check_dry_run();
let key = key.join(" "); let key = key.join(" ");
let mut store = store::Store::load()?; let mut store = store::Store::load()?;
let resolved = store.resolve_key(&key)?; let resolved = store.resolve_key(&key)?;
@ -38,6 +39,7 @@ pub fn cmd_used(key: &[String]) -> Result<(), String> {
pub fn cmd_wrong(key: &str, context: &[String]) -> Result<(), String> { pub fn cmd_wrong(key: &str, context: &[String]) -> Result<(), String> {
let ctx = if context.is_empty() { None } else { Some(context.join(" ")) }; let ctx = if context.is_empty() { None } else { Some(context.join(" ")) };
super::check_dry_run();
let mut store = store::Store::load()?; let mut store = store::Store::load()?;
let resolved = store.resolve_key(key)?; let resolved = store.resolve_key(key)?;
store.mark_wrong(&resolved, ctx.as_deref()); store.mark_wrong(&resolved, ctx.as_deref());
@ -71,6 +73,8 @@ pub fn cmd_not_relevant(key: &str) -> Result<(), String> {
} }
pub fn cmd_not_useful(key: &str) -> Result<(), String> { pub fn cmd_not_useful(key: &str) -> Result<(), String> {
// no args to validate
super::check_dry_run();
let mut store = store::Store::load()?; let mut store = store::Store::load()?;
let resolved = store.resolve_key(key)?; let resolved = store.resolve_key(key)?;
// Same as wrong but with clearer semantics: node content is bad, edges are fine. // Same as wrong but with clearer semantics: node content is bad, edges are fine.
@ -84,6 +88,7 @@ pub fn cmd_gap(description: &[String]) -> Result<(), String> {
if description.is_empty() { if description.is_empty() {
return Err("gap requires a description".into()); return Err("gap requires a description".into());
} }
super::check_dry_run();
let desc = description.join(" "); let desc = description.join(" ");
let mut store = store::Store::load()?; let mut store = store::Store::load()?;
store.record_gap(&desc); store.record_gap(&desc);
@ -146,6 +151,7 @@ pub fn cmd_node_delete(key: &[String]) -> Result<(), String> {
if key.is_empty() { if key.is_empty() {
return Err("node-delete requires a key".into()); return Err("node-delete requires a key".into());
} }
super::check_dry_run();
let key = key.join(" "); let key = key.join(" ");
let mut store = store::Store::load()?; let mut store = store::Store::load()?;
let resolved = store.resolve_key(&key)?; let resolved = store.resolve_key(&key)?;
@ -156,6 +162,8 @@ pub fn cmd_node_delete(key: &[String]) -> Result<(), String> {
} }
pub fn cmd_node_rename(old_key: &str, new_key: &str) -> Result<(), String> { pub fn cmd_node_rename(old_key: &str, new_key: &str) -> Result<(), String> {
// args are positional, always valid if present
super::check_dry_run();
let mut store = store::Store::load()?; let mut store = store::Store::load()?;
let old_resolved = store.resolve_key(old_key)?; let old_resolved = store.resolve_key(old_key)?;
store.rename_node(&old_resolved, new_key)?; store.rename_node(&old_resolved, new_key)?;
@ -286,6 +294,7 @@ pub fn cmd_write(key: &[String]) -> Result<(), String> {
if content.trim().is_empty() { if content.trim().is_empty() {
return Err("No content on stdin".into()); return Err("No content on stdin".into());
} }
super::check_dry_run();
let mut store = store::Store::load()?; let mut store = store::Store::load()?;
let key = store.resolve_key(&raw_key).unwrap_or(raw_key); let key = store.resolve_key(&raw_key).unwrap_or(raw_key);