Skip to content

feat: add sync command to bridge extracted memories into MEMORY.md - #3

Open
anupamchugh wants to merge 1 commit into
obra:mainfrom
anupamchugh:feat/sync-command
Open

feat: add sync command to bridge extracted memories into MEMORY.md#3
anupamchugh wants to merge 1 commit into
obra:mainfrom
anupamchugh:feat/sync-command

Conversation

@anupamchugh

@anupamchugh anupamchugh commented Feb 6, 2026

Copy link
Copy Markdown

Summary

Closes the feedback loop between claude-memory extract and Claude Code's built-in MEMORY.md.

Today the pipeline is:

conversations → extract → ~/.claude/memories/extracted/*.md  (done!)
                                      ↓
                               [gap — nothing reads these]
                                      ↓
                              MEMORY.md (loaded each session)

This PR adds claude-memory sync to bridge that gap:

claude-memory extract  →  extracted/*.md  →  claude-memory sync  →  MEMORY.md

What it does

  • Reads extracted memory files from ~/.claude/memories/extracted/
  • Filters by configurable confidence threshold (default >= 3.5)
  • Deduplicates using ID tracking + fuzzy title matching
  • Appends high-quality insights to MEMORY.md, respecting the 200-line limit
  • Backs up MEMORY.md before any modification
  • Discovers all project MEMORY.md locations automatically

New files

File Purpose
src/commands/sync.ts CLI command handler (stats, list-projects, dry-run)
src/sync/memory-parser.ts Parses extracted memory markdown + frontmatter
src/sync/deduplicator.ts ID-based + fuzzy title deduplication
src/sync/sync-engine.ts Core sync orchestrator with backup
tests/unit/sync.test.ts 24 tests covering parser, dedup, and engine

Usage

# See what's available
claude-memory sync --stats

# Preview what would sync
claude-memory sync --dry-run

# Sync to all project MEMORY.md files
claude-memory sync

# Higher threshold, recent only
claude-memory sync --min-confidence 4.0 --since 2026-02-01

# Specific project
claude-memory sync --memory-md ~/.claude/projects/-Users-me-myproject/memory/MEMORY.md

# List all MEMORY.md locations
claude-memory sync --list-projects

Hook integration

# Run after each extraction
claude-memory extract && claude-memory sync

# Or as post-session hook
echo 'claude-memory sync --min-confidence 4.0' >> .claude/hooks/post_session.sh

Design decisions

  • ID-based dedup over fuzzy-only: Stores synced memory IDs in .memory-sync-state.json alongside MEMORY.md. Fuzzy title matching is a fallback for memories synced before tracking was added.
  • Backup before write: Creates MEMORY.md.bak before any modification. Cheap insurance.
  • 190-line default limit: Leaves 10-line buffer under Claude Code's 200-line truncation.
  • No new dependencies: Uses only fs, path, and os from Node stdlib.
  • Follows existing patterns: Commander.js CLI, class-based command, ABOUTME comments, test style matches existing parser.test.ts.

Test plan

  • 24 new tests passing (npx jest tests/unit/sync.test.ts)
  • Existing tests unaffected (pre-existing failures in memory-writer, vector-store, llm-client are upstream issues)
  • TypeScript strict mode compiles clean
  • Full build succeeds (npx tsc)
  • Manual test with real extracted memories

Context

I built a Python prototype of this concept first, then ported to TypeScript to fit the existing codebase. The Python version works as a standalone zero-dependency alternative.


🤖 Generated with Claude Code

Summary by CodeRabbit

New Features

  • Memory sync command: Added a new CLI sync command for managing and synchronizing memories
  • Project memory listing: View all project memory directories and their entry counts
  • Memory statistics: Display summary statistics including total counts, distribution by confidence level, and memory type breakdowns
  • Dry-run mode: Preview synchronization changes before applying them
  • Advanced filtering: Filter memories by confidence threshold, date range, and line limits

Closes the feedback loop between post-session extraction and
in-session memory. Extracted insights from ~/.claude/memories/extracted/
are filtered by confidence, deduplicated, and synced into Claude Code's
MEMORY.md files — respecting the 200-line limit.

- New `claude-memory sync` CLI command with dry-run, stats, and
  list-projects subcommands
- ID-based + fuzzy title deduplication prevents double-syncing
- Backup MEMORY.md before modification
- 24 passing tests covering parser, dedup, and sync engine

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@coderabbitai

coderabbitai Bot commented Feb 6, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces a complete memory synchronization system, integrating a CLI sync command that orchestrates the extraction, deduplication, and merging of memory entries from markdown files into MEMORY.md files across Claude projects.

Changes

Cohort / File(s) Summary
CLI Integration
src/cli.ts
Adds SyncCommand import and wires a new top-level sync command with options for extracted directory, MEMORY.md path, confidence thresholds, line limits, date filtering, dry-run mode, and project listing/stats; normalizes paths and constructs the command with mapped options.
Sync Command
src/commands/sync.ts
Introduces SyncCommand class with SyncCommandOptions interface and execute() method that dispatches to listProjects(), showStats(), or runSync() based on options; includes private methods to list project memories, display statistics, and execute the core sync workflow.
Sync Infrastructure
src/sync/memory-parser.ts, src/sync/deduplicator.ts, src/sync/sync-engine.ts
memory-parser: Parses markdown files with YAML frontmatter to extract typed ExtractedMemoryFile objects; supports bulk loading with confidence and date filtering. deduplicator: Implements duplicate detection via exact ID match or fuzzy title overlap (70%\+ word match); provides state persistence for synced IDs and timestamps. sync-engine: Core orchestrator that formats memory entries, discovers MEMORY.md files in Claude projects, manages backups, and syncs memories with line-limit enforcement and optional dry-run support.
Unit Tests
tests/unit/sync.test.ts
Comprehensive test suite covering frontmatter parsing, memory file parsing, memory loading with filters, duplicate detection logic, state persistence, and syncToTarget behavior including backup creation, dry-run mode, idempotence, and line-limit handling.

Sequence Diagram

sequenceDiagram
    actor User
    participant CLI
    participant SyncCommand
    participant MemoryParser
    participant Deduplicator
    participant SyncEngine
    participant MEMORY.md

    User->>CLI: Run sync command
    CLI->>SyncCommand: new SyncCommand(options)
    SyncCommand->>SyncCommand: execute()
    
    alt listProjects mode
        SyncCommand->>MemoryParser: loadExtractedMemories()
        MemoryParser-->>SyncCommand: ExtractedMemoryFile[]
        SyncCommand-->>User: Display project list
    else showStats mode
        SyncCommand->>MemoryParser: loadExtractedMemories()
        MemoryParser-->>SyncCommand: ExtractedMemoryFile[]
        SyncCommand-->>User: Display statistics
    else runSync mode
        SyncCommand->>MemoryParser: loadExtractedMemories()
        MemoryParser-->>SyncCommand: ExtractedMemoryFile[]
        SyncCommand->>SyncEngine: syncMemories(options)
        SyncEngine->>SyncEngine: findProjectMemoryPaths()
        loop For each target MEMORY.md
            SyncEngine->>Deduplicator: loadSyncState()
            Deduplicator-->>SyncEngine: SyncState
            SyncEngine->>Deduplicator: isDuplicate() for each memory
            Deduplicator-->>SyncEngine: boolean
            SyncEngine->>MEMORY.md: Append new section (or dry-run)
            SyncEngine->>Deduplicator: saveSyncState()
        end
        SyncEngine-->>SyncCommand: SyncResult[]
        SyncCommand-->>User: Display sync results
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

A rabbit hops through memories vast,
Extracting wisdom from the past,
With dedup checks and sync so clean,
The finest system ever seen! 🐰✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% 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 describes the main feature: adding a sync command to move extracted memories into MEMORY.md files, which is the primary objective of this PR.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@src/cli.ts`:
- Around line 106-108: The parsed numeric CLI options minConfidence and maxLines
can become NaN (via parseFloat/parseInt) which silently disables guards; update
the parsing logic in src/cli.ts where minConfidence and maxLines are set to
validate the parsed values (use Number.isFinite or isNaN checks) and either (a)
throw a user-friendly error via the CLI parser or process.exit when input is
invalid, or (b) replace invalid values with safe defaults and log a warning;
ensure you reference the parsed results of parseFloat(options.minConfidence) and
parseInt(options.maxLines, 10) and apply the check before using them in any
confidence or line-limit comparisons.

In `@src/sync/deduplicator.ts`:
- Around line 22-31: loadSyncState currently returns parsed JSON without
validating shape, so a valid JSON file missing syncedIds (e.g., {}) leads to
syncState.syncedIds being undefined and later throwing when spread; update
loadSyncState to validate and normalize the parsed object: after JSON.parse
check that the result is an object and that result.syncedIds is an Array (use
Array.isArray) and result.lastSync is a string, otherwise return the safe
default { syncedIds: [], lastSync: '' } (or coerce fields to those types), and
reference the loadSyncState function and the syncState.syncedIds usage so
callers like the spread in sync-engine.ts operate on a guaranteed array.

In `@src/sync/memory-parser.ts`:
- Line 45: The filter `l => l !== '' || l === ''` is a tautology and does
nothing; update the usage in formatMemoryEntry (in src/sync/sync-engine.ts) and
the similar code in src/sync/memory-parser.ts to either remove the redundant
filter and call .join('\n') directly, or change the predicate to `l => l !== ''`
if the intent is to strip blank lines; locate the join usage in the
formatMemoryEntry function and replace the filter accordingly so blank-line
behavior matches the intended output.
- Line 83: The check in parseMemoryFile currently uses a falsy test (if (!fm.id
|| !fm.confidence) return null;) which drops valid memories with confidence ===
0; change the condition to explicitly reject missing values only (e.g., check
fm.id == null or fm.confidence == null/undefined) or otherwise document intent —
update the condition that references fm.id and fm.confidence to use strict
null/undefined checks (or add a comment if zero confidence should be treated as
invalid) so zero is preserved or explicitly rejected as intended.

In `@src/sync/sync-engine.ts`:
- Around line 44-46: The filter `l => l !== '' || l === ''` is tautological and
does nothing; remove the entire .filter(...) so the code simply returns
lines.join('\n') if you intend to preserve blank lines as spacers (or replace
the filter with `.filter(Boolean)` if you want to remove blank lines). If you
choose to keep the trailing blank line behavior used in the function that
constructs `lines`, also update the line-count logic in `syncToTarget` (the
`countLines` usage) to account for the extra trailing blank per entry so the
200-line budget is computed correctly.
🧹 Nitpick comments (5)
src/sync/memory-parser.ts (1)

35-39: Dead condition: trimmed.startsWith(' ') is always false after trim().

trimmed is the result of line.trim(), so it can never start with a space. This is harmless but misleading.

♻️ Suggested fix
-    if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-') || trimmed.startsWith(' ')) {
+    if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) {
src/sync/sync-engine.ts (3)

56-58: Prefer top-level import over inline require('os').

os is already imported at the top level in other files (e.g., src/cli.ts). Using require('os') inline is inconsistent and prevents tree-shaking / static analysis.

♻️ Suggested fix

Add to the existing imports at the top of the file:

 import * as fs from 'fs';
 import * as path from 'path';
+import * as os from 'os';

Then update the usage:

-  const projectsDir = path.join(require('os').homedir(), '.claude', 'projects');
+  const projectsDir = path.join(os.homedir(), '.claude', 'projects');

109-112: statePath derivation assumes the path ends with MEMORY.md.

memoryMdPath.replace(/MEMORY\.md$/, '.memory-sync-state.json') silently falls through if the path doesn't match the regex, resulting in the state file being written to the same path as the MEMORY.md (clobbering it). This only works correctly because all current callers guarantee the filename. A defensive guard would prevent silent data corruption if that assumption is ever violated.

🛡️ Suggested defensive check
   const statePath = memoryMdPath.replace(/MEMORY\.md$/, '.memory-sync-state.json');
+  if (statePath === memoryMdPath) {
+    throw new Error(`Expected memoryMdPath to end with MEMORY.md, got: ${memoryMdPath}`);
+  }

128-144: Line budget slightly underestimates due to inter-entry separators.

When multiple entries are joined via entries.join('\n') (line 157), each join inserts a \n between entries. Since each formatted entry already ends with a trailing newline, this produces a blank line between entries — adding approximately (N-1) lines not captured in addedLines. Given the 10-line buffer (190 of 200), this is unlikely to cause a real overshoot, but worth being aware of if the buffer is tightened.

tests/unit/sync.test.ts (1)

150-154: Date filter test implicitly relies on lexicographic string comparison.

The test at line 151 passes since: '2026-01-16' (a date-only string) while mem.created values are full ISO timestamps like '2026-01-16T10:00:00Z'. This works because of JS lexicographic comparison, but it's a subtle detail. Consider adding a brief comment in the test or in loadExtractedMemories noting that the comparison relies on ISO 8601's lexicographic ordering property.

Comment thread src/cli.ts
Comment on lines +106 to +108
minConfidence: parseFloat(options.minConfidence),
maxLines: parseInt(options.maxLines, 10),
since: options.since,

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

NaN from invalid numeric CLI input silently disables confidence and line-limit guards.

parseFloat('abc') and parseInt('abc', 10) both return NaN. Since NaN comparisons always return false, passing a non-numeric --min-confidence would skip all confidence filtering, and a non-numeric --max-lines would skip line-limit enforcement.

Consider adding a guard after parsing:

🛡️ Proposed fix
-    const command = new SyncCommand({
-      extractedDir: options.extractedDir,
-      memoryMd: options.memoryMd,
-      minConfidence: parseFloat(options.minConfidence),
-      maxLines: parseInt(options.maxLines, 10),
+    const minConfidence = parseFloat(options.minConfidence);
+    const maxLines = parseInt(options.maxLines, 10);
+    if (isNaN(minConfidence) || isNaN(maxLines)) {
+      console.error('Error: --min-confidence and --max-lines must be valid numbers.');
+      process.exit(1);
+    }
+
+    const command = new SyncCommand({
+      extractedDir: options.extractedDir,
+      memoryMd: options.memoryMd,
+      minConfidence,
+      maxLines,
🤖 Prompt for AI Agents
In `@src/cli.ts` around lines 106 - 108, The parsed numeric CLI options
minConfidence and maxLines can become NaN (via parseFloat/parseInt) which
silently disables guards; update the parsing logic in src/cli.ts where
minConfidence and maxLines are set to validate the parsed values (use
Number.isFinite or isNaN checks) and either (a) throw a user-friendly error via
the CLI parser or process.exit when input is invalid, or (b) replace invalid
values with safe defaults and log a warning; ensure you reference the parsed
results of parseFloat(options.minConfidence) and parseInt(options.maxLines, 10)
and apply the check before using them in any confidence or line-limit
comparisons.

Comment thread src/sync/deduplicator.ts
Comment on lines +22 to +31
export function loadSyncState(statePath: string): SyncState {
if (!fs.existsSync(statePath)) {
return { syncedIds: [], lastSync: '' };
}
try {
return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
} catch {
return { syncedIds: [], lastSync: '' };
}
}

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

Corrupted state file with valid JSON but wrong shape can crash the sync.

loadSyncState handles parse errors but not schema mismatches. If the file contains valid JSON without a syncedIds array (e.g., {}), then syncState.syncedIds is undefined, and the spread in sync-engine.ts line 180 ([...syncState.syncedIds, ...]) will throw TypeError: not iterable.

🛡️ Proposed fix — add minimal shape validation
 export function loadSyncState(statePath: string): SyncState {
   if (!fs.existsSync(statePath)) {
     return { syncedIds: [], lastSync: '' };
   }
   try {
-    return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
+    const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
+    return {
+      syncedIds: Array.isArray(parsed.syncedIds) ? parsed.syncedIds : [],
+      lastSync: typeof parsed.lastSync === 'string' ? parsed.lastSync : '',
+    };
   } catch {
     return { syncedIds: [], lastSync: '' };
   }
 }
🤖 Prompt for AI Agents
In `@src/sync/deduplicator.ts` around lines 22 - 31, loadSyncState currently
returns parsed JSON without validating shape, so a valid JSON file missing
syncedIds (e.g., {}) leads to syncState.syncedIds being undefined and later
throwing when spread; update loadSyncState to validate and normalize the parsed
object: after JSON.parse check that the result is an object and that
result.syncedIds is an Array (use Array.isArray) and result.lastSync is a
string, otherwise return the safe default { syncedIds: [], lastSync: '' } (or
coerce fields to those types), and reference the loadSyncState function and the
syncState.syncedIds usage so callers like the spread in sync-engine.ts operate
on a guaranteed array.

Comment thread src/sync/memory-parser.ts
if (colonIdx === -1) continue;

const key = trimmed.slice(0, colonIdx).trim();
const rawVal = trimmed.slice(colonIdx + 1).trim();

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

The filter in lines.filter(l => l !== '' || l === '') is a no-op — it always returns true.

Looking at how formatMemoryEntry in src/sync/sync-engine.ts (line 45) uses a similar pattern — lines.filter(l => l !== '' || l === '').join('\n') — this condition is tautological: every string is either not-empty or empty. All lines pass through unchanged. If the intent was to strip blank lines, the filter should be l => l !== ''. If the intent was to keep everything (including blank lines as spacers), just use .join('\n') directly.

I raise this here because the formatMemoryEntry in sync-engine.ts (line 45) is the actual usage, but the root issue is the logical expression itself.

🤖 Prompt for AI Agents
In `@src/sync/memory-parser.ts` at line 45, The filter `l => l !== '' || l === ''`
is a tautology and does nothing; update the usage in formatMemoryEntry (in
src/sync/sync-engine.ts) and the similar code in src/sync/memory-parser.ts to
either remove the redundant filter and call .join('\n') directly, or change the
predicate to `l => l !== ''` if the intent is to strip blank lines; locate the
join usage in the formatMemoryEntry function and replace the filter accordingly
so blank-line behavior matches the intended output.

Comment thread src/sync/memory-parser.ts
}

const fm = parseFrontmatter(text);
if (!fm.id || !fm.confidence) return null;

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

confidence: 0 is falsy — memories with zero confidence are silently dropped.

!fm.confidence is true when confidence is 0, so parseMemoryFile returns null for a valid-but-zero confidence entry. If this is intentional (zero confidence is meaningless), consider making it explicit with a comment or using a stricter check.

♻️ Suggested clarification
-  if (!fm.id || !fm.confidence) return null;
+  if (!fm.id || fm.confidence === undefined || fm.confidence === null) return null;

Or, if zero confidence should indeed be rejected:

-  if (!fm.id || !fm.confidence) return null;
+  // Note: confidence of 0 is intentionally rejected as meaningless
+  if (!fm.id || !fm.confidence) return null;
📝 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
if (!fm.id || !fm.confidence) return null;
if (!fm.id || fm.confidence === undefined || fm.confidence === null) return null;
🤖 Prompt for AI Agents
In `@src/sync/memory-parser.ts` at line 83, The check in parseMemoryFile currently
uses a falsy test (if (!fm.id || !fm.confidence) return null;) which drops valid
memories with confidence === 0; change the condition to explicitly reject
missing values only (e.g., check fm.id == null or fm.confidence ==
null/undefined) or otherwise document intent — update the condition that
references fm.id and fm.confidence to use strict null/undefined checks (or add a
comment if zero confidence should be treated as invalid) so zero is preserved or
explicitly rejected as intended.

Comment thread src/sync/sync-engine.ts
Comment on lines +44 to +46

return lines.filter(l => l !== '' || l === '').join('\n');
}

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

Tautological filter — l !== '' || l === '' is always true.

Every string is either not-empty or empty, so this filter passes all elements unchanged. If the intent is to keep blank lines as spacers (the trailing '' on line 42), just drop the filter:

♻️ Suggested fix
-  return lines.filter(l => l !== '' || l === '').join('\n');
+  return lines.filter(Boolean).join('\n');

Or if the trailing blank line is intentional (to separate entries):

-  return lines.filter(l => l !== '' || l === '').join('\n');
+  return lines.join('\n');

Note: If the trailing blank line is intentional, the line-count budget in syncToTarget (line 139) undercounts by one line per entry, since countLines trims before counting — but the blank line is present in the appended text. With a 10-line buffer (190 of 200), this is unlikely to matter in practice.

📝 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
return lines.filter(l => l !== '' || l === '').join('\n');
}
return lines.filter(Boolean).join('\n');
}
Suggested change
return lines.filter(l => l !== '' || l === '').join('\n');
}
return lines.join('\n');
}
🤖 Prompt for AI Agents
In `@src/sync/sync-engine.ts` around lines 44 - 46, The filter `l => l !== '' || l
=== ''` is tautological and does nothing; remove the entire .filter(...) so the
code simply returns lines.join('\n') if you intend to preserve blank lines as
spacers (or replace the filter with `.filter(Boolean)` if you want to remove
blank lines). If you choose to keep the trailing blank line behavior used in the
function that constructs `lines`, also update the line-count logic in
`syncToTarget` (the `countLines` usage) to account for the extra trailing blank
per entry so the 200-line budget is computed correctly.

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