claude-memory-agent 2.2.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,36 @@
1
- """Bidirectional sync between MCP vector DB and Claude Code's native auto memory.
1
+ """One-way sync: Claude Code's native MEMORY.md -> MCP vector DB.
2
2
 
3
- Direction A (MCP -> Native): Fast, no embeddings needed.
4
- High-importance MCP memories -> fenced section in native MEMORY.md
3
+ Native MEMORY.md is owned exclusively by Claude Code's auto memory.
4
+ This module ingests its contents into the MCP vector DB at session end
5
+ so they become searchable via semantic search.
5
6
 
6
- Direction B (Native -> MCP): Needs embeddings, runs at session end.
7
- Native MEMORY.md sections -> MCP DB as type='chunk', tagged source=native_memory_md
7
+ The MCP-to-Native direction has been removed to avoid competing with
8
+ Claude Code for the 200-line MEMORY.md budget.
8
9
 
9
- Dedup: markdown_syncs.content_hash prevents duplicates in both directions.
10
+ Dedup: markdown_syncs.content_hash prevents duplicate imports.
10
11
  """
11
12
  import hashlib
13
+ import json
12
14
  import logging
13
15
  import re
14
16
  from datetime import datetime
15
17
  from pathlib import Path
16
- from typing import Dict, Any, List, Optional
18
+ from typing import Dict, Any, List
17
19
 
18
20
  from services.native_memory_paths import get_native_memory_md, list_native_memory_files
19
21
 
20
22
  logger = logging.getLogger(__name__)
21
23
 
24
+ # Source tag used for native->MCP memories
25
+ NATIVE_SOURCE_TAG = "source=native_memory_md"
22
26
 
23
- def _ensure_markdown_syncs_table(conn):
24
- """Create the markdown_syncs table if it doesn't exist yet.
27
+ # Legacy markers (used only for stripping during import)
28
+ _MCP_SYNC_START = "<!-- MCP-SYNCED START -->"
29
+ _MCP_SYNC_END = "<!-- MCP-SYNCED END -->"
25
30
 
