diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4c955f7..c349572 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "lore", "source": "./", "description": "Shared memory for Claude Code, Codex, and DOXA: capped curated facts, evidence-backed beliefs, FTS5 session search, staged review, learned skills, and signed sync.", - "version": "0.62.24" + "version": "0.62.25" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6d824c2..ced730b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "lore", "description": "Memory for Claude Code that reasons about you and improves itself: Honcho-pattern belief store with deriver, dreamer and dialectic (/lore:ask), automatic skillification with a closed judge/update/retire loop, on a Hermes-pattern core \u2014 hard-capped curated memory, FTS5 session search, staged review.", - "version": "0.62.24", + "version": "0.62.25", "author": { "name": "Fabian Wilde", "email": "docwilde@proton.me" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb94c7..ca565c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.62.25 — 2026-10-20 + +**Memory match errors** + +- Fix **`memory replace|remove|move`** and **`filemap replace|remove`** reporting `review_changed` when `--match` hit no entry or several. They now return **`match_not_found`** or **`match_ambiguous`** and change nothing: the caller's match needs fixing, not a concurrent writer or stale review. +- Refuse a missing or empty `--match`, and a positional these commands do not take, as `invalid_request`. Previously `memory replace "" ""` joined both into the text and matched every entry; `filemap remove ` removed the only row of a one-row map. +- Report an unresolved or ambiguous project name (`memory move --to`, `project move`, `backfill --project`) the same way. `review_changed` remains for reviewed-snapshot, exact sync-replay and pending-listing changes. + ## 0.62.24 — 2026-10-19 **Diagnostic traces** diff --git a/Cargo.lock b/Cargo.lock index 484e549..24a35c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -612,7 +612,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lore-core" -version = "0.62.24" +version = "0.62.25" dependencies = [ "fancy-regex", "hmac", diff --git a/commands/help.md b/commands/help.md index d6c5b66..8a62480 100644 --- a/commands/help.md +++ b/commands/help.md @@ -30,10 +30,10 @@ Show this reference card, formatted as below, nothing else: **Flow:** sessions end → deriver stages proposals → you triage in `/lore:pending` → dreamer reconciles beliefs. Nothing writes to memory without approval. -**CLI:** `"${CLAUDE_PLUGIN_ROOT}/bin/lore" ` — extra: `search`, `session `, `index`, `dream`, `crosscheck` (read-only: `user` vs `user-model` near-duplicate beliefs), `memory add|replace|remove|move|list`, `filemap show|add|replace|remove`. +**CLI:** `"${CLAUDE_PLUGIN_ROOT}/bin/lore" ` — extra: `search`, `session `, `index`, `dream`, `crosscheck` (read-only: `user` vs `user-model` near-duplicate beliefs), `memory add|replace|remove|move|list`, `filemap show|add|replace|remove` (replace/remove/move name the entry with `--match ""`). **Env knobs:** `LORE_USER_CAP` / `LORE_MEMORY_CAP` (9000/8800), `LORE_FILEMAP_CAP` (4400), `LORE_MACHINE_CAP` (4400) / `LORE_MACHINE_HOST`, `LORE_CLUSTER_BLOCK` (0.30) / `LORE_CLUSTER_MODEL` (haiku, `off` to skip), `LORE_DIGEST_LAST_N` / `LORE_DIGEST_TOTAL_CAP` (500/250k), `LORE_MEMORY_PROPOSAL_CAP` (3) / `LORE_DUP_CONTAINMENT` (0.60), `LORE_REFRESH_SECS`, `LORE_DEFER_DREAM`, `LORE_BELIEF_DORMANT_DAYS` (45) / `LORE_INCLUDE_DORMANT`, `LORE_NOTIFY`, `LORE_SKIP`. **Write gate (0.36.0):** the interactive agent and a human shell write directly; a write arriving from a HOOK or a detached process stages in `pending/` instead, tagged with the context that wrote it. `lore provenance` shows how every entry got in (approved / interactive / derived / unknown for pre-0.36 rows). Advisory, not a boundary — a caller that knows about the gate can forge what it reads; the README says so plainly. -**CLI:** `"${CLAUDE_PLUGIN_ROOT}/bin/lore" ` — extra: `search`, `session `, `index`, `dream`, `provenance`, `memory add|replace|remove|move|list`, `filemap show|add|replace|remove`. +**CLI:** `"${CLAUDE_PLUGIN_ROOT}/bin/lore" ` — extra: `search`, `session `, `index`, `dream`, `provenance`, `memory add|replace|remove|move|list`, `filemap show|add|replace|remove` (replace/remove/move name the entry with `--match ""`). **Env knobs:** `LORE_USER_CAP` / `LORE_MEMORY_CAP` (2750/8800), `LORE_FILEMAP_CAP` (4400), `LORE_DIGEST_LAST_N` / `LORE_DIGEST_TOTAL_CAP` (500/250k), `LORE_REFRESH_SECS`, `LORE_DEFER_DREAM`, `LORE_BELIEF_DORMANT_DAYS` (45) / `LORE_INCLUDE_DORMANT`, `LORE_NOTIFY`, `LORE_SKIP`. **Stage switches** (all default on; set via `lore config set 1`, clear via `lore config unset `): `LORE_DISABLE_INJECT` (snapshot), `LORE_DISABLE_INDEX` (session index), `LORE_DISABLE_REVIEW` (SessionEnd + PreCompact review), `LORE_DISABLE_PRECOMPACT` (PreCompact review only), `LORE_DISABLE_BELIEFS` (belief store), `LORE_DISABLE_SKILLS` (skillification); `LORE_STREAM_INDEX=1` is the one opt-in (streaming); `LORE_CONSULT=1` opts into act-time consult. `LORE_SKIP` overrides them all. diff --git a/docs/manual.md b/docs/manual.md index 9de61de..7f41f7e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -66,6 +66,17 @@ from that project. A project means the **git repo root**, so a session started in `repo/viz` shares the repo's memory instead of forking an invisible second scope. +Edit an existing entry by naming it with `--match`, a case-insensitive substring that must hit exactly one entry: + +```sh +lore memory replace --scope project --match "" "" +lore memory remove --scope project --match "" +lore filemap replace --match "" "" +lore filemap remove --match "" +``` + +The match is only ever the flag; positional words are the new text (memory) or path and purpose (file map). A missing or empty `--match`, or a positional the command does not take, is `invalid_request`. A match that hits no entry is `match_not_found`; one that hits several is `match_ambiguous` and changes nothing — use a longer substring. `review_changed` is reserved for a reviewed snapshot that changed underneath you. + `lore index` and `lore search` read Claude transcripts from `~/.claude/projects` and native Codex transcripts from `${CODEX_HOME:-~/.codex}/sessions`. Both use the same project-scoped FTS5 index. Search hits and `lore session` display the source engine; it is provenance, not a separate memory scope. Codex sessions use ids prefixed with `codex:` in LORE. DOXA's Codex transcripts are already indexed in Claude-shaped form, so their matching native Codex rollouts are skipped when DOXA's thread sidecar is present. Override the native directory with `LORE_CODEX_SESSIONS_DIR`. - A project-scoped fact defaults to the repo it was learned in. When a session is clearly about a different repo (reviewing another repo's PR, discussing a plugin from its consumer), the reviewer can name that project explicitly; a resolvable name retargets the write and shows as a cross-project note in `/lore:pending`. An unresolvable name stays filed under the session's own project rather than guessing, flagged the same way. diff --git a/plugin.json b/plugin.json index 8f16dbb..46647d9 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "lore", - "version": "0.62.24", + "version": "0.62.25", "description": "Unified user, repo, and session memory for Claude Code, Codex, and DOXA", "author": { "name": "Dr. Fabian Wilde", diff --git a/rust/lore-core/Cargo.toml b/rust/lore-core/Cargo.toml index 12be6c4..9834d00 100644 --- a/rust/lore-core/Cargo.toml +++ b/rust/lore-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lore-core" -version = "0.62.24" +version = "0.62.25" edition = "2021" license = "AGPL-3.0-only" description = "Native LORE memory, beliefs, review and session index" diff --git a/rust/lore-core/src/filemap.rs b/rust/lore-core/src/filemap.rs index 5118b9a..fb5b081 100644 --- a/rust/lore-core/src/filemap.rs +++ b/rust/lore-core/src/filemap.rs @@ -131,19 +131,21 @@ pub(crate) fn mutate_locked( } } "replace" | "remove" => { - let hits = if exact { - entries + // As in memory: an exact key missing means the file changed since + // it was resolved; a substring missing is the caller's match. + let index = if exact { + let hits = entries .iter() .enumerate() .filter_map(|(i, entry)| (entry == needle).then_some(i)) - .collect() + .collect::>(); + if hits.len() != 1 { + return Err(Error::Changed); + } + hits[0] } else { - memory::match_entries(&entries, needle) + memory::single_match(&memory::match_entries(&entries, needle))? }; - if hits.len() != 1 { - return Err(Error::Changed); - } - let index = hits[0]; old = Some(entries[index].clone()); if action == "remove" { entries.remove(index); @@ -189,6 +191,10 @@ pub fn action(cfg: &Config, req: &Value, authority: &Authority) -> Result .get("purpose") .map_or(Some(""), Value::as_str) .ok_or(Error::InvalidRequest)?; + // An empty substring matches every row; see memory::direct_action. + if action != "add" && needle.trim().is_empty() { + return Err(Error::InvalidRequest); + } if !authority.may_write() { let id = gate::stage( cfg, @@ -289,4 +295,28 @@ mod tests { ); assert!(path(&cfg, "../outside").is_err()); } + #[test] + fn substring_misses_name_the_match_and_leave_the_map_alone() { + let temp = tempfile::tempdir().unwrap(); + let cfg = Config::for_root(temp.path().join("lore")); + let authority = Authority::Interactive { + agent: "fixture".into(), + engine: "codex".into(), + }; + let file = path(&cfg, &project_slug(temp.path())).unwrap(); + files::atomic_write(&file, "- src/a.rs \u{2014} first\n- src/b.rs \u{2014} second\n".as_bytes()).unwrap(); + let before = std::fs::read(&file).unwrap(); + for (verb, needle, expected) in [ + ("replace", "absent", Error::NoMatch), + ("remove", "absent", Error::NoMatch), + ("replace", "src/", Error::AmbiguousMatch), + ("remove", "src/", Error::AmbiguousMatch), + ("replace", "", Error::InvalidRequest), + ("remove", " ", Error::InvalidRequest), + ] { + let req = json!({"cwd":temp.path(),"action":verb,"match":needle,"path":"src/c.rs","purpose":"third"}); + assert_eq!(action(&cfg, &req, &authority), Err(expected), "{verb} {needle:?}"); + assert_eq!(std::fs::read(&file).unwrap(), before, "{verb} {needle:?}"); + } + } } diff --git a/rust/lore-core/src/lib.rs b/rust/lore-core/src/lib.rs index 93ccb29..2b08bca 100644 --- a/rust/lore-core/src/lib.rs +++ b/rust/lore-core/src/lib.rs @@ -42,7 +42,14 @@ pub enum Error { UnsafePath, TooLarge, Timeout, + /// A reviewed snapshot or source changed between read and write. Changed, + /// A caller-supplied substring matched no entry. Distinct from `Changed`: + /// the caller's match is what to fix, not a concurrent writer. + NoMatch, + /// A caller-supplied substring matched more than one entry; a longer + /// substring picks one. Refused rather than guessing which to rewrite. + AmbiguousMatch, OverCap, Untrusted, Unsupported, @@ -57,6 +64,8 @@ impl Error { Self::TooLarge => "output_too_large", Self::Timeout => "timeout", Self::Changed => "review_changed", + Self::NoMatch => "match_not_found", + Self::AmbiguousMatch => "match_ambiguous", Self::OverCap => "over_cap", Self::Untrusted => "untrusted_write", Self::Unsupported => "unavailable_operation", diff --git a/rust/lore-core/src/memory.rs b/rust/lore-core/src/memory.rs index 0567301..d04e260 100644 --- a/rust/lore-core/src/memory.rs +++ b/rust/lore-core/src/memory.rs @@ -85,6 +85,16 @@ pub fn match_entries(entries: &[String], needle: &str) -> Vec { .filter_map(|(index, entry)| entry.to_lowercase().contains(&needle).then_some(index)) .collect() } +/// The single entry a caller's substring names. Zero or several hits are the +/// caller's match to fix, so they get their own codes: `review_changed` would +/// send them looking for a concurrent writer or a stale review instead. +pub fn single_match(hits: &[usize]) -> Result { + match hits { + [index] => Ok(*index), + [] => Err(Error::NoMatch), + _ => Err(Error::AmbiguousMatch), + } +} pub fn write_entries(path: &Path, entries: &[String], cap: usize) -> Result<()> { // Persist a text-ordered set irrespective of local authorship/receipt order. // UTF-8 byte order matches Python's Unicode code point order. Keep reads @@ -390,19 +400,21 @@ pub fn mutate_locked( None } "replace" | "remove" => { - let hits = if exact { - entries + // An exact key was resolved earlier (sync replay), so a miss means + // the file changed since. A substring is the caller's own match. + let index = if exact { + let hits = entries .iter() .enumerate() .filter_map(|(index, entry)| (entry == needle).then_some(index)) - .collect::>() + .collect::>(); + if hits.len() != 1 { + return Err(Error::Changed); + } + hits[0] } else { - match_entries(&entries, needle) + single_match(&match_entries(&entries, needle))? }; - if hits.len() != 1 { - return Err(Error::Changed); - } - let index = hits[0]; let old = entries[index].clone(); if action == "remove" { entries.remove(index); @@ -553,6 +565,11 @@ pub fn direct_action(cfg: &Config, req: &Value, authority: &Authority) -> Result if action == "add" && text.is_empty() { return Err(Error::InvalidRequest); } + // An empty substring matches every entry, so it can only ever be refused + // later as ambiguous or, on a one-entry file, rewrite that entry unasked. + if action != "add" && needle.trim().is_empty() { + return Err(Error::InvalidRequest); + } if !authority.may_write() { let id = gate::stage( cfg, @@ -620,11 +637,8 @@ fn move_with_writer( cfg.timeout, )?; let mut src = read_entries(&source)?; - let hits = match_entries(&src, needle); - if hits.len() != 1 { - return Err(Error::Changed); - } - let text = src[hits[0]].clone(); + let index = single_match(&match_entries(&src, needle))?; + let text = src[index].clone(); let mut dst = read_entries(&destination)?; let src_bucket = scope.bucket(from); let provenance = gate::provenance(cfg, "memory", &src_bucket, &text); @@ -654,7 +668,7 @@ fn move_with_writer( return Ok(MoveOutcome::DestinationOnly(error)); } } - src.remove(hits[0]); + src.remove(index); if let Err(error) = write(&source, &src, scope.cap(cfg)) { return Ok(MoveOutcome::DestinationOnly(error)); } @@ -678,7 +692,10 @@ pub fn move_action(cfg: &Config, req: &Value, authority: &Authority) -> Result Result<()> { } Ok(()) } +/// `pos` keeps a flag-supplied value and ignores positionals; refuse that +/// combination instead, since nothing would read the positionals. +fn no_positionals_beside(req: &Value, name: &str, p: &[String]) -> Result<()> { + if req.get(name).is_some() && !p.is_empty() { + return Err(Error::InvalidRequest); + } + Ok(()) +} fn id(req: &mut Value, p: &[String]) -> Result<()> { if req.get("belief_id").is_none() { req["belief_id"] = json!(p @@ -380,8 +389,15 @@ pub fn run(args: &[String]) -> Result<()> { ("memory", "usage") => crate::memory::usage(&cfg, &req)?, ("memory", action @ ("add" | "replace" | "remove" | "move")) => { req["action"] = json!(action); + // The entry to change is named only by --match. Text stays + // variadic as in bin/lore.py, so a positional is never the match: + // refuse one where it can only be a misplaced match (or a text + // already given as --text) instead of dropping it unread. if matches!(action, "add" | "replace") { + no_positionals_beside(&req, "text", &p)?; pos(&mut req, "text", &p, true)? + } else if !p.is_empty() { + return Err(Error::InvalidRequest); } if let Some(to) = req.get("to_machine").cloned() { req["to_scope"] = json!("machine"); @@ -398,7 +414,13 @@ pub fn run(args: &[String]) -> Result<()> { ("filemap", "show") => crate::filemap::show(&cfg, &req)?, ("filemap", action @ ("add" | "replace" | "remove")) => { req["action"] = json!(action); + // As for memory: a positional on remove can only be a misplaced + // --match, so it is refused rather than silently dropped. + if action == "remove" && !p.is_empty() { + return Err(Error::InvalidRequest); + } if action != "remove" { + no_positionals_beside(&req, "path", &p)?; pos(&mut req, "path", &p, false)?; pos(&mut req, "purpose", p.get(1..).unwrap_or(&[]), true)? } diff --git a/rust/lore-core/src/standalone_ops.rs b/rust/lore-core/src/standalone_ops.rs index 6eb1146..34fc449 100644 --- a/rust/lore-core/src/standalone_ops.rs +++ b/rust/lore-core/src/standalone_ops.rs @@ -416,8 +416,11 @@ pub fn backfill(cfg: &Config, req: &Value) -> Result { .cloned() .collect() }; - if matched.len() != 1 { - return Err(Error::Changed); + // A --project term the caller typed, not a snapshot that moved. + match matched.len() { + 1 => {} + 0 => return Err(Error::NoMatch), + _ => return Err(Error::AmbiguousMatch), } chosen.insert(matched[0].clone()); } @@ -830,10 +833,11 @@ fn resolve(raw: &str, known: &BTreeSet) -> Result { .cloned() .collect::>() }; - if matches.len() == 1 { - Ok(matches[0].clone()) - } else { - Err(Error::Changed) + // A project name the caller typed (`memory move --to`, `project move`). + match matches.len() { + 1 => Ok(matches[0].clone()), + 0 => Err(Error::NoMatch), + _ => Err(Error::AmbiguousMatch), } } pub fn resolve_destination(cfg: &Config, raw: &str) -> Result { diff --git a/rust/lore-core/tests/cli.rs b/rust/lore-core/tests/cli.rs index 5245e59..64d6672 100644 --- a/rust/lore-core/tests/cli.rs +++ b/rust/lore-core/tests/cli.rs @@ -366,6 +366,64 @@ fn codex_session_writes_apply_with_interactive_provenance() { } } +#[test] +fn memory_and_filemap_mutations_refuse_a_missing_or_misplaced_match() { + // The reported case was `memory replace --scope project "" ""`: + // both positionals became the text, the match stayed empty and the write + // was refused as review_changed. The match is a flag; say so. + let fixture = Fixture::new(); + let env = [("CODEX_THREAD_ID", "fixture-codex-session")]; + fixture.write(&fixture.project(), "- old project fact\n- other project fact\n"); + fixture.write(&fixture.map(), "- src/a.rs \u{2014} first\n"); + let memory = fs::read(fixture.project()).unwrap(); + let map = fs::read(fixture.map()).unwrap(); + for args in [ + vec!["memory", "replace", "--scope", "project", "old project fact", "new project fact"], + vec!["memory", "replace", "--scope", "project", "new project fact"], + vec!["memory", "replace", "--scope", "project", "--match", "", "new project fact"], + vec!["memory", "replace", "--scope", "project", "--match", "old", "--text", "new", "stray"], + vec!["memory", "remove", "--scope", "project", "old project fact"], + vec!["memory", "remove", "--scope", "project", "--match", "old", "stray"], + vec!["memory", "move", "--scope", "project", "--match", "old", "--to-machine", "owned-host", "stray"], + vec!["memory", "move", "--scope", "project", "--match", " ", "--to-machine", "owned-host"], + vec!["filemap", "remove", "src/a.rs"], + vec!["filemap", "replace", "src/b.rs", "second"], + vec!["filemap", "remove", "--match", "a.rs", "stray"], + ] { + let (success, out, err) = fixture.invoke_env(&args, &env); + assert!(!success && out.is_empty(), "{args:?}: {out}"); + assert_eq!(err, "lore-rs: invalid_request\n", "{args:?}"); + assert_eq!(fs::read(fixture.project()).unwrap(), memory, "{args:?}"); + assert_eq!(fs::read(fixture.map()).unwrap(), map, "{args:?}"); + } + for (needle, code) in [("absent", "match_not_found"), ("project fact", "match_ambiguous")] { + let (success, out, err) = fixture.invoke_env( + &["memory", "replace", "--scope", "project", "--match", needle, "new project fact"], + &env, + ); + assert!(!success && out.is_empty(), "{needle}: {out}"); + assert_eq!(err, format!("lore-rs: {code}\n")); + assert_eq!(fs::read(fixture.project()).unwrap(), memory); + } + // A destination project name that resolves to nothing is the same kind + // of miss, reported the same way. + let (success, out, err) = fixture.invoke_env( + &["memory", "move", "--scope", "project", "--match", "old", "--to", "no-such-project"], + &env, + ); + assert!(!success && out.is_empty(), "{out}"); + assert_eq!(err, "lore-rs: match_not_found\n"); + assert_eq!(fs::read(fixture.project()).unwrap(), memory); + let (success, out, err) = fixture.invoke_env( + &["memory", "replace", "--scope", "project", "--match", "old", "new project fact"], + &env, + ); + assert!(success && err.is_empty(), "{out} {err}"); + assert_eq!( + fs::read_to_string(fixture.project()).unwrap(), + "- new project fact\n- other project fact\n" + ); +} #[test] fn codex_session_can_write_config_and_preserves_explicit_source_engine() { let fixture = Fixture::new();