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.
- package/bin/cli.js +11 -1
- package/bin/lib/banner.js +39 -0
- package/bin/lib/environment.js +166 -0
- package/bin/lib/installer.js +291 -0
- package/bin/lib/models.js +95 -0
- package/bin/lib/steps/advanced.js +101 -0
- package/bin/lib/steps/confirm.js +87 -0
- package/bin/lib/steps/model.js +57 -0
- package/bin/lib/steps/provider.js +65 -0
- package/bin/lib/steps/scope.js +59 -0
- package/bin/lib/steps/server.js +74 -0
- package/bin/lib/ui.js +75 -0
- package/bin/onboarding.js +164 -0
- package/bin/postinstall.js +22 -257
- package/config.py +103 -4
- package/dashboard.html +697 -27
- package/hooks/extract_memories.py +439 -0
- package/hooks/pre_compact_hook.py +76 -0
- package/hooks/session_end_hook.py +149 -0
- package/hooks/stop_hook.py +372 -0
- package/install.py +91 -37
- package/main.py +1636 -892
- package/mcp_server.py +451 -0
- package/package.json +14 -3
- package/requirements.txt +12 -8
- package/services/adaptive_ranker.py +272 -0
- package/services/agent_catalog.json +153 -0
- package/services/agent_registry.py +245 -730
- package/services/claude_md_sync.py +320 -4
- package/services/consolidation.py +417 -0
- package/services/database.py +586 -105
- package/services/embedding_pipeline.py +262 -0
- package/services/embeddings.py +493 -85
- package/services/memory_decay.py +408 -0
- package/services/native_memory_paths.py +86 -0
- package/services/native_memory_sync.py +496 -0
- package/services/response_manager.py +183 -0
- package/services/terminal_ui.py +199 -0
- package/services/tier_manager.py +235 -0
- package/services/websocket.py +26 -6
- package/skills/search.py +136 -61
- package/skills/session_review.py +210 -23
- package/skills/store.py +125 -18
- package/terminal_dashboard.py +474 -0
- package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
- package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
- package/hooks/__pycache__/grounding-hook.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
- package/services/__pycache__/__init__.cpython-312.pyc +0 -0
- package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
- package/services/__pycache__/auth.cpython-312.pyc +0 -0
- package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
- package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
- package/services/__pycache__/confidence.cpython-312.pyc +0 -0
- package/services/__pycache__/curator.cpython-312.pyc +0 -0
- package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
- package/services/__pycache__/database.cpython-312.pyc +0 -0
- package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
- package/services/__pycache__/insights.cpython-312.pyc +0 -0
- package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
- package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
- package/services/__pycache__/timeline.cpython-312.pyc +0 -0
- package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
- package/services/__pycache__/websocket.cpython-312.pyc +0 -0
- package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
- package/skills/__pycache__/admin.cpython-312.pyc +0 -0
- package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
- package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
- package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/skills/__pycache__/confidence_tracker.cpython-312.pyc +0 -0
- package/skills/__pycache__/context.cpython-312.pyc +0 -0
- package/skills/__pycache__/curator.cpython-312.pyc +0 -0
- package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
- package/skills/__pycache__/insights.cpython-312.pyc +0 -0
- package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
- package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
- package/skills/__pycache__/search.cpython-312.pyc +0 -0
- package/skills/__pycache__/session_review.cpython-312.pyc +0 -0
- package/skills/__pycache__/state.cpython-312.pyc +0 -0
- package/skills/__pycache__/store.cpython-312.pyc +0 -0
- package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
- package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
- package/skills/__pycache__/verification.cpython-312.pyc +0 -0
- package/test_automation.py +0 -221
- package/test_complete.py +0 -338
- package/test_full.py +0 -322
- 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")
|