26
- Lightweight safeguard: the full initialize_schema() may fail on older DBs
27
- due to unrelated column mismatches, so we create just the table we need.
28
- """
31
+
32
+ def _ensure_markdown_syncs_table(conn):
33
+ """Create the markdown_syncs table if it doesn't exist yet."""
29
34
  conn.execute("""
30
35
  CREATE TABLE IF NOT EXISTS markdown_syncs (
31
36
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -44,226 +49,70 @@ def _ensure_markdown_syncs_table(conn):
44
49
  conn.commit()
45
50
 
46
51
 
47
- # Markers for the MCP-synced section in native MEMORY.md
48
- MCP_SYNC_START = "<!-- MCP-SYNCED START -->"
49
- MCP_SYNC_END = "<!-- MCP-SYNCED END -->"
50
-
51
- # Budget: native auto memory loads first 200 lines, leave 20 headroom
52
- MAX_NATIVE_LINES = 180
53
-
54
- # Source tag used for native->MCP memories
55
- NATIVE_SOURCE_TAG = "source=native_memory_md"
56
-
57
-
58
52
  def _content_hash(text: str) -> str:
59
53
  """Generate a short hash for dedup tracking."""
60
54
  return hashlib.md5(text.strip().encode("utf-8")).hexdigest()[:16]
61
55
 
62
56
 
63
- # ── Direction A: MCP -> Native ────────────────────────────────────────────
64
-
65
-
66
- async def sync_mcp_to_native(
67
- db,
68
- project_path: str,
69
- min_importance: int = 7,
70
- ) -> Dict[str, Any]:
71
- """Sync high-importance MCP memories into the native MEMORY.md fenced section.
72
-
73
- 1. Query MCP DB for important memories for this project
74
- 2. Skip already-synced (via markdown_syncs with file_type='mcp_to_native')
75
- 3. Read native MEMORY.md, check line budget
76
- 4. Replace content between MCP-SYNCED markers (non-destructive)
77
- 5. Record synced entries in markdown_syncs
78
-
79
- Args:
80
- db: DatabaseService instance
81
- project_path: Absolute project path (e.g. C:\\xampp\\htdocs\\server)
82
- min_importance: Minimum importance threshold (default 7)
83
-
84
- Returns:
85
- Dict with sync results
86
- """
87
- from services.database import normalize_path
88
- norm_path = normalize_path(project_path)
89
-
90
- _ensure_markdown_syncs_table(db.conn)
91
- cursor = db.conn.cursor()
92
-
93
- # 1. Query high-importance decisions, preferences, and successful errors
94
- try:
95
- cursor.execute("""
96
- SELECT id, type, content, importance, success, created_at
97
- FROM memories
98
- WHERE importance >= ?
99
- AND (project_path = ? OR project_path IS NULL)
100
- AND (
101
- type IN ('decision', 'preference')
102
- OR (type = 'error' AND success = 1)
103
- )
104
- ORDER BY importance DESC, created_at DESC
105
- LIMIT 30
106
- """, (min_importance, norm_path))
107
- memories = [dict(row) for row in cursor.fetchall()]
108
- except Exception as e:
109
- logger.error(f"MCP->Native: failed to query memories: {e}")
110
- return {"success": False, "error": str(e)}
111
-
112
- if not memories:
113
- return {"success": True, "synced": 0, "reason": "no qualifying memories"}
114
-
115
- # 2. Check which are already synced
116
- already_synced_ids = set()
117
- try:
118
- cursor.execute("""
119
- SELECT memory_id FROM markdown_syncs
120
- WHERE file_type = 'mcp_to_native'
121
- AND project_path = ?
122
- """, (norm_path,))
123
- already_synced_ids = {row["memory_id"] for row in cursor.fetchall()}
124
- except Exception as e:
125
- logger.warning(f"MCP->Native: failed to check synced IDs: {e}")
126
-
127
- # Also get all synced content hashes for this direction
128
- already_synced_hashes = set()
129
- try:
130
- cursor.execute("""
131
- SELECT content_hash FROM markdown_syncs
132
- WHERE file_type = 'mcp_to_native'
133
- AND project_path = ?
134
- """, (norm_path,))
135
- already_synced_hashes = {row["content_hash"] for row in cursor.fetchall()}
136
- except Exception:
137
- pass
138
-
139
- # Filter to new memories only
140
- new_memories = []
141
- for mem in memories:
142
- h = _content_hash(mem["content"])
143
- if mem["id"] not in already_synced_ids and h not in already_synced_hashes:
144
- new_memories.append(mem)
145
-
146
- if not new_memories:
147
- return {"success": True, "synced": 0, "reason": "all already synced"}
148
-
149
- # 3. Read native MEMORY.md
150
- native_md_path = get_native_memory_md(project_path)
151
-
152
- if native_md_path.exists():
153
- existing_content = native_md_path.read_text(encoding="utf-8")
154
- else:
155
- existing_content = ""
156
-
157
- # Count lines outside the MCP-synced section
158
- user_content = _strip_mcp_section(existing_content)
159
- user_line_count = len(user_content.splitlines())
160
-
161
- if user_line_count >= MAX_NATIVE_LINES:
162
- logger.warning(
163
- f"MCP->Native: MEMORY.md already at {user_line_count} lines "
164
- f"(budget {MAX_NATIVE_LINES}), skipping sync"
165
- )
166
- return {
167
- "success": True,
168
- "synced": 0,
169
- "reason": f"line budget exceeded ({user_line_count}/{MAX_NATIVE_LINES})",
170
- }
171
-
172
- # 4. Build the fenced section content
173
- # Include previously synced memories too (rebuild full section)
174
- all_for_section = memories # all qualifying, not just new
175
- section_lines = _build_mcp_section(all_for_section)
176
-
177
- # Check combined line count
178
- total_lines = user_line_count + len(section_lines)
179
- if total_lines > MAX_NATIVE_LINES:
180
- # Trim section to fit budget
181
- budget = MAX_NATIVE_LINES - user_line_count
182
- if budget < 5:
183
- return {
184
- "success": True,
185
- "synced": 0,
186
- "reason": "not enough line budget for MCP section",
187
- }
188
- section_lines = section_lines[:budget]
189
-
190
- # 5. Replace or append the fenced section
191
- section_text = "\n".join(section_lines)
192
- updated_content = _replace_mcp_section(user_content, section_text)
193
-
194
- # Write back
195
- native_md_path.parent.mkdir(parents=True, exist_ok=True)
196
- native_md_path.write_text(updated_content, encoding="utf-8")
197
-
198
- # 6. Record in markdown_syncs
199
- now = datetime.now().isoformat()
200
- synced_count = 0
201
- for mem in new_memories:
202
- h = _content_hash(mem["content"])
203
- try:
204
- cursor.execute("""
205
- INSERT INTO markdown_syncs (file_type, file_path, memory_id, project_path, synced_at, content_hash)
206
- VALUES ('mcp_to_native', ?, ?, ?, ?, ?)
207
- """, (str(native_md_path), mem["id"], norm_path, now, h))
208
- synced_count += 1
209
- except Exception as e:
210
- logger.warning(f"MCP->Native: failed to record sync for memory {mem['id']}: {e}")
211
-
212
- db.conn.commit()
213
-
214
- return {
215
- "success": True,
216
- "synced": synced_count,
217
- "total_in_section": len(all_for_section),
218
- "file": str(native_md_path),
219
- "user_lines": user_line_count,
220
- "total_lines": len(updated_content.splitlines()),
221
- }
222
-
223
-
224
- def _build_mcp_section(memories: list) -> List[str]:
225
- """Build the fenced MCP-SYNCED section lines."""
226
- lines = [
227
- MCP_SYNC_START,
228
- "## Synced from MCP Memory DB",
229
- "",
230
- ]
231
- for mem in memories:
232
- mtype = mem.get("type", "chunk")
233
- content = mem.get("content", "").replace("\n", " ").strip()
234
- # Truncate long entries
235
- if len(content) > 200:
236
- content = content[:197] + "..."
237
- importance = mem.get("importance", 5)
238
- lines.append(f"- [{mtype}] {content} (importance: {importance})")
239
-
240
- lines.append("")
241
- lines.append(MCP_SYNC_END)
242
- return lines
243
-
244
-
245
57
  def _strip_mcp_section(content: str) -> str:
246
- """Remove the MCP-SYNCED fenced section from content."""
58
+ """Remove any legacy MCP-SYNCED fenced section from content."""
247
59
  pattern = re.compile(
248
- re.escape(MCP_SYNC_START) + r".*?" + re.escape(MCP_SYNC_END),
60
+ re.escape(_MCP_SYNC_START) + r".*?" + re.escape(_MCP_SYNC_END),
249
61
  re.DOTALL,
250
62
  )
251
63
  stripped = pattern.sub("", content)
252
- # Clean up extra blank lines left behind
253
64
  stripped = re.sub(r"\n{3,}", "\n\n", stripped)
254
65
  return stripped.rstrip("\n") + "\n" if stripped.strip() else ""
255
66
 
256
67
 
257
- def _replace_mcp_section(user_content: str, section_text: str) -> str:
258
- """Append (or replace) the MCP section at the end of user content."""
259
- # Ensure user content ends with a newline
260
- if user_content and not user_content.endswith("\n"):
261
- user_content += "\n"
68
+ def _parse_markdown_entries(content: str, source_file: str = "MEMORY.md") -> List[Dict[str, Any]]:
69
+ """Parse markdown into discrete entries for import.
262
70
 
263
- return user_content + "\n" + section_text + "\n"
71
+ Splits on ## headers. Each section becomes one entry.
72
+ Bullet groups under a section are kept together.
73
+ Very short entries (< 20 chars) are skipped.
74
+ """
75
+ if not content.strip():
76
+ return []
264
77
 
78
+ entries = []
79
+ lines = content.splitlines()
265
80
 
266
- # ── Direction B: Native -> MCP ────────────────────────────────────────────
81
+ current_section = ""
82
+ current_lines: list = []
83
+
84
+ for line in lines:
85
+ if line.startswith("## "):
86
+ # Flush previous section
87
+ if current_lines:
88
+ text = "\n".join(current_lines).strip()
89
+ if len(text) >= 20:
90
+ entries.append({
91
+ "content": text,
92
+ "section": current_section,
93
+ "source_file": source_file,
94
+ })
95
+ current_section = line[3:].strip()
96
+ current_lines = [line]
97
+ elif line.startswith("# ") and not current_lines:
98
+ continue
99
+ else:
100
+ current_lines.append(line)
101
+
102
+ # Flush last section
103
+ if current_lines:
104
+ text = "\n".join(current_lines).strip()
105
+ if len(text) >= 20:
106
+ entries.append({
107
+ "content": text,
108
+ "section": current_section,
109
+ "source_file": source_file,
110
+ })
111
+
112
+ return entries
113
+
114
+
115
+ # ── Native -> MCP (the only sync direction) ──────────────────────────
267
116
 
268
117
 
269
118
  async def sync_native_to_mcp(
@@ -275,7 +124,7 @@ async def sync_native_to_mcp(
275
124
 
276
125
  1. Read native MEMORY.md + topic files
277
126
  2. Parse into entries (## sections or bullet groups)
278
- 3. Skip the MCP-SYNCED section (avoid circular sync)
127
+ 3. Strip any legacy MCP-SYNCED section (avoid circular import)
279
128
  4. Hash each entry, check markdown_syncs for file_type='native_to_mcp'
280
129
  5. For new/changed entries: generate embedding, store in MCP DB
281
130
  6. Record in markdown_syncs
@@ -306,9 +155,8 @@ async def sync_native_to_mcp(
306
155
  logger.warning(f"Native->MCP: failed to read {fpath}: {e}")
307
156
  continue
308
157
 
309
- # Strip the MCP-synced section to avoid circular import
158
+ # Strip any legacy MCP-synced section
310
159
  clean = _strip_mcp_section(raw)
311
-
312
160
  parsed = _parse_markdown_entries(clean, source_file=fpath.name)
313
161
  entries.extend(parsed)
314
162
 
@@ -346,31 +194,25 @@ async def sync_native_to_mcp(
346
194
 
347
195
  for entry in new_entries:
348
196
  try:
349
- # Generate embedding
350
197
  emb_result = await embeddings.generate_embedding(entry["content"])
351
198
  if hasattr(emb_result, "ok") and not emb_result.ok:
352
199
  logger.warning(f"Native->MCP: embedding failed for entry: {emb_result.error_message}")
353
200
  errors.append(entry["content"][:50])
354
201
  continue
355
202
 
356
- # Extract the embedding vector
357
203
  embedding = emb_result.embedding if hasattr(emb_result, "embedding") else emb_result
358
204
 
359
- # Build tags
360
205
  tags = [NATIVE_SOURCE_TAG]
361
206
  if entry.get("source_file"):
362
207
  tags.append(f"file={entry['source_file']}")
363
208
  if entry.get("section"):
364
209
  tags.append(f"section={entry['section']}")
365
210
 
366
- # Store in MCP DB
367
- import json
368
211
  embedding_json = json.dumps(
369
212
  embedding if isinstance(embedding, list) else embedding.tolist()
370
213
  if hasattr(embedding, "tolist") else list(embedding)
371
214
  )
372
215
 
373
- # Use only columns guaranteed to exist in the active DB schema
374
216
  cursor.execute("""
375
217
  INSERT INTO memories (type, content, embedding, project_path, importance, tags, created_at)
376
218
  VALUES ('chunk', ?, ?, ?, 6, ?, ?)
@@ -383,7 +225,6 @@ async def sync_native_to_mcp(
383
225
  ))
