claude-memory-agent 2.1.0 → 2.2.1

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.
Files changed (91) hide show
  1. package/bin/cli.js +11 -1
  2. package/bin/lib/banner.js +39 -0
  3. package/bin/lib/environment.js +166 -0
  4. package/bin/lib/installer.js +291 -0
  5. package/bin/lib/models.js +95 -0
  6. package/bin/lib/steps/advanced.js +101 -0
  7. package/bin/lib/steps/confirm.js +87 -0
  8. package/bin/lib/steps/model.js +57 -0
  9. package/bin/lib/steps/provider.js +65 -0
  10. package/bin/lib/steps/scope.js +59 -0
  11. package/bin/lib/steps/server.js +74 -0
  12. package/bin/lib/ui.js +75 -0
  13. package/bin/onboarding.js +164 -0
  14. package/bin/postinstall.js +22 -257
  15. package/config.py +103 -4
  16. package/dashboard.html +697 -27
  17. package/hooks/extract_memories.py +439 -0
  18. package/hooks/pre_compact_hook.py +76 -0
  19. package/hooks/session_end_hook.py +149 -0
  20. package/hooks/stop_hook.py +372 -0
  21. package/install.py +91 -37
  22. package/main.py +1636 -892
  23. package/mcp_server.py +451 -0
  24. package/package.json +14 -3
  25. package/requirements.txt +12 -8
  26. package/services/adaptive_ranker.py +272 -0
  27. package/services/agent_catalog.json +153 -0
  28. package/services/agent_registry.py +245 -730
  29. package/services/claude_md_sync.py +320 -4
  30. package/services/consolidation.py +417 -0
  31. package/services/database.py +586 -105
  32. package/services/embedding_pipeline.py +262 -0
  33. package/services/embeddings.py +493 -85
  34. package/services/memory_decay.py +408 -0
  35. package/services/native_memory_paths.py +86 -0
  36. package/services/native_memory_sync.py +496 -0
  37. package/services/response_manager.py +183 -0
  38. package/services/terminal_ui.py +199 -0
  39. package/services/tier_manager.py +235 -0
  40. package/services/websocket.py +26 -6
  41. package/skills/search.py +136 -61
  42. package/skills/session_review.py +210 -23
  43. package/skills/store.py +125 -18
  44. package/terminal_dashboard.py +474 -0
  45. package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
  46. package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
  47. package/hooks/__pycache__/grounding-hook.cpython-312.pyc +0 -0
  48. package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
  49. package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  50. package/services/__pycache__/__init__.cpython-312.pyc +0 -0
  51. package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
  52. package/services/__pycache__/auth.cpython-312.pyc +0 -0
  53. package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
  54. package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
  55. package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
  56. package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
  57. package/services/__pycache__/confidence.cpython-312.pyc +0 -0
  58. package/services/__pycache__/curator.cpython-312.pyc +0 -0
  59. package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
  60. package/services/__pycache__/database.cpython-312.pyc +0 -0
  61. package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
  62. package/services/__pycache__/insights.cpython-312.pyc +0 -0
  63. package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
  64. package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
  65. package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
  66. package/services/__pycache__/timeline.cpython-312.pyc +0 -0
  67. package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
  68. package/services/__pycache__/websocket.cpython-312.pyc +0 -0
  69. package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
  70. package/skills/__pycache__/admin.cpython-312.pyc +0 -0
  71. package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
  72. package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
  73. package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
  74. package/skills/__pycache__/confidence_tracker.cpython-312.pyc +0 -0
  75. package/skills/__pycache__/context.cpython-312.pyc +0 -0
  76. package/skills/__pycache__/curator.cpython-312.pyc +0 -0
  77. package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
  78. package/skills/__pycache__/insights.cpython-312.pyc +0 -0
  79. package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
  80. package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
  81. package/skills/__pycache__/search.cpython-312.pyc +0 -0
  82. package/skills/__pycache__/session_review.cpython-312.pyc +0 -0
  83. package/skills/__pycache__/state.cpython-312.pyc +0 -0
  84. package/skills/__pycache__/store.cpython-312.pyc +0 -0
  85. package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
  86. package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
  87. package/skills/__pycache__/verification.cpython-312.pyc +0 -0
  88. package/test_automation.py +0 -221
  89. package/test_complete.py +0 -338
  90. package/test_full.py +0 -322
  91. package/verify_db.py +0 -134
