Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 "<old>" "<new>"` joined both into the text and matched every entry; `filemap remove <path>` 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**
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions commands/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" <cmd>` — extra: `search`, `session <id>`, `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" <cmd>` — extra: `search`, `session <id>`, `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 "<substring>"`).
**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" <cmd>` — extra: `search`, `session <id>`, `index`, `dream`, `provenance`, `memory add|replace|remove|move|list`, `filemap show|add|replace|remove`.
**CLI:** `"${CLAUDE_PLUGIN_ROOT}/bin/lore" <cmd>` — extra: `search`, `session <id>`, `index`, `dream`, `provenance`, `memory add|replace|remove|move|list`, `filemap show|add|replace|remove` (replace/remove/move name the entry with `--match "<substring>"`).
**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 <VAR> 1`, clear via `lore config unset <VAR>`): `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.
11 changes: 11 additions & 0 deletions docs/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<substring>" "<new fact>"
lore memory remove --scope project --match "<substring>"
lore filemap replace --match "<substring>" <path> "<purpose>"
lore filemap remove --match "<substring>"
```

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.
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion rust/lore-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
46 changes: 38 additions & 8 deletions rust/lore-core/src/filemap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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);
Expand Down Expand Up @@ -189,6 +191,10 @@ pub fn action(cfg: &Config, req: &Value, authority: &Authority) -> Result<Value>
.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,
Expand Down Expand Up @@ -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:?}");
}
}
}
9 changes: 9 additions & 0 deletions rust/lore-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
99 changes: 84 additions & 15 deletions rust/lore-core/src/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ pub fn match_entries(entries: &[String], needle: &str) -> Vec<usize> {
.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<usize> {
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
Expand Down Expand Up @@ -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::<Vec<_>>()
.collect::<Vec<_>>();
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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}
Expand All @@ -678,7 +692,10 @@ pub fn move_action(cfg: &Config, req: &Value, authority: &Authority) -> Result<V
let (scope, key, _) = identity(cfg, req)?;
let to_scope = Scope::parse(req["to_scope"].as_str().unwrap_or(scope.name()))?;
let to = req["to"].as_str().ok_or(Error::InvalidRequest)?;
let needle = req["match"].as_str().ok_or(Error::InvalidRequest)?;
let needle = req["match"]
.as_str()
.filter(|needle| !needle.trim().is_empty())
.ok_or(Error::InvalidRequest)?;
if !authority.may_write() {
let id = gate::stage(
cfg,
Expand Down Expand Up @@ -970,4 +987,56 @@ mod tests {
let action_req = json!({"cwd":temp.path(),"scope":"user","action":"remove","entry":"fact","expected":{"key":review["key"],"sha256":review["sha256"]}});
assert_eq!(action(&cfg, &action_req, &auth()), Err(Error::Changed));
}
#[test]
fn direct_substring_misses_name_the_match_and_leave_memory_alone() {
let temp = tempfile::tempdir().unwrap();
let cfg = Config::for_root(temp.path().join("lore"));
let path = cfg.root.join("USER.md");
write_entries(&path, &["fact one".into(), "fact two".into()], 100).unwrap();
let before = fs::read(&path).unwrap();
for (verb, needle, expected) in [
("replace", "absent", Error::NoMatch),
("remove", "absent", Error::NoMatch),
("replace", "fact", Error::AmbiguousMatch),
("remove", "fact", Error::AmbiguousMatch),
("replace", "", Error::InvalidRequest),
("remove", " ", Error::InvalidRequest),
] {
let req = json!({"cwd":temp.path(),"scope":"user","action":verb,"match":needle,"text":"new fact"});
assert_eq!(direct_action(&cfg, &req, &auth()), Err(expected), "{verb} {needle:?}");
assert_eq!(fs::read(&path).unwrap(), before, "{verb} {needle:?}");
}
assert_eq!(Error::NoMatch.code(), "match_not_found");
assert_eq!(Error::AmbiguousMatch.code(), "match_ambiguous");
let req = json!({"cwd":temp.path(),"scope":"user","action":"replace","match":"one","text":"fact three"});
assert_eq!(direct_action(&cfg, &req, &auth()).unwrap()["status"], "applied");
assert_eq!(read_entries(&path).unwrap(), ["fact three", "fact two"]);
}
#[test]
fn move_substring_misses_name_the_match_and_move_nothing() {
let temp = tempfile::tempdir().unwrap();
let cfg = Config::for_root(temp.path().join("lore"));
let source = Scope::Project.path(&cfg, "a").unwrap();
let destination = Scope::Project.path(&cfg, "b").unwrap();
write_entries(&source, &["fact one".into(), "fact two".into()], 100).unwrap();
let before = fs::read(&source).unwrap();
for (needle, expected) in [("absent", Error::NoMatch), ("fact", Error::AmbiguousMatch)] {
assert_eq!(
move_entry(&cfg, Scope::Project, "a", needle, Scope::Project, "b", &auth()),
Err(expected)
);
assert_eq!(fs::read(&source).unwrap(), before);
assert!(!destination.exists());
}
}
#[test]
fn internal_exact_miss_still_reports_a_changed_file() {
let temp = tempfile::tempdir().unwrap();
let cfg = Config::for_root(temp.path().join("lore"));
files::atomic_write(&cfg.root.join("USER.md"), b"- shared fact extended\n").unwrap();
assert_eq!(
mutate(&cfg, Scope::User, "", "remove-exact", "shared fact", "", "direct", None, None, &auth()),
Err(Error::Changed)
);
}
}
Loading
Loading