Skip to content

feat: memory v2 — categories, dream cleanup, self-skeptical (Claude Code inspired)#61

Merged
kienbui1995 merged 2 commits intomainfrom
feat/memory-v2
Apr 14, 2026
Merged

feat: memory v2 — categories, dream cleanup, self-skeptical (Claude Code inspired)#61
kienbui1995 merged 2 commits intomainfrom
feat/memory-v2

Conversation

@kienbui1995
Copy link
Copy Markdown
Owner

@kienbui1995 kienbui1995 commented Apr 14, 2026

Memory v2

Inspired by Claude Code's leaked 4-layer memory architecture.

Changes

Feature Before After
Categories Flat KV 4 categories: project, user, feedback, reference
Prompt Raw list Grouped by category + self-skeptical hint
Cleanup Eviction only Dream compact (dedup, keep newest)
Auto-compact None On session start if >150 facts
Auto-save 10 patterns, no category 15+ patterns, auto-categorized
Tool spec key+value+delete +category enum

Self-skeptical (from Claude Code)

## Project Memory
*Treat as hints — verify against actual code before acting.*

274 tests, 0 fail.

Summary by CodeRabbit

  • New Features

    • Memory facts can be assigned categories (project, user, feedback, reference)
    • Tool accepts an optional category when saving memory facts
  • Improvements

    • Memory display in prompts is organized by category with clear headers
    • Auto-saved facts are now categorized automatically based on content
  • Bug Fixes / Maintenance

    • Automatic memory deduplication runs on startup when memory grows large, removing redundant entries

…pact

Inspired by Claude Code leaked memory architecture:

1. Memory categories: project, user, feedback, reference
   - Fact struct gains 'category' field (serde default: project)
   - memory_write tool accepts category parameter
   - to_prompt_section groups by category
   - Auto-memory detects category from content patterns

2. Self-skeptical prompt:
   - 'Treat as hints — verify against actual code before acting'
   - Injected in to_prompt_section header

3. Dream cleanup (compact):
   - memory.compact() deduplicates by key, keeps newest
   - /memory compact available via TUI
   - auto_compact_on_start(150) runs on session init

4. Auto-categorized detection:
   - 'convention is' / 'always use' → feedback
   - 'running on port' / 'located at' → reference
   - 'prefers' / 'user wants' → user
   - Default → project

274 tests, 0 fail.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 14, 2026

📝 Walkthrough

Walkthrough

Adds memory categorization (project, user, feedback, reference), persists categories with facts, auto-compacts duplicate facts on startup when above a threshold, and updates auto-save and tool schema to detect/submit categories.

Changes

Cohort / File(s) Summary
Memory Model & Persistence
mc/crates/mc-core/src/memory.rs
Added category: String to Fact (serde default "project"); introduced set_with_category() and delegated set() to it; added compact() and auto_compact_on_start(threshold); updated to_prompt_section() to render categorized sections; handle_write() accepts optional category and updates success message.
Auto-Save / Runtime
mc/crates/mc-core/src/runtime.rs
Replaced boolean fact detection with categorized detection returning (is_fact, category); writes use memory.set_with_category(key, value, category) so auto-saved facts include category.
CLI Initialization
mc/crates/mc-cli/src/main.rs
On TUI startup, load mutable MemoryStore, call auto_compact_on_start(150), then pass the (potentially compacted) store into rt.set_memory.
Tool Schema / Spec
mc/crates/mc-tools/src/spec.rs
Updated memory_write tool description and input schema: added optional category property (enum: project, user, feedback, reference) and documented categories in the tool description.

Sequence Diagram

sequenceDiagram
    participant CLI as CLI (mc-cli)
    participant Memory as MemoryStore
    participant Runtime as Runtime
    participant AutoSave as auto_save_memory

    CLI->>Memory: load_from_disk()
    activate Memory
    Memory-->>CLI: MemoryStore
    deactivate Memory

    CLI->>Memory: auto_compact_on_start(150)
    activate Memory
    Memory->>Memory: compact()  -- dedupe by key, keep newest
    Memory-->>CLI: compacted MemoryStore
    deactivate Memory

    CLI->>Runtime: set_memory(compacted_store)

    Runtime->>AutoSave: process output -> detect fact?
    activate AutoSave
    AutoSave->>AutoSave: determine (is_fact, category)
    AutoSave-->>Runtime: (is_fact, category)
    deactivate AutoSave

    Runtime->>Memory: set_with_category(key, value, category)
    activate Memory
    Memory->>Memory: store Fact {key, value, category, updated_at}
    Memory-->>Runtime: ok
    deactivate Memory
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through memory, tidy and bright,

