Skip to content

Commit 1697a5a

Browse files
authored
Merge pull request gi-dellav#185 from xavierforge/feat/memory-edit-dedupe
feat(memory): add memory_edit, backups, and long-term append dedup
2 parents 33b3ce3 + 6f6e783 commit 1697a5a

6 files changed

Lines changed: 965 additions & 38 deletions

File tree

docs/MEMORY.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Enum selecting which file to write to:
5151

5252
### `WriteMode`
5353

54-
- `Append` — append content, inserting a `\n` separator if the file does not end with one
54+
- `Append` — append content, inserting a `\n` separator if the file does not end with one. For `long_term` only, appended lines are deduplicated (see [Long-term append deduplication](#long-term-append-deduplication))
5555
- `Overwrite` — replace the entire file
5656

5757
### `Mem`
@@ -120,7 +120,7 @@ Rules:
120120

121121
## Rig Tools
122122

123-
Three tools are registered when the `memory` feature is enabled:
123+
Four tools are registered when the `memory` feature is enabled:
124124

125125
### `memory_write`
126126

@@ -140,6 +140,38 @@ Three tools are registered when the `memory` feature is enabled:
140140

141141
`source=list` enumerates all `.md` files in the store (global MEMORY.md + current project's notes + daily logs).
142142

143+
### `memory_edit`
144+
145+
| Parameter | Type | Description |
146+
|---|---|---|
147+
| `target` | string | `long_term`, `scratchpad`, `daily`, or `note` |
148+
| `name` | string (opt) | Note stem (required for `note`); or a `YYYY-MM-DD` date for `daily` to edit an earlier day (defaults to today) |
149+
| `old_str` | string (opt) | Substring to replace; must occur exactly once. Omit to delete a whole note |
150+
| `new_str` | string | Replacement text; empty string deletes the matched substring |
151+
152+
Replaces a unique substring in a memory file in place. `old_str` is matched literally (no fuzzy matching) and must occur exactly once in the target file; zero or multiple matches fail without writing. `new_str` replaces the match verbatim with no newline cleanup, so an empty `new_str` deletes exactly the matched text, and including the trailing newline in `old_str` deletes a whole line.
153+
154+
Omitting `old_str` deletes an entire note file (`notes/<name>.md`) from disk; this requires `target=note` with a `name`. Omitting `old_str` for `long_term`, `scratchpad`, or `daily` is rejected and changes nothing. Deleting a note that does not exist is an error.
155+
156+
---
157+
158+
## Backups
159+
160+
Content-destroying mutations first copy the current file to a sibling `.bak` (single version, `MEMORY.md` becomes `MEMORY.bak`), so the pre-mutation content stays recoverable. There is exactly one `.bak` per file: each qualifying mutation overwrites the previous `.bak` rather than keeping a history. The `.bak` extension keeps these files out of `memory_read source=list` and `memory_search`, which both filter to `.md` only, so backups never leak into the model's context.
161+
162+
A backup is taken only before these operations (and only when the target file already exists: a first-ever overwrite of a not-yet-created file has nothing to back up and skips silently):
163+
164+
| Operation | `long_term` | `scratchpad` | `daily` | `note` |
165+
|---|---|---|---|---|
166+
| `memory_write` overwrite | Backs up | Backs up | No | No |
167+
| `memory_edit` content-replace (`old_str` given) | Backs up | Backs up | No | No |
168+
| `memory_edit` whole-note deletion (`old_str` omitted) | n/a | n/a | n/a | Backs up |
169+
| any append | No | No | No | No |
170+
171+
Appends are non-destructive by construction, so they never back up. `daily` and `note` content edits are targeted unique-match replacements (low-risk and already reversible via a re-edit), so they are deliberately left un-backed-up to avoid churn.
172+
173+
If the backup copy itself fails (for example the `.bak` path is not writable), the mutation still proceeds (the primary operation is what was asked for), but the tool response is suffixed with a `warning: backup failed, no .bak written` note so the caller knows there is no undo for that change. The failure is also logged.
174+
143175
### `memory_search`
144176

145177
| Parameter | Type | Description |
@@ -148,6 +180,26 @@ Three tools are registered when the `memory` feature is enabled:
148180

149181
---
150182

183+
## Long-term append deduplication
184+
185+
`MEMORY.md` is curated one fact per line, so `memory_write target=long_term mode=append` deduplicates its lines. This applies to `long_term` appends **only**: `scratchpad`, `daily`, and `note` appends are never deduplicated (repeats are preserved), and no target dedups on `overwrite`.
186+
187+
Comparison is whitespace-insensitive: each line is normalized by trimming and collapsing every run of Unicode whitespace (ASCII spaces/tabs and the full-width `U+3000` space) to a single ASCII space, preserving case. Two lines that differ only in whitespace width are duplicates.
188+
189+
For a `long_term` append batch (the incoming content split on `\n`):
190+
191+
1. Batch-internal duplicates are dropped, keeping the first occurrence.
192+
2. Lines whose normalized form already exists anywhere in `MEMORY.md` are dropped.
193+
3. Blank / whitespace-only lines normalize to empty; they are never a dedup key (they carry no fact) and are kept verbatim.
194+
4. If nothing meaningful survives, the write is skipped entirely and the file is left byte-for-byte unchanged.
195+
196+
The response message reflects the outcome:
197+
- No duplicates: `Wrote N bytes to <path>` (unchanged).
198+
- Partial dedup: `Wrote N bytes to <path> (skipped M duplicate line(s))`.
199+
- Whole batch dropped: `Nothing written to <path>: all M line(s) were duplicates`.
200+
201+
---
202+
151203
## Search Algorithm
152204

153205
`Mem::search(query)` implements a case-insensitive, multi-term keyword search:

src/agent/builder.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,11 +235,15 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
235235

236236
#[cfg(feature = "memory")]
237237
{
238-
use crate::extras::memory::{MemoryRead, MemorySearch, MemoryWrite};
238+
use crate::extras::memory::{MemoryEdit, MemoryRead, MemorySearch, MemoryWrite};
239239
all_tools.push(Box::new(MemoryWrite::new(
240240
permission.clone(),
241241
ask_tx.clone(),
242242
)));
243+
all_tools.push(Box::new(MemoryEdit::new(
244+
permission.clone(),
245+
ask_tx.clone(),
246+
)));
243247
all_tools.push(Box::new(MemoryRead::new(
244248
permission.clone(),
245249
ask_tx.clone(),

src/agent/prompt.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,23 @@ is already injected above; use the tools to read more or to persist new memory.
7171
7272
- memory_write target=long_term: durable facts, preferences, and decisions that \
7373
should ALWAYS be remembered (written to MEMORY.md, injected every session). Keep \
74-
it curated and concise.
74+
it curated and concise: write ONE fact per line. Appends are deduplicated \
75+
(whitespace-insensitive), so re-appending a line already present is skipped and \
76+
leaves the file unchanged.
7577
- memory_write target=daily: a running log of what happened today. Use for \
7678
progress, findings, and context worth recalling soon but not forever.
7779
- memory_write target=scratchpad: a checklist; write `- [ ]` items. Open items \
7880
are injected automatically; mark `- [x]` or rewrite with mode=overwrite when done.
7981
- memory_write target=note name=<stem>: longer reference material kept on disk \
8082
and NOT auto-injected. Find it later with memory_search, then read it in full \
8183
with memory_read source=note name=<stem>.
84+
- memory_edit: replace a unique substring in a memory file in place (target=\
85+
long_term, scratchpad, daily, or note). old_str must occur EXACTLY once, matched \
86+
literally, so include enough surrounding text to make it unique; a zero- or \
87+
multiple-match old_str fails without writing. Set new_str to an empty string to \
88+
delete the matched text, and include the trailing newline in old_str to delete a \
89+
whole line. Use this to fix or remove existing memory; use memory_write to append \
90+
or overwrite.
8291
- memory_search: keyword search over all memory (including older daily logs not \
8392
injected above). Space-separated words are separate terms. It locates relevant \
8493
files with a little context — to use a file's full content, follow up with \

0 commit comments

Comments
 (0)