384
226
  memory_id = cursor.lastrowid
385
227
 
386
- # Record in markdown_syncs
387
228
  cursor.execute("""
388
229
  INSERT INTO markdown_syncs (file_type, file_path, memory_id, project_path, synced_at, content_hash)
389
230
  VALUES ('native_to_mcp', ?, ?, ?, ?, ?)
@@ -409,88 +250,3 @@ async def sync_native_to_mcp(
409
250
  "new_entries": len(new_entries),
410
251
  "errors": errors if errors else None,
411
252
  }
412
-
413
-
414
- def _parse_markdown_entries(content: str, source_file: str = "MEMORY.md") -> List[Dict[str, Any]]:
415
- """Parse markdown into discrete entries for import.
416
-
417
- Splits on ## headers. Each section becomes one entry.
418
- Bullet groups under a section are kept together.
419
- Very short entries (< 20 chars) are skipped.
420
- """
421
- if not content.strip():
422
- return []
423
-
424
- entries = []
425
- lines = content.splitlines()
426
-
427
- current_section = ""
428
- current_lines = []
429
-
430
- for line in lines:
431
- if line.startswith("## "):
432
- # Flush previous section
433
- if current_lines:
434
- text = "\n".join(current_lines).strip()
435
- if len(text) >= 20:
436
- entries.append({
437
- "content": text,
438
- "section": current_section,
439
- "source_file": source_file,
440
- })
441
- current_section = line[3:].strip()
442
- current_lines = [line]
443
- elif line.startswith("# ") and not current_lines:
444
- # Top-level heading, skip (it's the file title)
445
- continue
446
- else:
447
- current_lines.append(line)
448
-
449
- # Flush last section
450
- if current_lines:
451
- text = "\n".join(current_lines).strip()
452
- if len(text) >= 20:
453
- entries.append({
454
- "content": text,
455
- "section": current_section,
456
- "source_file": source_file,
457
- })
458
-
459
- return entries
460
-
461
-
462
- # ── Combined sync ─────────────────────────────────────────────────────────
463
-
464
-
465
- async def sync_bidirectional(
466
- db,
467
- embeddings,
468
- project_path: str,
469
- ) -> Dict[str, Any]:
470
- """Run both sync directions. Used at session end.
471
-
472
- Args:
473
- db: DatabaseService instance
474
- embeddings: EmbeddingService instance (needed for native->MCP)
475
- project_path: Absolute project path
476
-
477
- Returns:
478
- Dict with results from both directions
479
- """
480
- results = {}
481
-
482
- # Direction A: MCP -> Native (fast)
483
- try:
484
- results["mcp_to_native"] = await sync_mcp_to_native(db, project_path)
485
- except Exception as e:
486
- logger.error(f"Bidirectional sync MCP->Native failed: {e}")
487
- results["mcp_to_native"] = {"success": False, "error": str(e)}
488
-
489
- # Direction B: Native -> MCP (needs embeddings)
490
- try:
491
- results["native_to_mcp"] = await sync_native_to_mcp(db, embeddings, project_path)
492
- except Exception as e:
493
- logger.error(f"Bidirectional sync Native->MCP failed: {e}")
494
- results["native_to_mcp"] = {"success": False, "error": str(e)}
495
-
496
- return results