Skip to content

fix(memory): fix 4 bugs in store layer#80

Merged
wcatz merged 1 commit intomainfrom
fix/memory-store-bugs
Mar 18, 2026
Merged

fix(memory): fix 4 bugs in store layer#80
wcatz merged 1 commit intomainfrom
fix/memory-store-bugs

Conversation

@wcatz
Copy link
Copy Markdown
Owner

@wcatz wcatz commented Mar 18, 2026

Summary

  • RecordDecision now checks the error from the secondary memory INSERT instead of silently discarding it
  • GetRecentExchanges fixed ordering: subquery fetches most recent DESC then re-sorts ASC for correct user→assistant pairing
  • sanitizeFTS keeps single-char words (was silently dropping "a", "i", etc.)
  • GetByCategory includes _global project memories (matches GetTopMemories and SearchFTS)

Issue 3 (SearchVector loads all embeddings) left as-is — already project-filtered via JOIN, fine at current scale.

Test plan

  • go test ./internal/memory/... — passes (updated test expectation for sanitizeFTS)
  • go vet ./... — clean
  • go build ./cmd/ghost/ — compiles

Summary by CodeRabbit

  • New Features

    • Global memories now appear alongside project-specific memories
    • Enhanced search functionality to include single-character terms
  • Bug Fixes

    • Improved error handling for decision memory recording with clearer error messages
    • Recent exchanges now correctly displayed in chronological order

- decisions.go: check error from memory INSERT in RecordDecision
- store.go: fix GetRecentExchanges ordering (subquery for DESC+ASC)
- store.go: sanitizeFTS now keeps single-char words instead of dropping
- store.go: GetByCategory includes _global project (matches SearchFTS)
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 18, 2026

📝 Walkthrough

Walkthrough

The memory system is enhanced to support global scope visibility across all projects. GetByCategory and GetTopMemories now include '_global' project memories in their results. GetRecentExchanges is refactored with subqueries to ensure chronological ordering. sanitizeFTS expands token validation to accept single-character words. Error handling is added to decision memory recording with prefixed error messages.

Changes

Cohort / File(s) Summary
Error Handling
internal/memory/decisions.go
ExecContext call assigned and checked for errors; errors returned with "record decision memory: " prefix.
Store Query Logic
internal/memory/store.go
GetByCategory and GetTopMemories include global memories via (project_id = ? OR project_id = '_global') filter. GetRecentExchanges rewritten with subquery for ascending chronological ordering. Pair assembly logic adjusted to collect user/assistant content sequentially. sanitizeFTS now accepts single-character tokens (length >= 1).
Test Updates
internal/memory/store_test.go
TestSanitizeFTS updated to expect single-character words ("a") in output.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hopping through memories, both old and new,
Global scope spreads, a wider view,
Chronological order, chronological grace,
Errors now handled in their proper place!
A single hop, two hops, all memories shine,
The burrow runs deeper, the queries align!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: fixing multiple bugs in the memory store layer, as evidenced by changes to decisions.go, store.go, and store_test.go.

✏️ 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 fix/memory-store-bugs
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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 (2)
internal/memory/decisions.go (1)

33-49: ⚠️ Potential issue | 🟠 Major

Make the decision insert and memory insert atomic.

The new error check is better, but these writes still happen outside a transaction. If the memory INSERT fails, this returns an error after the decision row has already been committed, so retries can create duplicate decisions while the memory table stays out of sync.

🧩 Wrap both writes in one transaction
 func (s *Store) RecordDecision(ctx context.Context, projectID, title, decision, rationale string, alternatives, tags []string) (string, error) {
 	s.mu.Lock()
 	defer s.mu.Unlock()

 	altJSON, _ := json.Marshal(alternatives)
 	tagJSON, _ := json.Marshal(tags)

+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return "", fmt.Errorf("begin decision tx: %w", err)
+	}
+	defer tx.Rollback()
+
 	var id string