@@ -0,0 +1,496 @@
1
+ """Bidirectional sync between MCP vector DB and Claude Code's native auto memory.
2
+
3
+ Direction A (MCP -> Native): Fast, no embeddings needed.
4
+ High-importance MCP memories -> fenced section in native MEMORY.md
5
+
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
8
+
9
+ Dedup: markdown_syncs.content_hash prevents duplicates in both directions.
10
+ """
11
+ import hashlib
12
+ import logging
13
+ import re
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Dict, Any, List, Optional
17
+
18
+ from services.native_memory_paths import get_native_memory_md, list_native_memory_files
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _ensure_markdown_syncs_table(conn):
24
+ """Create the markdown_syncs table if it doesn't exist yet.
25
+
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
+ """
29
+ conn.execute("""
30
+ CREATE TABLE IF NOT EXISTS markdown_syncs (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ file_type TEXT NOT NULL,
33
+ file_path TEXT NOT NULL,
34
+ memory_id INTEGER,
35
+ project_path TEXT,
36
+ synced_at TEXT DEFAULT (datetime('now')),
37
+ content_hash TEXT,
38
+ FOREIGN KEY (memory_id) REFERENCES memories(id)
39
+ )
40
+ """)
41
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_markdown_syncs_type ON markdown_syncs(file_type)")
42
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_markdown_syncs_project ON markdown_syncs(project_path)")
43
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_markdown_syncs_memory ON markdown_syncs(memory_id)")
44
+ conn.commit()
45
+
46
+
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
+ def _content_hash(text: str) -> str:
59
+ """Generate a short hash for dedup tracking."""
60
+ return hashlib.md5(text.strip().encode("utf-8")).hexdigest()[:16]
61
+
62
+
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
+ def _strip_mcp_section(content: str) -> str:
246
+ """Remove the MCP-SYNCED fenced section from content."""
247
+ pattern = re.compile(
248
+ re.escape(MCP_SYNC_START) + r".*?" + re.escape(MCP_SYNC_END),
249
+ re.DOTALL,
250
+ )
251
+ stripped = pattern.sub("", content)
252
+ # Clean up extra blank lines left behind
253
+ stripped = re.sub(r"\n{3,}", "\n\n", stripped)
254
+ return stripped.rstrip("\n") + "\n" if stripped.strip() else ""
255
+
256
+
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"
262
+
263
+ return user_content + "\n" + section_text + "\n"
264
+
265
+
266
+ # ── Direction B: Native -> MCP ────────────────────────────────────────────
267
+
268
+
269
+ async def sync_native_to_mcp(
270
+ db,
271
+ embeddings,
272
+ project_path: str,
273
+ ) -> Dict[str, Any]:
274
+ """Sync native MEMORY.md content into the MCP vector DB.
275
+
276
+ 1. Read native MEMORY.md + topic files
277
+ 2. Parse into entries (## sections or bullet groups)
278
+ 3. Skip the MCP-SYNCED section (avoid circular sync)
279
+ 4. Hash each entry, check markdown_syncs for file_type='native_to_mcp'
280
+ 5. For new/changed entries: generate embedding, store in MCP DB
281
+ 6. Record in markdown_syncs
282
+
283
+ Args:
284
+ db: DatabaseService instance
285
+ embeddings: EmbeddingService instance
286
+ project_path: Absolute project path
287
+
288
+ Returns:
289
+ Dict with sync results
290
+ """
291
+ from services.database import normalize_path
292
+ norm_path = normalize_path(project_path)
293
+
294
+ _ensure_markdown_syncs_table(db.conn)
295
+
296
+ all_files = list_native_memory_files(project_path)
297
+ if not all_files:
298
+ return {"success": True, "synced": 0, "reason": "no native memory files"}
299
+
300
+ # 1. Read and parse all files
301
+ entries = []
302
+ for fpath in all_files:
303
+ try:
304
+ raw = fpath.read_text(encoding="utf-8")
305
+ except Exception as e:
306
+ logger.warning(f"Native->MCP: failed to read {fpath}: {e}")
307
+ continue
308
+
309
+ # Strip the MCP-synced section to avoid circular import
310
+ clean = _strip_mcp_section(raw)
311
+
312
+ parsed = _parse_markdown_entries(clean, source_file=fpath.name)
313
+ entries.extend(parsed)
314
+
315
+ if not entries:
316
+ return {"success": True, "synced": 0, "reason": "no parseable entries"}
317
+
318
+ # 2. Check what's already synced
319
+ cursor = db.conn.cursor()
320
+ already_synced_hashes = set()
321
+ try:
322
+ cursor.execute("""
323
+ SELECT content_hash FROM markdown_syncs
324
+ WHERE file_type = 'native_to_mcp'
325
+ AND project_path = ?
326
+ """, (norm_path,))
327
+ already_synced_hashes = {row["content_hash"] for row in cursor.fetchall()}
328
+ except Exception as e:
329
+ logger.warning(f"Native->MCP: failed to check synced hashes: {e}")
330
+
331
+ # 3. Filter to new/changed entries
332
+ new_entries = []
333
+ for entry in entries:
334
+ h = _content_hash(entry["content"])
335
+ if h not in already_synced_hashes:
336
+ entry["hash"] = h
337
+ new_entries.append(entry)
338
+
339
+ if not new_entries:
340
+ return {"success": True, "synced": 0, "reason": "all already synced"}
341
+
342
+ # 4. Generate embeddings and store
343
+ now = datetime.now().isoformat()
344
+ synced_count = 0
345
+ errors = []
346
+
347
+ for entry in new_entries:
348
+ try:
349
+ # Generate embedding
350
+ emb_result = await embeddings.generate_embedding(entry["content"])
351
+ if hasattr(emb_result, "ok") and not emb_result.ok:
352
+ logger.warning(f"Native->MCP: embedding failed for entry: {emb_result.error_message}")
353
+ errors.append(entry["content"][:50])
354
+ continue
355
+
356
+ # Extract the embedding vector
357
+ embedding = emb_result.embedding if hasattr(emb_result, "embedding") else emb_result
358
+
359
+ # Build tags
360
+ tags = [NATIVE_SOURCE_TAG]
361
+ if entry.get("source_file"):
362
+ tags.append(f"file={entry['source_file']}")
363
+ if entry.get("section"):
364
+ tags.append(f"section={entry['section']}")
365
+
366
+ # Store in MCP DB
367
+ import json
368
+ embedding_json = json.dumps(
369
+ embedding if isinstance(embedding, list) else embedding.tolist()
370
+ if hasattr(embedding, "tolist") else list(embedding)
371
+ )
372
+
373
+ # Use only columns guaranteed to exist in the active DB schema
374
+ cursor.execute("""
375
+ INSERT INTO memories (type, content, embedding, project_path, importance, tags, created_at)
376
+ VALUES ('chunk', ?, ?, ?, 6, ?, ?)
377
+ """, (
378
+ entry["content"],
379
+ embedding_json,
380
+ norm_path,
381
+ json.dumps(tags),
382
+ now,
383
+ ))
384
+ memory_id = cursor.lastrowid
385
+
386
+ # Record in markdown_syncs
387
+ cursor.execute("""
388
+ INSERT INTO markdown_syncs (file_type, file_path, memory_id, project_path, synced_at, content_hash)
389
+ VALUES ('native_to_mcp', ?, ?, ?, ?, ?)
390
+ """, (
391
+ entry.get("source_file", "MEMORY.md"),
392
+ memory_id,
393
+ norm_path,
394
+ now,
395
+ entry["hash"],
396
+ ))
397
+ synced_count += 1
398
+
399
+ except Exception as e:
400
+ logger.error(f"Native->MCP: failed to sync entry: {e}")
401
+ errors.append(str(e))
402
+
403
+ db.conn.commit()
404
+
405
+ return {
406
+ "success": True,
407
+ "synced": synced_count,
408
+ "total_entries": len(entries),
409
+ "new_entries": len(new_entries),
410
+ "errors": errors if errors else None,
411
+ }
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
@@ -0,0 +1,183 @@
1
+ """Response size management with progressive degradation.
2
+
3
+ Ensures MCP tool responses stay within Claude Code's token limits
4
+ by applying increasingly aggressive size reduction strategies.
5
+ """
6
+ import json
7
+ import logging
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from config import config
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Fields added by graph enrichment that are safe to strip
15
+ GRAPH_ENRICHMENT_FIELDS = frozenset({
16
+ "known_fixes", "rationale", "consequences",
17
+ "contradictions", "causal_chain",
18
+ })
19
+
20
+ # Keys in result dicts that hold lists of memory items
21
+ RESULT_LIST_KEYS = (
22
+ "results", "memories", "patterns", "decisions",
23
+ "code_patterns", "relevant_to_query", "matches",
24
+ )
25
+
26
+
27
+ def _json_size(data: Any, indent: Optional[int] = 2) -> int:
28
+ """Return the character count of JSON-serialized data."""
29
+ return len(json.dumps(data, indent=indent, default=str))
30
+
31
+
32
+ def _strip_graph_fields(data: Any) -> Any:
33
+ """Recursively remove graph enrichment fields from dicts/lists."""
34
+ if isinstance(data, dict):
35
+ return {
36
+ k: _strip_graph_fields(v)
37
+ for k, v in data.items()
38
+ if k not in GRAPH_ENRICHMENT_FIELDS
39
+ }
40
+ if isinstance(data, list):
41
+ return [_strip_graph_fields(item) for item in data]
42
+ return data
43
+
44
+
45
+ def _truncate_content_fields(data: Any, max_len: int) -> Any:
46
+ """Truncate string values in 'content', 'outcome', 'solution' fields."""
47
+ truncatable = {"content", "outcome", "solution", "description"}
48
+ if isinstance(data, dict):
49
+ result = {}
50
+ for k, v in data.items():
51
+ if k in truncatable and isinstance(v, str) and len(v) > max_len:
52
+ result[k] = v[:max_len] + "..."
53
+ else:
54
+ result[k] = _truncate_content_fields(v, max_len)
55
+ return result
56
+ if isinstance(data, list):
57
+ return [_truncate_content_fields(item, max_len) for item in data]
58
+ return data
59
+
60
+
61
+ def _halve_result_lists(data: Any, min_count: int) -> Any:
62
+ """Reduce list-type result fields to at most half their size (min min_count)."""
63
+ if not isinstance(data, dict):
64
+ return data
65
+ result = {}
66
+ for k, v in data.items():
67
+ if k in RESULT_LIST_KEYS and isinstance(v, list) and len(v) > min_count:
68
+ new_len = max(len(v) // 2, min_count)
69
+ result[k] = v[:new_len]
70
+ else:
71
+ result[k] = _halve_result_lists(v, min_count)
72
+ return result
73
+
74
+
75
+ def fit_response(
76
+ data: Any,
77
+ max_chars: Optional[int] = None,
78
+ ) -> str:
79
+ """Serialize data to JSON, applying progressive degradation if too large.
80
+
81
+ Degradation levels:
82
+ 0 - Full response with indent=2
83
+ 1 - Compact JSON (no indent)
84
+ 2 - Strip graph enrichment fields
85
+ 3 - Truncate content fields to CONTENT_TRUNCATE_LENGTH
86
+ 4 - Halve result list counts (min MIN_RESULT_COUNT)
87
+ 5 - Emergency hard truncation
88
+
89
+ Returns:
90
+ JSON string guaranteed to be <= max_chars.
91
+ """
92
+ if max_chars is None:
93
+ max_chars = config.MAX_RESPONSE_CHARS
94
+
95
+ level = 0
96
+ working = data
97
+
98
+ # Level 0: full pretty-printed JSON
99
+ output = json.dumps(working, indent=2, default=str)
100
+ if len(output) <= max_chars:
101
+ return output
102
+
103
+ # Level 1: compact JSON
104
+ level = 1
105
+ output = json.dumps(working, default=str)
106
+ if len(output) <= max_chars:
107
+ return _with_meta(output, working, level, max_chars)
108
+
109
+ # Level 2: strip graph enrichment fields
110
+ level = 2
111
+ working = _strip_graph_fields(working)
112
+ output = json.dumps(working, default=str)
113
+ if len(output) <= max_chars:
114
+ return _with_meta(output, working, level, max_chars)
115
+
116
+ # Level 3: truncate content fields
117
+ level = 3
118
+ working = _truncate_content_fields(working, config.CONTENT_TRUNCATE_LENGTH)
119
+ output = json.dumps(working, default=str)
120
+ if len(output) <= max_chars:
121
+ return _with_meta(output, working, level, max_chars)
122
+
123
+ # Level 4: halve result counts
124
+ level = 4
125
+ working = _halve_result_lists(working, config.MIN_RESULT_COUNT)
126
+ output = json.dumps(working, default=str)
127
+ if len(output) <= max_chars:
128
+ return _with_meta(output, working, level, max_chars)
129
+
130
+ # Level 5: emergency hard truncation
131
+ level = 5
132
+ logger.warning(
133
+ "Response required emergency truncation: %d -> %d chars",
134
+ len(output), max_chars,
135
+ )
136
+ output = output[:max_chars - 100]
137
+ # Append a valid JSON suffix with metadata
138
+ meta = json.dumps({
139
+ "_response_meta": {
140
+ "degradation_level": level,
141
+ "truncated": True,
142
+ "original_chars": _json_size(data, indent=None),
143
+ "note": "Response was emergency-truncated. Use specific queries to retrieve full data.",
144
+ }
145
+ })
146
+ return output + "\n" + meta
147
+
148
+
149
+ def _with_meta(
150
+ compact_json: str,
151
+ working_data: Any,
152
+ level: int,
153
+ max_chars: int,
154
+ ) -> str:
155
+ """Inject _response_meta into the serialized response."""
156
+ if not isinstance(working_data, dict):
157
+ return compact_json
158
+
159
+ meta = {
160
+ "degradation_level": level,
161
+ "truncated": False,
162
+ "note": _level_description(level),
163
+ }
164
+ working_data["_response_meta"] = meta
165
+ output = json.dumps(working_data, default=str)
166
+
167
+ # If adding meta pushes us over, return without meta
168
+ if len(output) > max_chars:
169
+ del working_data["_response_meta"]
170
+ return compact_json
171
+
172
+ return output
173
+
174
+
175
+ def _level_description(level: int) -> str:
176
+ descriptions = {
177
+ 1: "Compact JSON (whitespace removed)",
178
+ 2: "Graph enrichment fields stripped",
179
+ 3: "Content fields truncated",
180
+ 4: "Result counts reduced",
181
+ 5: "Emergency truncation applied",
182
+ }
183
+ return descriptions.get(level, "Unknown degradation")