Project, user, feedback—lined up just right.
Duplicates swept with a careful start,
Categories humming, each fact plays its part.
A little rabbit trimmed the store with delight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning Description includes a clear overview of changes via a feature comparison table and explains the self-skeptical addition, but is missing the structured template sections (What/Why/How) and the required checklist. Restructure to include all template sections: What (brief description), Why (motivation/issue), How (implementation details), and the Checklist with build/test verification steps.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately captures the main feature (memory v2 with categories), mentions key improvements (dream cleanup, self-skeptical), and references the inspiration (Claude Code).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/memory-v2

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces categorized memory storage for facts, allowing them to be grouped into 'project', 'user', 'feedback', and 'reference' categories. It updates the auto-detection logic to assign these categories, enhances prompt generation to display categorized sections, and adds a memory compaction feature to deduplicate entries. Feedback includes moving synchronous disk writes outside of loops to improve performance, optimizing the prompt section generation to avoid multiple iterations, aligning documentation with the compaction implementation, and using integer comparisons for timestamps to ensure reliability.

Comment on lines +1348 to 1349
memory.set_with_category(&key, trimmed, category);
let _ = memory.save();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

memory.save() is called inside the loop for every detected fact. Since save() performs a synchronous disk write, this can cause significant performance degradation if multiple facts are detected in a single response. It should be called once after the loop finishes to minimize disk I/O.