-	err := s.db.QueryRowContext(ctx, `
+	err = tx.QueryRowContext(ctx, `
 		INSERT INTO decisions (project_id, title, decision, alternatives, rationale, tags)
 		VALUES (?, ?, ?, ?, ?, ?)
 		RETURNING id
 	`, projectID, title, decision, string(altJSON), rationale, string(tagJSON)).Scan(&id)
 	if err != nil {
 		return "", fmt.Errorf("record decision: %w", err)
 	}

 	// Also save as a memory so it shows up in search and prompt context.
 	content := fmt.Sprintf("%s: %s. Rationale: %s", title, decision, rationale)
-	if _, err := s.db.ExecContext(ctx, `
+	if _, err := tx.ExecContext(ctx, `
 		INSERT INTO memories (project_id, category, content, source, importance, tags)
 		VALUES (?, 'decision', ?, 'decision_log', 0.9, ?)
 	`, projectID, content, string(tagJSON)); err != nil {
-		return id, fmt.Errorf("record decision memory: %w", err)
+		return "", fmt.Errorf("record decision memory: %w", err)
+	}
+	if err := tx.Commit(); err != nil {
+		return "", fmt.Errorf("commit decision tx: %w", err)
 	}

 	if s.onSave != nil {
 		s.onSave(projectID)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/memory/decisions.go` around lines 33 - 49, Wrap the decision INSERT
and the subsequent memory INSERT in a single DB transaction so both succeed or
both roll back: start a transaction with s.db.BeginTx(ctx, nil), perform the
decisions INSERT via tx.QueryRowContext(...).Scan(&id) (using projectID, title,
decision, string(altJSON), rationale, string(tagJSON)), then perform the
memories INSERT via tx.ExecContext(...) (using projectID, content,
string(tagJSON)); on any error call tx.Rollback() and return the wrapped error,
and on success call tx.Commit() before returning id. Ensure you replace
s.db.QueryRowContext/ExecContext with tx.QueryRowContext/tx.ExecContext and
still return the same error messages (e.g., "record decision: %w" and "record
decision memory: %w").
internal/memory/store.go (1)

505-533: ⚠️ Potential issue | 🟠 Major

Don't rebuild exchanges from a flattened message window.

Because the subquery drops conversation_id, the loop can only pair by adjacency. That breaks if conversations overlap, and it also under-fills limit when the newest window starts with an unanswered user turn. Those bad pairs flow straight into internal/reflection/engine.go:74-99 and internal/reflection/prompt.go:35-52. Keep conversation identity through the query and apply the final limit after complete user→assistant pairs are formed.

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

In `@internal/memory/store.go` around lines 505 - 533, The current SQL drops
conversation_id which forces pairing by adjacency; change the query to SELECT
conversation_id, role, content, created_at (so you retain conversation identity)
and then in the rows loop use a map keyed by conversation_id to build
per-conversation [2]string user→assistant pairs (use created_at to order within
each conversation), collect all completed pairs across conversations, sort them
by their assistant message created_at (or user created_at depending on desired
semantics) and only then apply the final limit before returning; update the
variables and logic around pairs/current to reference conversation_id and ensure
empty user starts are ignored so you don’t under-fill results consumed by
internal/reflection/engine.go and internal/reflection/prompt.go.
🧹 Nitpick comments (2)
internal/memory/store_test.go (1)

355-357: Consider an end-to-end SearchFTS regression for this case.

This locks down the rewritten MATCH string, but not the user-visible search behavior. A small DB-backed test that stores content with "a" or "i" and queries it through SearchFTS would catch tokenizer/query-shape regressions too.

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

In `@internal/memory/store_test.go` around lines 355 - 357, Add an end-to-end
DB-backed test that verifies SearchFTS returns rows for single-character tokens
(e.g., "a" and "i") to catch tokenizer/query-shape regressions: create a new
test (e.g., TestSearchFTS_SingleChar) in the same test suite that inserts
records with content containing single-char words, calls SearchFTS with queries
like "a" and "i", and asserts the expected rows are returned; use the existing
test helpers/setup in internal/memory/store_test.go so the test exercises the
actual DB MATCH path rather than only asserting the rewritten MATCH string.
internal/memory/store.go (1)

292-299: Prefer project-local memories before _global when this list is limited.

Once _global is included here, ORDER BY importance DESC, created_at DESC LIMIT ? can return only global rows for a dense category and hide the project's own memories. internal/mcpserver/mcpserver.go:266-272 renders this result set as-is, so a small limit will make that pretty visible.

♻️ Possible ordering tweak
-		ORDER BY importance DESC, created_at DESC
+		ORDER BY CASE WHEN project_id = ? THEN 0 ELSE 1 END,
+		         importance DESC, created_at DESC
 		LIMIT ?
-	`, projectID, category, limit)
+	`, projectID, category, projectID, limit)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/memory/store.go` around lines 292 - 299, The current SQL in the
s.db.QueryContext call can return only _global rows when limit is small; change
the ORDER BY to prefer project-local memories first by adding a deterministic
project-local sort key before importance. Update the query used in
s.db.QueryContext to ORDER BY (project_id = '_global') ASC, importance DESC,
created_at DESC (so local project_id rows sort before '_global'), leaving the
parameters (projectID, category, limit) unchanged; this ensures project-local
memories are returned before global ones when the result is limited.
🤖 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 `@internal/memory/decisions.go`:
- Around line 33-49: Wrap the decision INSERT and the subsequent memory INSERT
in a single DB transaction so both succeed or both roll back: start a
transaction with s.db.BeginTx(ctx, nil), perform the decisions INSERT via
tx.QueryRowContext(...).Scan(&id) (using projectID, title, decision,
string(altJSON), rationale, string(tagJSON)), then perform the memories INSERT
via tx.ExecContext(...) (using projectID, content, string(tagJSON)); on any
error call tx.Rollback() and return the wrapped error, and on success call
tx.Commit() before returning id. Ensure you replace
s.db.QueryRowContext/ExecContext with tx.QueryRowContext/tx.ExecContext and
still return the same error messages (e.g., "record decision: %w" and "record
decision memory: %w").

In `@internal/memory/store.go`:
- Around line 505-533: The current SQL drops conversation_id which forces
pairing by adjacency; change the query to SELECT conversation_id, role, content,
created_at (so you retain conversation identity) and then in the rows loop use a
map keyed by conversation_id to build per-conversation [2]string user→assistant
pairs (use created_at to order within each conversation), collect all completed
pairs across conversations, sort them by their assistant message created_at (or
user created_at depending on desired semantics) and only then apply the final
limit before returning; update the variables and logic around pairs/current to
reference conversation_id and ensure empty user starts are ignored so you don’t
under-fill results consumed by internal/reflection/engine.go and
internal/reflection/prompt.go.

---

Nitpick comments:
In `@internal/memory/store_test.go`:
- Around line 355-357: Add an end-to-end DB-backed test that verifies SearchFTS
returns rows for single-character tokens (e.g., "a" and "i") to catch
tokenizer/query-shape regressions: create a new test (e.g.,
TestSearchFTS_SingleChar) in the same test suite that inserts records with
content containing single-char words, calls SearchFTS with queries like "a" and
"i", and asserts the expected rows are returned; use the existing test
helpers/setup in internal/memory/store_test.go so the test exercises the actual
DB MATCH path rather than only asserting the rewritten MATCH string.

In `@internal/memory/store.go`:
- Around line 292-299: The current SQL in the s.db.QueryContext call can return
only _global rows when limit is small; change the ORDER BY to prefer
project-local memories first by adding a deterministic project-local sort key
before importance. Update the query used in s.db.QueryContext to ORDER BY
(project_id = '_global') ASC, importance DESC, created_at DESC (so local
project_id rows sort before '_global'), leaving the parameters (projectID,
category, limit) unchanged; this ensures project-local memories are returned
before global ones when the result is limited.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a055b7c-47a3-492a-a860-10b7f39632bf

📥 Commits

Reviewing files that changed from the base of the PR and between 162d8fb and 7a22dd3.

📒 Files selected for processing (3)
  • internal/memory/decisions.go
  • internal/memory/store.go
  • internal/memory/store_test.go

@wcatz wcatz merged commit 2badbed into main Mar 18, 2026
4 checks passed
@wcatz wcatz deleted the fix/memory-store-bugs branch March 18, 2026 19:52
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