Comment on lines +124 to +143
for cat in &["project", "user", "feedback", "reference"] {
let facts: Vec<_> = self.facts.iter().filter(|f| f.category == *cat).collect();
if !facts.is_empty() {
section.push_str(&format!("\n### {}\n", capitalize(cat)));
for f in facts {
section.push_str(&format!("- {}: {}\n", f.key, f.value));
}
}
}
// Uncategorized
let other: Vec<_> = self
.facts
.iter()
.map(|f| format!("- {}: {}", f.key, f.value))
.filter(|f| {
!["project", "user", "feedback", "reference"].contains(&f.category.as_str())
})
.collect();
format!("\n\n## Project Memory\n{}", lines.join("\n"))
for f in other {
section.push_str(&format!("- {}: {}\n", f.key, f.value));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The to_prompt_section method iterates over the facts list multiple times (once for each hardcoded category plus once for uncategorized facts). This is inefficient and creates a maintenance burden whenever a new category is added. Consider grouping the facts in a single pass using a HashMap or similar structure, and deriving the category list from a central definition to ensure consistency with the tool specifications.

Comment on lines +199 to +200
/// Dream cleanup: remove duplicates, resolve contradictions (keep newest),
/// remove entries with same key prefix. Call on session start or /memory compact.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring for compact mentions removing entries with the same key prefix, but the implementation only deduplicates by exact key. If prefix-based deduplication is intended (e.g., to handle auto_ timestamped keys), the logic should be updated; otherwise, the docstring should be corrected to reflect the actual behavior.

for (i, f) in self.facts.iter().enumerate() {
seen.entry(f.key.clone())
.and_modify(|(idx, ts): &mut (usize, String)| {
if f.updated_at > *ts {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Comparing updated_at as a String lexicographically is unreliable for numeric timestamps if their string lengths differ (e.g., "100" < "99" is true). While Unix timestamps in seconds currently have the same number of digits, it is safer to parse them as integers before comparison to avoid future bugs when the timestamp length increases.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
mc/crates/mc-core/src/memory.rs (1)

324-336: ⚠️ Potential issue | 🟡 Minor

Move test inside mod tests block.

This test is defined outside the #[cfg(test)] mod tests block (which ends at line 322). It will compile into non-test builds, increasing binary size unnecessarily.

🧪 Move test into mod tests
-#[test]
-fn handle_write_delete() {
-    let path = std::env::temp_dir().join(format!("mc-mem-del-{}", std::process::id()));
-    let mut store = MemoryStore::load(&path, 100);
-    store.handle_write(&serde_json::json!({"key": "temp", "value": "data"}));
-    assert!(store
-        .handle_read(&serde_json::json!({"key": "temp"}))
-        .contains("data"));
-    store.handle_write(&serde_json::json!({"key": "temp", "delete": true}));
-    assert!(!store
-        .handle_read(&serde_json::json!({"key": "temp"}))
-        .contains("data"));
-    std::fs::remove_file(path).ok();
-}

Move this test inside the mod tests block above, using the tmp_path() helper for consistency:

#[test]
fn handle_write_delete() {
    let path = tmp_path();
    let mut store = MemoryStore::load(&path, 100);
    store.handle_write(&serde_json::json!({"key": "temp", "value": "data"}));
    assert!(store
        .handle_read(&serde_json::json!({"key": "temp"}))
        .contains("data"));
    store.handle_write(&serde_json::json!({"key": "temp", "delete": true}));
    assert!(!store
        .handle_read(&serde_json::json!({"key": "temp"}))
        .contains("data"));
    fs::remove_file(path).ok();
}

As per coding guidelines: "Unit tests must use #[cfg(test)] mod tests at bottom of each file."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mc/crates/mc-core/src/memory.rs` around lines 324 - 336, The test function
handle_write_delete is declared outside the #[cfg(test)] mod tests block; move
the entire handle_write_delete test into the existing test module (inside the
mod tests block) so it only compiles for tests, and update the temp file usage
to use the tmp_path() helper for consistency; keep the test body using
MemoryStore::load, store.handle_write, store.handle_read, and remove the file
with fs::remove_file(path).ok() as suggested.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@mc/crates/mc-core/src/memory.rs`:
- Around line 324-336: The test function handle_write_delete is declared outside
the #[cfg(test)] mod tests block; move the entire handle_write_delete test into
the existing test module (inside the mod tests block) so it only compiles for
tests, and update the temp file usage to use the tmp_path() helper for
consistency; keep the test body using MemoryStore::load, store.handle_write,
store.handle_read, and remove the file with fs::remove_file(path).ok() as
suggested.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05303b9b-e201-4aa2-9455-a48710ed5b09

📥 Commits

Reviewing files that changed from the base of the PR and between c413fc8 and 7f2ada3.

📒 Files selected for processing (4)
  • mc/crates/mc-cli/src/main.rs
  • mc/crates/mc-core/src/memory.rs
  • mc/crates/mc-core/src/runtime.rs
  • mc/crates/mc-tools/src/spec.rs

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
mc/crates/mc-core/src/memory.rs (2)

226-231: Surface startup-compaction save failures.

If save() fails here, memory is compacted in RAM but the JSON file stays stale, so the same duplicates come back next launch with no clue why. Returning a Result or bubbling the error to the caller would make this diagnosable.

💡 Possible direction
-    pub fn auto_compact_on_start(&mut self, threshold: usize) {
+    pub fn auto_compact_on_start(&mut self, threshold: usize) -> Result<(), std::io::Error> {
         if self.facts.len() > threshold {
             let removed = self.compact();
             if removed > 0 {
-                let _ = self.save();
+                self.save()?;
             }
         }
+        Ok(())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mc/crates/mc-core/src/memory.rs` around lines 226 - 231,
auto_compact_on_start currently compacts in-memory (calls compact()) and
swallows save() failures, leaving on-disk JSON stale; change
auto_compact_on_start to return Result<(), E> (or Result<(), anyhow::Error>)
instead of (), call save() only when removed > 0 and propagate its error (use
the ? operator) so callers see and can handle disk write failures; update any
callers of auto_compact_on_start to handle the Result and adjust tests
accordingly.

323-334: Please add regression coverage for the new memory-v2 paths.

This test still only exercises plain write/delete. The risky additions here are defaulting missing category, preserving categories across set(), invalid-category handling, and compact() semantics, so a few focused cases there would make regressions much easier to catch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mc/crates/mc-core/src/memory.rs` around lines 323 - 334, Add regression tests
that exercise the new memory-v2 behavior: extend or add tests around
MemoryStore::load, handle_write, handle_read and the underlying set()/compact()
logic to cover (1) defaulting a missing "category" on write/read (ensure writes
without category land in the default category and reads without category find
them), (2) preserving an existing category when calling set() on an existing key
(write key with category A, update value without category and assert category A
is kept), (3) invalid-category handling (attempt writes/reads with an invalid
category and assert the expected error or no-op behavior), and (4) compact()
semantics (create multiple versions across categories, call compact() and assert
it removes expired/older entries per the memory-v2 rules). Reference
MemoryStore::load, handle_write, handle_read, set, and compact in your new
tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mc/crates/mc-core/src/memory.rs`:
- Line 68: In set(), don't hard-code "project" when delegating to
set_with_category; first check whether a fact for key already exists (e.g. via
self.facts.get(key) or the module's existing lookup helper) and if it does use
that fact's f.category when calling set_with_category(key, value, category),
otherwise default to "project"; update the call site that currently does
self.set_with_category(key, value, "project") to compute category =
existing_fact.map(|f| f.category.clone()).unwrap_or("project".into()) and pass
that through to preserve existing categories on updates.
- Around line 165-168: The code currently accepts any string for the local
variable `category` (derived from input.get("category")...) and must reject
unknown values before persisting; replace the unvalidated unwrap_or("project")
logic by parsing/validating the input string against the canonical enum of
allowed categories (e.g. via a FromStr/try_from or explicit match against the
four allowed variants) and return an error (or validation failure) when the
value is not one of the permitted options; apply the same validation fix to the
other occurrence of this pattern around the second instance noted (lines
~181-182) so only valid enum categories are persisted and invalid inputs are
rejected.
- Around line 207-210: In compact(), inside the .and_modify closure that updates
the (idx, ts) tuple, change the comparison from strict greater (f.updated_at >
*ts) to greater-or-equal (f.updated_at >= *ts) so that when updated_at
timestamps are equal (same-second duplicates) the later duplicate (current index
i) wins; update the conditional and leave the assignments to *idx = i and *ts =
f.updated_at.clone() unchanged.

---

Nitpick comments:
In `@mc/crates/mc-core/src/memory.rs`:
- Around line 226-231: auto_compact_on_start currently compacts in-memory (calls
compact()) and swallows save() failures, leaving on-disk JSON stale; change
auto_compact_on_start to return Result<(), E> (or Result<(), anyhow::Error>)
instead of (), call save() only when removed > 0 and propagate its error (use
the ? operator) so callers see and can handle disk write failures; update any
callers of auto_compact_on_start to handle the Result and adjust tests
accordingly.
- Around line 323-334: Add regression tests that exercise the new memory-v2
behavior: extend or add tests around MemoryStore::load, handle_write,
handle_read and the underlying set()/compact() logic to cover (1) defaulting a
missing "category" on write/read (ensure writes without category land in the
default category and reads without category find them), (2) preserving an
existing category when calling set() on an existing key (write key with category
A, update value without category and assert category A is kept), (3)
invalid-category handling (attempt writes/reads with an invalid category and
assert the expected error or no-op behavior), and (4) compact() semantics
(create multiple versions across categories, call compact() and assert it
removes expired/older entries per the memory-v2 rules). Reference
MemoryStore::load, handle_write, handle_read, set, and compact in your new
tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: abe1b12a-3750-45b5-b36e-f97b71db7137

📥 Commits

Reviewing files that changed from the base of the PR and between 7f2ada3 and af9dcb3.

📒 Files selected for processing (1)
  • mc/crates/mc-core/src/memory.rs


/// Set.
pub fn set(&mut self, key: &str, value: &str) {
self.set_with_category(key, value, "project");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve the current category when set() updates an existing key.

set_with_category() overwrites f.category on update, so hard-coding "project" here silently reclassifies any existing user/feedback/reference fact written through the legacy API. Only default new keys to "project".

💡 Proposed fix
 pub fn set(&mut self, key: &str, value: &str) {
-    self.set_with_category(key, value, "project");
+    let category = self
+        .get(key)
+        .map_or_else(|| "project".to_string(), |fact| fact.category.clone());
+    self.set_with_category(key, value, &category);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.set_with_category(key, value, "project");
pub fn set(&mut self, key: &str, value: &str) {
let category = self
.get(key)
.map_or_else(|| "project".to_string(), |fact| fact.category.clone());
self.set_with_category(key, value, &category);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mc/crates/mc-core/src/memory.rs` at line 68, In set(), don't hard-code
"project" when delegating to set_with_category; first check whether a fact for
key already exists (e.g. via self.facts.get(key) or the module's existing lookup
helper) and if it does use that fact's f.category when calling
set_with_category(key, value, category), otherwise default to "project"; update
the call site that currently does self.set_with_category(key, value, "project")
to compute category = existing_fact.map(|f|
f.category.clone()).unwrap_or("project".into()) and pass that through to
preserve existing categories on updates.

Comment on lines +165 to +168
let category = input
.get("category")
.and_then(|v| v.as_str())
.unwrap_or("project");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject unknown categories before persisting them.

This accepts any string, but mc/crates/mc-tools/src/spec.rs:117-130 defines memory_write.category as a four-value enum. A typo from the model or a direct caller becomes invalid stored state, and to_prompt_section() will render it as a trailing uncategorized bullet.

💡 Proposed fix
         let category = input
             .get("category")
             .and_then(|v| v.as_str())
             .unwrap_or("project");
         if key.is_empty() {
             return "Error: key is required".into();
         }
         if let Some(delete) = input.get("delete").and_then(serde_json::Value::as_bool) {
             if delete {
                 return if self.delete(key) {
                     format!("Deleted: {key}")
                 } else {
                     format!("Key not found: {key}")
                 };
             }
         }
+        if !matches!(category, "project" | "user" | "feedback" | "reference") {
+            return format!("Error: invalid category: {category}");
+        }
         self.set_with_category(key, value, category);

Also applies to: 181-182

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mc/crates/mc-core/src/memory.rs` around lines 165 - 168, The code currently
accepts any string for the local variable `category` (derived from
input.get("category")...) and must reject unknown values before persisting;
replace the unvalidated unwrap_or("project") logic by parsing/validating the
input string against the canonical enum of allowed categories (e.g. via a
FromStr/try_from or explicit match against the four allowed variants) and return
an error (or validation failure) when the value is not one of the permitted
options; apply the same validation fix to the other occurrence of this pattern
around the second instance noted (lines ~181-182) so only valid enum categories
are persisted and invalid inputs are rejected.

Comment on lines +207 to +210
.and_modify(|(idx, ts): &mut (usize, String)| {
if f.updated_at > *ts {
*idx = i;
*ts = f.updated_at.clone();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Break same-second ties toward the later duplicate.

updated_at only has second resolution, so duplicate rows written in the same second compare equal here. With the strict > check, the older entry wins, which breaks the "keep newest" contract of compact().

💡 Proposed fix
                 .and_modify(|(idx, ts): &mut (usize, String)| {
-                    if f.updated_at > *ts {
+                    if f.updated_at > *ts || (f.updated_at == *ts && i > *idx) {
                         *idx = i;
                         *ts = f.updated_at.clone();
                     }
                 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.and_modify(|(idx, ts): &mut (usize, String)| {
if f.updated_at > *ts {
*idx = i;
*ts = f.updated_at.clone();
.and_modify(|(idx, ts): &mut (usize, String)| {
if f.updated_at > *ts || (f.updated_at == *ts && i > *idx) {
*idx = i;
*ts = f.updated_at.clone();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mc/crates/mc-core/src/memory.rs` around lines 207 - 210, In compact(), inside
the .and_modify closure that updates the (idx, ts) tuple, change the comparison
from strict greater (f.updated_at > *ts) to greater-or-equal (f.updated_at >=
*ts) so that when updated_at timestamps are equal (same-second duplicates) the
later duplicate (current index i) wins; update the conditional and leave the
assignments to *idx = i and *ts = f.updated_at.clone() unchanged.

@kienbui1995 kienbui1995 merged commit 828702f into main Apr 14, 2026
9 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Apr 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant