brainlayer 1.0.0__py3-none-any.whl
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.
- brainlayer/__init__.py +3 -0
- brainlayer/cli/__init__.py +1545 -0
- brainlayer/cli/wizard.py +132 -0
- brainlayer/cli_new.py +151 -0
- brainlayer/client.py +164 -0
- brainlayer/clustering.py +736 -0
- brainlayer/daemon.py +1105 -0
- brainlayer/dashboard/README.md +129 -0
- brainlayer/dashboard/__init__.py +5 -0
- brainlayer/dashboard/app.py +151 -0
- brainlayer/dashboard/search.py +229 -0
- brainlayer/dashboard/views.py +230 -0
- brainlayer/embeddings.py +131 -0
- brainlayer/engine.py +550 -0
- brainlayer/index_new.py +87 -0
- brainlayer/mcp/__init__.py +1558 -0
- brainlayer/migrate.py +205 -0
- brainlayer/paths.py +43 -0
- brainlayer/pipeline/__init__.py +47 -0
- brainlayer/pipeline/analyze_communication.py +508 -0
- brainlayer/pipeline/brain_graph.py +567 -0
- brainlayer/pipeline/chat_tags.py +63 -0
- brainlayer/pipeline/chunk.py +422 -0
- brainlayer/pipeline/classify.py +472 -0
- brainlayer/pipeline/cluster_sampling.py +73 -0
- brainlayer/pipeline/enrichment.py +810 -0
- brainlayer/pipeline/extract.py +66 -0
- brainlayer/pipeline/extract_claude_desktop.py +149 -0
- brainlayer/pipeline/extract_corrections.py +231 -0
- brainlayer/pipeline/extract_markdown.py +195 -0
- brainlayer/pipeline/extract_whatsapp.py +227 -0
- brainlayer/pipeline/git_overlay.py +301 -0
- brainlayer/pipeline/longitudinal_analyzer.py +568 -0
- brainlayer/pipeline/obsidian_export.py +455 -0
- brainlayer/pipeline/operation_grouping.py +486 -0
- brainlayer/pipeline/plan_linking.py +313 -0
- brainlayer/pipeline/sanitize.py +549 -0
- brainlayer/pipeline/semantic_style.py +574 -0
- brainlayer/pipeline/session_enrichment.py +472 -0
- brainlayer/pipeline/style_embed.py +67 -0
- brainlayer/pipeline/style_index.py +139 -0
- brainlayer/pipeline/temporal_chains.py +203 -0
- brainlayer/pipeline/time_batcher.py +248 -0
- brainlayer/pipeline/unified_timeline.py +569 -0
- brainlayer/storage.py +66 -0
- brainlayer/store.py +155 -0
- brainlayer/taxonomy.json +80 -0
- brainlayer/vector_store.py +1891 -0
- brainlayer-1.0.0.dist-info/METADATA +313 -0
- brainlayer-1.0.0.dist-info/RECORD +53 -0
- brainlayer-1.0.0.dist-info/WHEEL +4 -0
- brainlayer-1.0.0.dist-info/entry_points.txt +4 -0
- brainlayer-1.0.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,1558 @@
|
|
|
1
|
+
"""BrainLayer MCP Server - Model Context Protocol interface for Claude Code."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp.server import Server
|
|
7
|
+
from mcp.server.stdio import stdio_server
|
|
8
|
+
from mcp.types import (
|
|
9
|
+
CallToolResult,
|
|
10
|
+
CompleteResult,
|
|
11
|
+
Completion,
|
|
12
|
+
TextContent,
|
|
13
|
+
Tool,
|
|
14
|
+
ToolAnnotations,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from ..embeddings import get_embedding_model
|
|
18
|
+
from ..paths import DEFAULT_DB_PATH
|
|
19
|
+
from ..vector_store import VectorStore
|
|
20
|
+
|
|
21
|
+
# Create MCP server
|
|
22
|
+
server = Server("brainlayer")
|
|
23
|
+
|
|
24
|
+
# Lazy-loaded globals
|
|
25
|
+
_vector_store = None
|
|
26
|
+
_embedding_model = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_project_name(project: str | None) -> str | None:
|
|
30
|
+
"""Normalize project names for consistent filtering.
|
|
31
|
+
|
|
32
|
+
Handles:
|
|
33
|
+
- Claude Code encoded paths: "-Users-username-Gits-myproject" → "myproject"
|
|
34
|
+
- Worktree paths: "myproject-nightshift-1770775282043" → "myproject"
|
|
35
|
+
- Path-like names with multiple segments
|
|
36
|
+
- Already-clean names pass through unchanged
|
|
37
|
+
"""
|
|
38
|
+
if not project:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
name = project.strip()
|
|
42
|
+
if not name or name == "-":
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# Decode Claude Code path encoding
|
|
46
|
+
# "-Users-username-Gits-myproject" → "myproject"
|
|
47
|
+
# "-Users-username-Desktop-Gits-my-monorepo" → "my-monorepo"
|
|
48
|
+
if name.startswith("-"):
|
|
49
|
+
import os
|
|
50
|
+
|
|
51
|
+
# Find the "Gits" segment by splitting on dashes
|
|
52
|
+
segments = name[1:].split("-") # Remove leading dash, split
|
|
53
|
+
gits_idx = None
|
|
54
|
+
for i, s in enumerate(segments):
|
|
55
|
+
if s == "Gits":
|
|
56
|
+
gits_idx = i
|
|
57
|
+
# Use last occurrence in case of nested "Desktop-Gits"
|
|
58
|
+
if gits_idx is not None and gits_idx + 1 < len(segments):
|
|
59
|
+
# Remaining segments after "Gits" form the project path
|
|
60
|
+
remaining = segments[gits_idx + 1 :]
|
|
61
|
+
# Skip secondary "Gits" (e.g., Desktop-Gits)
|
|
62
|
+
while remaining and remaining[0] == "Gits":
|
|
63
|
+
remaining = remaining[1:]
|
|
64
|
+
if not remaining:
|
|
65
|
+
return None
|
|
66
|
+
# Try progressively joining segments with dashes to find a real directory
|
|
67
|
+
gits_dir = "/" + "/".join(segments[:gits_idx]) + "/Gits"
|
|
68
|
+
for length in range(len(remaining), 0, -1):
|
|
69
|
+
candidate_name = "-".join(remaining[:length])
|
|
70
|
+
candidate_path = os.path.join(gits_dir, candidate_name)
|
|
71
|
+
if os.path.isdir(candidate_path):
|
|
72
|
+
return candidate_name
|
|
73
|
+
# Fallback: return first segment (best guess)
|
|
74
|
+
return remaining[0]
|
|
75
|
+
# No "Gits" found — not a standard project path
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# Strip worktree suffixes (nightshift-{epoch}, haiku-*, worktree-*)
|
|
79
|
+
import re
|
|
80
|
+
|
|
81
|
+
name = re.sub(r"-(?:nightshift|haiku|worktree)-\d+$", "", name)
|
|
82
|
+
|
|
83
|
+
return name
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_vector_store() -> VectorStore:
|
|
87
|
+
global _vector_store
|
|
88
|
+
if _vector_store is None:
|
|
89
|
+
_vector_store = VectorStore(DEFAULT_DB_PATH)
|
|
90
|
+
return _vector_store
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _get_embedding_model():
|
|
94
|
+
global _embedding_model
|
|
95
|
+
if _embedding_model is None:
|
|
96
|
+
_embedding_model = get_embedding_model()
|
|
97
|
+
return _embedding_model
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# All BrainLayer tools are read-only (search and analyze only)
|
|
101
|
+
_READ_ONLY = ToolAnnotations(
|
|
102
|
+
readOnlyHint=True,
|
|
103
|
+
destructiveHint=False,
|
|
104
|
+
idempotentHint=True,
|
|
105
|
+
openWorldHint=False,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Write tool annotation — for brainlayer_store
|
|
109
|
+
_WRITE = ToolAnnotations(
|
|
110
|
+
readOnlyHint=False,
|
|
111
|
+
destructiveHint=False,
|
|
112
|
+
idempotentHint=False,
|
|
113
|
+
openWorldHint=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _error_result(message: str) -> CallToolResult:
|
|
118
|
+
"""Return a CallToolResult with isError=True. Bypasses outputSchema validation."""
|
|
119
|
+
return CallToolResult(content=[TextContent(type="text", text=message)], isError=True)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _memory_to_dict(item: dict) -> dict:
|
|
123
|
+
"""Convert a memory item dict to structured output format."""
|
|
124
|
+
d: dict = {"content": item.get("content", "")}
|
|
125
|
+
for key in ("summary", "intent", "importance", "project", "content_type"):
|
|
126
|
+
if item.get(key) is not None:
|
|
127
|
+
d[key] = item[key]
|
|
128
|
+
if item.get("created_at"):
|
|
129
|
+
d["date"] = item["created_at"][:10]
|
|
130
|
+
if item.get("tags") and isinstance(item["tags"], list):
|
|
131
|
+
d["tags"] = [str(t) for t in item["tags"]]
|
|
132
|
+
return d
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- Output Schemas (MCP spec 2025-06-18+) ---
|
|
136
|
+
# Tools with outputSchema MUST return structuredContent alongside text content.
|
|
137
|
+
|
|
138
|
+
_MEMORY_ITEM_SCHEMA = {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"summary": {"type": "string"},
|
|
142
|
+
"content": {"type": "string"},
|
|
143
|
+
"intent": {"type": "string"},
|
|
144
|
+
"importance": {"type": "number"},
|
|
145
|
+
"project": {"type": "string"},
|
|
146
|
+
"date": {"type": "string"},
|
|
147
|
+
"content_type": {"type": "string"},
|
|
148
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
|
149
|
+
},
|
|
150
|
+
"required": ["content"],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_SEARCH_OUTPUT_SCHEMA = {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"properties": {
|
|
156
|
+
"query": {"type": "string"},
|
|
157
|
+
"total": {"type": "integer"},
|
|
158
|
+
"results": {
|
|
159
|
+
"type": "array",
|
|
160
|
+
"items": {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"properties": {
|
|
163
|
+
"score": {"type": "number"},
|
|
164
|
+
"project": {"type": "string"},
|
|
165
|
+
"content_type": {"type": "string"},
|
|
166
|
+
"content": {"type": "string"},
|
|
167
|
+
"source_file": {"type": "string"},
|
|
168
|
+
"date": {"type": "string"},
|
|
169
|
+
"source": {"type": "string"},
|
|
170
|
+
"summary": {"type": "string"},
|
|
171
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
|
172
|
+
"intent": {"type": "string"},
|
|
173
|
+
"importance": {"type": "number"},
|
|
174
|
+
"chunk_id": {"type": "string"},
|
|
175
|
+
},
|
|
176
|
+
"required": ["content", "project", "content_type", "score"],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
"required": ["query", "total", "results"],
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_STATS_OUTPUT_SCHEMA = {
|
|
184
|
+
"type": "object",
|
|
185
|
+
"properties": {
|
|
186
|
+
"total_chunks": {"type": "integer"},
|
|
187
|
+
"projects": {"type": "array", "items": {"type": "string"}},
|
|
188
|
+
"content_types": {"type": "array", "items": {"type": "string"}},
|
|
189
|
+
},
|
|
190
|
+
"required": ["total_chunks", "projects", "content_types"],
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_THINK_OUTPUT_SCHEMA = {
|
|
194
|
+
"type": "object",
|
|
195
|
+
"properties": {
|
|
196
|
+
"query": {"type": "string"},
|
|
197
|
+
"total": {"type": "integer"},
|
|
198
|
+
"decisions": {"type": "array", "items": _MEMORY_ITEM_SCHEMA},
|
|
199
|
+
"patterns": {"type": "array", "items": _MEMORY_ITEM_SCHEMA},
|
|
200
|
+
"bugs": {"type": "array", "items": _MEMORY_ITEM_SCHEMA},
|
|
201
|
+
"context": {"type": "array", "items": _MEMORY_ITEM_SCHEMA},
|
|
202
|
+
},
|
|
203
|
+
"required": ["query", "total", "decisions", "patterns", "bugs", "context"],
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_RECALL_OUTPUT_SCHEMA = {
|
|
207
|
+
"type": "object",
|
|
208
|
+
"properties": {
|
|
209
|
+
"target": {"type": "string"},
|
|
210
|
+
"file_history": {
|
|
211
|
+
"type": "array",
|
|
212
|
+
"items": {
|
|
213
|
+
"type": "object",
|
|
214
|
+
"properties": {
|
|
215
|
+
"timestamp": {"type": "string"},
|
|
216
|
+
"action": {"type": "string"},
|
|
217
|
+
"session_id": {"type": "string"},
|
|
218
|
+
"file_path": {"type": "string"},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
"related_chunks": {"type": "array", "items": _MEMORY_ITEM_SCHEMA},
|
|
223
|
+
"session_summaries": {
|
|
224
|
+
"type": "array",
|
|
225
|
+
"items": {
|
|
226
|
+
"type": "object",
|
|
227
|
+
"properties": {
|
|
228
|
+
"session_id": {"type": "string"},
|
|
229
|
+
"branch": {"type": "string"},
|
|
230
|
+
"plan_name": {"type": "string"},
|
|
231
|
+
"started_at": {"type": "string"},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
"required": ["target", "file_history", "related_chunks", "session_summaries"],
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_CURRENT_CONTEXT_OUTPUT_SCHEMA = {
|
|
240
|
+
"type": "object",
|
|
241
|
+
"properties": {
|
|
242
|
+
"active_projects": {"type": "array", "items": {"type": "string"}},
|
|
243
|
+
"active_branches": {"type": "array", "items": {"type": "string"}},
|
|
244
|
+
"active_plan": {"type": "string"},
|
|
245
|
+
"recent_files": {"type": "array", "items": {"type": "string"}},
|
|
246
|
+
"recent_sessions": {
|
|
247
|
+
"type": "array",
|
|
248
|
+
"items": {
|
|
249
|
+
"type": "object",
|
|
250
|
+
"properties": {
|
|
251
|
+
"session_id": {"type": "string"},
|
|
252
|
+
"project": {"type": "string"},
|
|
253
|
+
"branch": {"type": "string"},
|
|
254
|
+
"started_at": {"type": "string"},
|
|
255
|
+
"plan_name": {"type": "string"},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
"required": ["active_projects", "active_branches", "active_plan", "recent_files", "recent_sessions"],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_STORE_OUTPUT_SCHEMA = {
|
|
264
|
+
"type": "object",
|
|
265
|
+
"properties": {
|
|
266
|
+
"chunk_id": {"type": "string"},
|
|
267
|
+
"related": {
|
|
268
|
+
"type": "array",
|
|
269
|
+
"items": {
|
|
270
|
+
"type": "object",
|
|
271
|
+
"properties": {
|
|
272
|
+
"content": {"type": "string"},
|
|
273
|
+
"summary": {"type": "string"},
|
|
274
|
+
"project": {"type": "string"},
|
|
275
|
+
"type": {"type": "string"},
|
|
276
|
+
"date": {"type": "string"},
|
|
277
|
+
},
|
|
278
|
+
"required": ["content"],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
"required": ["chunk_id", "related"],
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@server.list_tools()
|
|
287
|
+
async def list_tools() -> list[Tool]:
|
|
288
|
+
"""List available tools."""
|
|
289
|
+
return [
|
|
290
|
+
Tool(
|
|
291
|
+
name="brainlayer_search",
|
|
292
|
+
title="Search Knowledge Base",
|
|
293
|
+
description="""Search through past Claude Code conversations and learnings.
|
|
294
|
+
|
|
295
|
+
Use when: You need to find specific code, errors, or discussions from past sessions.
|
|
296
|
+
Not for: Getting general context (use brainlayer_think) or file history (use brainlayer_recall).
|
|
297
|
+
|
|
298
|
+
Returns: Structured JSON with `query`, `total`, and `results[]`. Each result has `score`, `project`, `content_type`, `content` (truncated to 1000 chars), `chunk_id`, and optional enrichment fields (`summary`, `tags`, `intent`, `importance`, `session_summary`).""",
|
|
299
|
+
annotations=_READ_ONLY,
|
|
300
|
+
inputSchema={
|
|
301
|
+
"type": "object",
|
|
302
|
+
"properties": {
|
|
303
|
+
"query": {
|
|
304
|
+
"type": "string",
|
|
305
|
+
"description": "Natural language search query (e.g., 'how did I implement authentication' or 'React useEffect cleanup')",
|
|
306
|
+
},
|
|
307
|
+
"project": {
|
|
308
|
+
"type": "string",
|
|
309
|
+
"description": "Filter by project name. Use brainlayer_list_projects for valid values. Encoded/worktree names are auto-normalized.",
|
|
310
|
+
},
|
|
311
|
+
"content_type": {
|
|
312
|
+
"type": "string",
|
|
313
|
+
"enum": [
|
|
314
|
+
"ai_code",
|
|
315
|
+
"stack_trace",
|
|
316
|
+
"user_message",
|
|
317
|
+
"assistant_text",
|
|
318
|
+
"file_read",
|
|
319
|
+
"git_diff",
|
|
320
|
+
],
|
|
321
|
+
"description": "Optional: filter by content type",
|
|
322
|
+
},
|
|
323
|
+
"num_results": {
|
|
324
|
+
"type": "integer",
|
|
325
|
+
"default": 5,
|
|
326
|
+
"minimum": 1,
|
|
327
|
+
"maximum": 100,
|
|
328
|
+
"description": "Number of results to return (default: 5, max: 100)",
|
|
329
|
+
},
|
|
330
|
+
"source": {
|
|
331
|
+
"type": "string",
|
|
332
|
+
"enum": ["claude_code", "whatsapp", "youtube", "all"],
|
|
333
|
+
"description": (
|
|
334
|
+
"Filter by data source (default: claude_code). Use 'all' to search everything."
|
|
335
|
+
),
|
|
336
|
+
},
|
|
337
|
+
"tag": {
|
|
338
|
+
"type": "string",
|
|
339
|
+
"description": "Filter by tag (e.g. 'bug-fix', 'authentication', 'typescript')",
|
|
340
|
+
},
|
|
341
|
+
"intent": {
|
|
342
|
+
"type": "string",
|
|
343
|
+
"enum": [
|
|
344
|
+
"debugging",
|
|
345
|
+
"designing",
|
|
346
|
+
"configuring",
|
|
347
|
+
"discussing",
|
|
348
|
+
"deciding",
|
|
349
|
+
"implementing",
|
|
350
|
+
"reviewing",
|
|
351
|
+
],
|
|
352
|
+
"description": "Filter by intent classification",
|
|
353
|
+
},
|
|
354
|
+
"importance_min": {
|
|
355
|
+
"type": "number",
|
|
356
|
+
"description": "Minimum importance score (1-10)",
|
|
357
|
+
},
|
|
358
|
+
"date_from": {
|
|
359
|
+
"type": "string",
|
|
360
|
+
"description": "Filter results from this date (ISO 8601, e.g. '2026-02-01')",
|
|
361
|
+
},
|
|
362
|
+
"date_to": {
|
|
363
|
+
"type": "string",
|
|
364
|
+
"description": "Filter results up to this date (ISO 8601, e.g. '2026-02-19')",
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
"required": ["query"],
|
|
368
|
+
},
|
|
369
|
+
outputSchema=_SEARCH_OUTPUT_SCHEMA,
|
|
370
|
+
),
|
|
371
|
+
Tool(
|
|
372
|
+
name="brainlayer_stats",
|
|
373
|
+
title="Knowledge Base Stats",
|
|
374
|
+
description="""Get statistics about the knowledge base.
|
|
375
|
+
|
|
376
|
+
Returns: Structured JSON with `total_chunks` (int), `projects` (string array), and `content_types` (string array). Also returns Markdown summary text.""",
|
|
377
|
+
annotations=_READ_ONLY,
|
|
378
|
+
inputSchema={"type": "object", "properties": {}},
|
|
379
|
+
outputSchema=_STATS_OUTPUT_SCHEMA,
|
|
380
|
+
),
|
|
381
|
+
Tool(
|
|
382
|
+
name="brainlayer_list_projects",
|
|
383
|
+
title="List Projects",
|
|
384
|
+
description="""List all projects in the knowledge base. Human-friendly Markdown list.
|
|
385
|
+
|
|
386
|
+
Use brainlayer_stats instead for a machine-friendly structured projects array.
|
|
387
|
+
|
|
388
|
+
Returns: Markdown list of project names (no structured output).""",
|
|
389
|
+
annotations=_READ_ONLY,
|
|
390
|
+
inputSchema={"type": "object", "properties": {}},
|
|
391
|
+
),
|
|
392
|
+
Tool(
|
|
393
|
+
name="brainlayer_context",
|
|
394
|
+
title="Get Chunk Context",
|
|
395
|
+
description="""Get surrounding conversation context for a search result.
|
|
396
|
+
|
|
397
|
+
Given a chunk_id from brainlayer_search results, returns the chunks before and after it from the same conversation. Useful for understanding isolated search results.
|
|
398
|
+
|
|
399
|
+
Returns: Markdown with conversation chunks showing position, content_type, and content. The target chunk is marked with [TARGET].""",
|
|
400
|
+
annotations=_READ_ONLY,
|
|
401
|
+
inputSchema={
|
|
402
|
+
"type": "object",
|
|
403
|
+
"properties": {
|
|
404
|
+
"chunk_id": {
|
|
405
|
+
"type": "string",
|
|
406
|
+
"description": "The chunk_id from a brainlayer_search result",
|
|
407
|
+
},
|
|
408
|
+
"before": {
|
|
409
|
+
"type": "integer",
|
|
410
|
+
"default": 3,
|
|
411
|
+
"minimum": 0,
|
|
412
|
+
"maximum": 50,
|
|
413
|
+
"description": "Number of chunks before the target to include (default: 3, max: 50)",
|
|
414
|
+
},
|
|
415
|
+
"after": {
|
|
416
|
+
"type": "integer",
|
|
417
|
+
"default": 3,
|
|
418
|
+
"minimum": 0,
|
|
419
|
+
"maximum": 50,
|
|
420
|
+
"description": "Number of chunks after the target to include (default: 3, max: 50)",
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
"required": ["chunk_id"],
|
|
424
|
+
},
|
|
425
|
+
),
|
|
426
|
+
Tool(
|
|
427
|
+
name="brainlayer_file_timeline",
|
|
428
|
+
title="File Interaction Timeline",
|
|
429
|
+
description="""Get the interaction timeline for a specific file across sessions.
|
|
430
|
+
|
|
431
|
+
Shows all Claude Code sessions that read, edited, or wrote to a file, ordered chronologically. Uses substring matching (e.g., 'auth.ts' matches 'src/auth.ts').
|
|
432
|
+
|
|
433
|
+
Returns: Markdown list of interactions, each with action, file_path, timestamp, session ID, and project.""",
|
|
434
|
+
annotations=_READ_ONLY,
|
|
435
|
+
inputSchema={
|
|
436
|
+
"type": "object",
|
|
437
|
+
"properties": {
|
|
438
|
+
"file_path": {
|
|
439
|
+
"type": "string",
|
|
440
|
+
"description": "File path or partial path (substring match, e.g. 'auth.ts' matches 'src/auth.ts')",
|
|
441
|
+
},
|
|
442
|
+
"project": {
|
|
443
|
+
"type": "string",
|
|
444
|
+
"description": "Optional: filter by project name",
|
|
445
|
+
},
|
|
446
|
+
"limit": {
|
|
447
|
+
"type": "integer",
|
|
448
|
+
"default": 50,
|
|
449
|
+
"description": "Maximum number of interactions to return",
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
"required": ["file_path"],
|
|
453
|
+
},
|
|
454
|
+
),
|
|
455
|
+
Tool(
|
|
456
|
+
name="brainlayer_operations",
|
|
457
|
+
title="Session Operations",
|
|
458
|
+
description="""Get logical operation groups for a session. Operations are patterns like read-edit-test cycles, research chains, or debug sequences.
|
|
459
|
+
|
|
460
|
+
Returns: Markdown list of operations, each with operation_type, summary, outcome (success/failure), step_count, and started_at timestamp.
|
|
461
|
+
|
|
462
|
+
Get session_id from brainlayer_sessions or brainlayer_search result metadata.""",
|
|
463
|
+
annotations=_READ_ONLY,
|
|
464
|
+
inputSchema={
|
|
465
|
+
"type": "object",
|
|
466
|
+
"properties": {
|
|
467
|
+
"session_id": {
|
|
468
|
+
"type": "string",
|
|
469
|
+
"description": "Session ID to query. Get from brainlayer_sessions or brainlayer_search results.",
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
"required": ["session_id"],
|
|
473
|
+
},
|
|
474
|
+
),
|
|
475
|
+
Tool(
|
|
476
|
+
name="brainlayer_regression",
|
|
477
|
+
title="Regression Analysis",
|
|
478
|
+
description="""Analyze a file for regressions — shows the last successful operation and all changes after it.
|
|
479
|
+
|
|
480
|
+
Uses substring matching on file_path (same as brainlayer_file_timeline). "Last success" means the most recent operation with outcome=success for that file.
|
|
481
|
+
|
|
482
|
+
Returns: Markdown with timeline count, last success details (timestamp, session_id, branch), and a list of changes after the last success (action, timestamp, branch).""",
|
|
483
|
+
annotations=_READ_ONLY,
|
|
484
|
+
inputSchema={
|
|
485
|
+
"type": "object",
|
|
486
|
+
"properties": {
|
|
487
|
+
"file_path": {
|
|
488
|
+
"type": "string",
|
|
489
|
+
"description": "File path or partial path (substring match)",
|
|
490
|
+
},
|
|
491
|
+
"project": {"type": "string", "description": "Optional: filter by project"},
|
|
492
|
+
},
|
|
493
|
+
"required": ["file_path"],
|
|
494
|
+
},
|
|
495
|
+
),
|
|
496
|
+
Tool(
|
|
497
|
+
name="brainlayer_plan_links",
|
|
498
|
+
title="Plan-Session Links",
|
|
499
|
+
description="""Query plan-linked sessions. Two modes:
|
|
500
|
+
|
|
501
|
+
1. **Session lookup** (session_id provided): Returns plan/phase/story for that session. Ignores plan_name.
|
|
502
|
+
2. **Plan query** (plan_name provided or neither): Lists all sessions for that plan, or all plan-linked sessions.
|
|
503
|
+
|
|
504
|
+
Returns: Markdown with session details (branch, PR, plan, phase, story).
|
|
505
|
+
|
|
506
|
+
Get session_id from brainlayer_sessions or brainlayer_search results.""",
|
|
507
|
+
annotations=_READ_ONLY,
|
|
508
|
+
inputSchema={
|
|
509
|
+
"type": "object",
|
|
510
|
+
"properties": {
|
|
511
|
+
"plan_name": {
|
|
512
|
+
"type": "string",
|
|
513
|
+
"description": "Plan name to query (e.g. 'local-llm-integration')",
|
|
514
|
+
},
|
|
515
|
+
"session_id": {
|
|
516
|
+
"type": "string",
|
|
517
|
+
"description": "Session ID to look up plan info for. Takes precedence over plan_name.",
|
|
518
|
+
},
|
|
519
|
+
"project": {"type": "string", "description": "Optional: filter by project"},
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
),
|
|
523
|
+
Tool(
|
|
524
|
+
name="brainlayer_think",
|
|
525
|
+
title="Think — Retrieve Relevant Memories",
|
|
526
|
+
description="""Given your current task context, retrieve relevant past decisions, patterns, and code.
|
|
527
|
+
|
|
528
|
+
Use when: Starting a task and you want informed context instead of cold-starting.
|
|
529
|
+
Not for: Searching for specific code (use brainlayer_search) or file history (use brainlayer_recall).
|
|
530
|
+
|
|
531
|
+
Returns: Structured JSON with `query`, `total`, and categorized arrays: `decisions[]`, `patterns[]`, `bugs[]`, `context[]`. Each item has `content` plus optional `summary`, `intent`, `importance`, `project`, `date`, `tags`.""",
|
|
532
|
+
annotations=_READ_ONLY,
|
|
533
|
+
inputSchema={
|
|
534
|
+
"type": "object",
|
|
535
|
+
"properties": {
|
|
536
|
+
"context": {
|
|
537
|
+
"type": "string",
|
|
538
|
+
"description": "Describe what you're working on — the engine will find relevant past knowledge",
|
|
539
|
+
},
|
|
540
|
+
"project": {"type": "string", "description": "Optional: filter by project"},
|
|
541
|
+
"max_results": {
|
|
542
|
+
"type": "integer",
|
|
543
|
+
"default": 10,
|
|
544
|
+
"description": "Maximum memories to retrieve (default: 10)",
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
"required": ["context"],
|
|
548
|
+
},
|
|
549
|
+
outputSchema=_THINK_OUTPUT_SCHEMA,
|
|
550
|
+
),
|
|
551
|
+
Tool(
|
|
552
|
+
name="brainlayer_recall",
|
|
553
|
+
title="Recall — Proactive Context for File or Topic",
|
|
554
|
+
description="""Proactive smart retrieval. Requires at least one of file_path or topic.
|
|
555
|
+
|
|
556
|
+
- file_path mode: "What happened with this file before?" Returns timeline, sessions, related knowledge.
|
|
557
|
+
- topic mode: "What have I discussed about authentication?" Returns related discussions, decisions, patterns.
|
|
558
|
+
|
|
559
|
+
Use when: Opening a file or starting work on a familiar topic.
|
|
560
|
+
Not for: Searching for specific code (use brainlayer_search) or task-scoped context (use brainlayer_think).
|
|
561
|
+
|
|
562
|
+
Returns: Structured JSON with `target`, `file_history[]` (timestamp, action, session_id, file_path), `related_chunks[]`, and `session_summaries[]` (session_id, branch, plan_name, started_at).""",
|
|
563
|
+
annotations=_READ_ONLY,
|
|
564
|
+
inputSchema={
|
|
565
|
+
"type": "object",
|
|
566
|
+
"properties": {
|
|
567
|
+
"file_path": {
|
|
568
|
+
"type": "string",
|
|
569
|
+
"description": "File path to recall context for (e.g., 'auth.ts')",
|
|
570
|
+
},
|
|
571
|
+
"topic": {
|
|
572
|
+
"type": "string",
|
|
573
|
+
"description": "Topic to recall context for (e.g., 'authentication', 'deployment')",
|
|
574
|
+
},
|
|
575
|
+
"project": {"type": "string", "description": "Optional: filter by project"},
|
|
576
|
+
"max_results": {
|
|
577
|
+
"type": "integer",
|
|
578
|
+
"default": 10,
|
|
579
|
+
"description": "Maximum results (default: 10)",
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
outputSchema=_RECALL_OUTPUT_SCHEMA,
|
|
584
|
+
),
|
|
585
|
+
Tool(
|
|
586
|
+
name="brainlayer_sessions",
|
|
587
|
+
title="Browse Recent Sessions",
|
|
588
|
+
description="""List recent Claude Code sessions with metadata.
|
|
589
|
+
|
|
590
|
+
Shows session ID, project, branch, plan linkage, and timestamp. Use this to find session_id values for other tools.
|
|
591
|
+
|
|
592
|
+
Returns: Markdown list of sessions with session_id, project, branch, plan, and started_at.""",
|
|
593
|
+
annotations=_READ_ONLY,
|
|
594
|
+
inputSchema={
|
|
595
|
+
"type": "object",
|
|
596
|
+
"properties": {
|
|
597
|
+
"project": {"type": "string", "description": "Optional: filter by project"},
|
|
598
|
+
"days": {
|
|
599
|
+
"type": "integer",
|
|
600
|
+
"default": 7,
|
|
601
|
+
"minimum": 1,
|
|
602
|
+
"maximum": 365,
|
|
603
|
+
"description": "How many days back to look (default: 7, max: 365)",
|
|
604
|
+
},
|
|
605
|
+
"limit": {
|
|
606
|
+
"type": "integer",
|
|
607
|
+
"default": 20,
|
|
608
|
+
"minimum": 1,
|
|
609
|
+
"maximum": 100,
|
|
610
|
+
"description": "Maximum sessions to return (default: 20, max: 100)",
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
),
|
|
615
|
+
Tool(
|
|
616
|
+
name="brainlayer_current_context",
|
|
617
|
+
title="Current Working Context",
|
|
618
|
+
description="""Get what you're currently working on — recent sessions, projects, files, and active plan.
|
|
619
|
+
|
|
620
|
+
Lightweight (no embedding needed). Use at conversation start to understand current state.
|
|
621
|
+
|
|
622
|
+
Returns: Structured JSON with `active_projects` (string[]), `active_branches` (string[]), `active_plan` (string), `recent_files` (string[]), and `recent_sessions[]` (each with session_id, project, branch, started_at, plan_name). Arrays may be empty if no recent activity.""",
|
|
623
|
+
annotations=_READ_ONLY,
|
|
624
|
+
inputSchema={
|
|
625
|
+
"type": "object",
|
|
626
|
+
"properties": {
|
|
627
|
+
"hours": {
|
|
628
|
+
"type": "integer",
|
|
629
|
+
"default": 24,
|
|
630
|
+
"description": "How many hours back to look (default: 24)",
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
outputSchema=_CURRENT_CONTEXT_OUTPUT_SCHEMA,
|
|
635
|
+
),
|
|
636
|
+
Tool(
|
|
637
|
+
name="brainlayer_session_summary",
|
|
638
|
+
title="Session Summary",
|
|
639
|
+
description="""Get the enriched summary of a session. Requires sessions to have been enriched via 'brainlayer enrich-sessions'.
|
|
640
|
+
|
|
641
|
+
Returns: Markdown with summary, intent, outcome, quality score, complexity, duration, message counts, and sections for decisions, corrections, learnings, mistakes, what_worked, what_failed, and tags.
|
|
642
|
+
|
|
643
|
+
Get session_id from brainlayer_sessions or brainlayer_search result metadata.""",
|
|
644
|
+
annotations=_READ_ONLY,
|
|
645
|
+
inputSchema={
|
|
646
|
+
"type": "object",
|
|
647
|
+
"properties": {
|
|
648
|
+
"session_id": {
|
|
649
|
+
"type": "string",
|
|
650
|
+
"description": "Session ID to get summary for. Get from brainlayer_sessions or brainlayer_search results.",
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
"required": ["session_id"],
|
|
654
|
+
},
|
|
655
|
+
),
|
|
656
|
+
Tool(
|
|
657
|
+
name="brainlayer_store",
|
|
658
|
+
title="Store Memory",
|
|
659
|
+
description="""Persistently store a memory into BrainLayer.
|
|
660
|
+
|
|
661
|
+
Use this to save ideas, mistakes, decisions, learnings, todos, bookmarks, notes, or journal entries. Stored items are embedded at write time and immediately searchable.
|
|
662
|
+
|
|
663
|
+
Returns: Structured JSON with `chunk_id` (string, usable with brainlayer_context) and `related[]` (list of similar existing memories, each with content, summary, project, type, date).""",
|
|
664
|
+
annotations=_WRITE,
|
|
665
|
+
inputSchema={
|
|
666
|
+
"type": "object",
|
|
667
|
+
"properties": {
|
|
668
|
+
"content": {
|
|
669
|
+
"type": "string",
|
|
670
|
+
"description": "The text content to store (e.g., a decision, learning, idea)",
|
|
671
|
+
},
|
|
672
|
+
"type": {
|
|
673
|
+
"type": "string",
|
|
674
|
+
"enum": ["idea", "mistake", "decision", "learning", "todo", "bookmark", "note", "journal"],
|
|
675
|
+
"description": "What kind of memory this is",
|
|
676
|
+
},
|
|
677
|
+
"project": {
|
|
678
|
+
"type": "string",
|
|
679
|
+
"description": "Optional: project name to scope the memory",
|
|
680
|
+
},
|
|
681
|
+
"tags": {
|
|
682
|
+
"type": "array",
|
|
683
|
+
"items": {"type": "string"},
|
|
684
|
+
"description": "Optional: tags for categorization (e.g., ['reliability', 'api'])",
|
|
685
|
+
},
|
|
686
|
+
"importance": {
|
|
687
|
+
"type": "integer",
|
|
688
|
+
"minimum": 1,
|
|
689
|
+
"maximum": 10,
|
|
690
|
+
"description": "Optional: importance score 1-10",
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
"required": ["content", "type"],
|
|
694
|
+
},
|
|
695
|
+
outputSchema=_STORE_OUTPUT_SCHEMA,
|
|
696
|
+
),
|
|
697
|
+
]
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@server.completion()
|
|
701
|
+
async def handle_completion(ref, argument) -> CompleteResult:
|
|
702
|
+
"""Provide completions for tool arguments."""
|
|
703
|
+
# Only handle tool argument completions
|
|
704
|
+
if not hasattr(ref, "name"):
|
|
705
|
+
return CompleteResult(completion=Completion(values=[]))
|
|
706
|
+
|
|
707
|
+
arg_name = argument.name if hasattr(argument, "name") else ""
|
|
708
|
+
arg_value = argument.value if hasattr(argument, "value") else ""
|
|
709
|
+
|
|
710
|
+
if arg_name == "project":
|
|
711
|
+
try:
|
|
712
|
+
store = _get_vector_store()
|
|
713
|
+
stats = store.get_stats()
|
|
714
|
+
projects = stats.get("projects", [])
|
|
715
|
+
# Normalize and filter by prefix
|
|
716
|
+
normalized = []
|
|
717
|
+
for p in projects:
|
|
718
|
+
norm = _normalize_project_name(p) or p
|
|
719
|
+
if norm not in normalized:
|
|
720
|
+
normalized.append(norm)
|
|
721
|
+
if arg_value:
|
|
722
|
+
normalized = [p for p in normalized if p.lower().startswith(arg_value.lower())]
|
|
723
|
+
return CompleteResult(completion=Completion(values=sorted(normalized)[:20], hasMore=len(normalized) > 20))
|
|
724
|
+
except Exception:
|
|
725
|
+
return CompleteResult(completion=Completion(values=[]))
|
|
726
|
+
|
|
727
|
+
elif arg_name == "content_type":
|
|
728
|
+
types = ["ai_code", "stack_trace", "user_message", "assistant_text", "file_read", "git_diff"]
|
|
729
|
+
if arg_value:
|
|
730
|
+
types = [t for t in types if t.startswith(arg_value)]
|
|
731
|
+
return CompleteResult(completion=Completion(values=types))
|
|
732
|
+
|
|
733
|
+
elif arg_name == "source":
|
|
734
|
+
sources = ["claude_code", "whatsapp", "youtube", "all"]
|
|
735
|
+
if arg_value:
|
|
736
|
+
sources = [s for s in sources if s.startswith(arg_value)]
|
|
737
|
+
return CompleteResult(completion=Completion(values=sources))
|
|
738
|
+
|
|
739
|
+
elif arg_name == "intent":
|
|
740
|
+
intents = ["debugging", "designing", "configuring", "discussing", "deciding", "implementing", "reviewing"]
|
|
741
|
+
if arg_value:
|
|
742
|
+
intents = [i for i in intents if i.startswith(arg_value)]
|
|
743
|
+
return CompleteResult(completion=Completion(values=intents))
|
|
744
|
+
|
|
745
|
+
return CompleteResult(completion=Completion(values=[]))
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@server.call_tool()
|
|
749
|
+
async def call_tool(name: str, arguments: dict[str, Any]):
|
|
750
|
+
"""Handle tool calls."""
|
|
751
|
+
|
|
752
|
+
if name == "brainlayer_search":
|
|
753
|
+
return await _search(
|
|
754
|
+
query=arguments["query"],
|
|
755
|
+
project=arguments.get("project"),
|
|
756
|
+
content_type=arguments.get("content_type"),
|
|
757
|
+
num_results=arguments.get("num_results", 5),
|
|
758
|
+
source=arguments.get("source"),
|
|
759
|
+
tag=arguments.get("tag"),
|
|
760
|
+
intent=arguments.get("intent"),
|
|
761
|
+
importance_min=arguments.get("importance_min"),
|
|
762
|
+
date_from=arguments.get("date_from"),
|
|
763
|
+
date_to=arguments.get("date_to"),
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
elif name == "brainlayer_stats":
|
|
767
|
+
return await _stats()
|
|
768
|
+
|
|
769
|
+
elif name == "brainlayer_list_projects":
|
|
770
|
+
return await _list_projects()
|
|
771
|
+
|
|
772
|
+
elif name == "brainlayer_context":
|
|
773
|
+
return await _context(
|
|
774
|
+
chunk_id=arguments["chunk_id"],
|
|
775
|
+
before=max(0, min(arguments.get("before", 3), 50)),
|
|
776
|
+
after=max(0, min(arguments.get("after", 3), 50)),
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
elif name == "brainlayer_file_timeline":
|
|
780
|
+
return await _file_timeline(
|
|
781
|
+
file_path=arguments["file_path"],
|
|
782
|
+
project=arguments.get("project"),
|
|
783
|
+
limit=arguments.get("limit", 50),
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
elif name == "brainlayer_operations":
|
|
787
|
+
return await _operations(
|
|
788
|
+
session_id=arguments["session_id"],
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
elif name == "brainlayer_regression":
|
|
792
|
+
return await _regression(
|
|
793
|
+
file_path=arguments["file_path"],
|
|
794
|
+
project=arguments.get("project"),
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
elif name == "brainlayer_plan_links":
|
|
798
|
+
return await _plan_links(
|
|
799
|
+
plan_name=arguments.get("plan_name"),
|
|
800
|
+
session_id=arguments.get("session_id"),
|
|
801
|
+
project=arguments.get("project"),
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
elif name == "brainlayer_think":
|
|
805
|
+
return await _think(
|
|
806
|
+
context=arguments["context"],
|
|
807
|
+
project=arguments.get("project"),
|
|
808
|
+
max_results=arguments.get("max_results", 10),
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
elif name == "brainlayer_recall":
|
|
812
|
+
# Validate: at least one of file_path or topic is required
|
|
813
|
+
if not arguments.get("file_path") and not arguments.get("topic"):
|
|
814
|
+
return _error_result("Validation error: provide at least one of 'file_path' or 'topic'.")
|
|
815
|
+
return await _recall(
|
|
816
|
+
file_path=arguments.get("file_path"),
|
|
817
|
+
topic=arguments.get("topic"),
|
|
818
|
+
project=arguments.get("project"),
|
|
819
|
+
max_results=arguments.get("max_results", 10),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
elif name == "brainlayer_sessions":
|
|
823
|
+
return await _sessions(
|
|
824
|
+
project=arguments.get("project"),
|
|
825
|
+
days=max(1, min(arguments.get("days", 7), 365)),
|
|
826
|
+
limit=max(1, min(arguments.get("limit", 20), 100)),
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
elif name == "brainlayer_current_context":
|
|
830
|
+
return await _current_context(
|
|
831
|
+
hours=arguments.get("hours", 24),
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
elif name == "brainlayer_session_summary":
|
|
835
|
+
return await _session_summary(session_id=arguments["session_id"])
|
|
836
|
+
|
|
837
|
+
elif name == "brainlayer_store":
|
|
838
|
+
imp = arguments.get("importance")
|
|
839
|
+
return await _store(
|
|
840
|
+
content=arguments["content"],
|
|
841
|
+
memory_type=arguments["type"],
|
|
842
|
+
project=arguments.get("project"),
|
|
843
|
+
tags=arguments.get("tags"),
|
|
844
|
+
importance=max(1, min(imp, 10)) if imp is not None else None,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
else:
|
|
848
|
+
return _error_result(f"Unknown tool: {name}")
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
async def _search(
|
|
852
|
+
query: str,
|
|
853
|
+
project: str | None = None,
|
|
854
|
+
content_type: str | None = None,
|
|
855
|
+
num_results: int = 5,
|
|
856
|
+
source: str | None = None,
|
|
857
|
+
tag: str | None = None,
|
|
858
|
+
intent: str | None = None,
|
|
859
|
+
importance_min: float | None = None,
|
|
860
|
+
date_from: str | None = None,
|
|
861
|
+
date_to: str | None = None,
|
|
862
|
+
):
|
|
863
|
+
"""Execute a hybrid search query (semantic + keyword via RRF)."""
|
|
864
|
+
try:
|
|
865
|
+
if num_results < 1:
|
|
866
|
+
num_results = 5
|
|
867
|
+
elif num_results > 100:
|
|
868
|
+
num_results = 100
|
|
869
|
+
|
|
870
|
+
store = _get_vector_store()
|
|
871
|
+
|
|
872
|
+
if store.count() == 0:
|
|
873
|
+
empty = {"query": query, "total": 0, "results": []}
|
|
874
|
+
return (
|
|
875
|
+
[TextContent(type="text", text="Knowledge base is empty. Run 'brainlayer index' to populate it.")],
|
|
876
|
+
empty,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
# Normalize project name for consistent filtering
|
|
880
|
+
normalized_project = _normalize_project_name(project)
|
|
881
|
+
|
|
882
|
+
# Generate embedding (run in thread to not block)
|
|
883
|
+
loop = asyncio.get_running_loop()
|
|
884
|
+
model = _get_embedding_model()
|
|
885
|
+
query_embedding = await loop.run_in_executor(None, model.embed_query, query)
|
|
886
|
+
|
|
887
|
+
# Default to claude_code unless explicitly set to 'all'
|
|
888
|
+
if source == "all":
|
|
889
|
+
source_filter = None
|
|
890
|
+
elif source:
|
|
891
|
+
source_filter = source
|
|
892
|
+
else:
|
|
893
|
+
source_filter = "claude_code"
|
|
894
|
+
|
|
895
|
+
# Use hybrid search (semantic + FTS5 keyword via RRF)
|
|
896
|
+
results = store.hybrid_search(
|
|
897
|
+
query_embedding=query_embedding,
|
|
898
|
+
query_text=query,
|
|
899
|
+
n_results=num_results,
|
|
900
|
+
project_filter=normalized_project,
|
|
901
|
+
content_type_filter=content_type,
|
|
902
|
+
source_filter=source_filter,
|
|
903
|
+
tag_filter=tag,
|
|
904
|
+
intent_filter=intent,
|
|
905
|
+
importance_min=importance_min,
|
|
906
|
+
date_from=date_from,
|
|
907
|
+
date_to=date_to,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
if not results["documents"][0]:
|
|
911
|
+
empty = {"query": query, "total": 0, "results": []}
|
|
912
|
+
return ([TextContent(type="text", text="No results found.")], empty)
|
|
913
|
+
|
|
914
|
+
# Enrich results with session-level context (Phase 7)
|
|
915
|
+
results = store.enrich_results_with_session_context(results)
|
|
916
|
+
|
|
917
|
+
# Build structured results + formatted text
|
|
918
|
+
output_parts = [f"## Search Results for: {query}\n"]
|
|
919
|
+
structured_results = []
|
|
920
|
+
|
|
921
|
+
for i, (doc, meta, dist) in enumerate(
|
|
922
|
+
zip(results["documents"][0], results["metadatas"][0], results["distances"][0])
|
|
923
|
+
):
|
|
924
|
+
score = 1 - dist if dist is not None else 0
|
|
925
|
+
|
|
926
|
+
# Build structured result item
|
|
927
|
+
item = {
|
|
928
|
+
"score": round(score, 4),
|
|
929
|
+
"project": _normalize_project_name(meta.get("project")) or meta.get("project", "unknown"),
|
|
930
|
+
"content_type": meta.get("content_type", "unknown"),
|
|
931
|
+
"content": doc[:1000],
|
|
932
|
+
"source_file": meta.get("source_file", "unknown"),
|
|
933
|
+
}
|
|
934
|
+
if meta.get("created_at"):
|
|
935
|
+
item["date"] = meta["created_at"][:10] if len(meta.get("created_at", "")) >= 10 else meta["created_at"]
|
|
936
|
+
if meta.get("source") and meta["source"] != "claude_code":
|
|
937
|
+
item["source"] = meta["source"]
|
|
938
|
+
if meta.get("summary"):
|
|
939
|
+
item["summary"] = meta["summary"]
|
|
940
|
+
if meta.get("tags") and isinstance(meta["tags"], list):
|
|
941
|
+
item["tags"] = [str(t) for t in meta["tags"][:5]]
|
|
942
|
+
if meta.get("intent"):
|
|
943
|
+
item["intent"] = meta["intent"]
|
|
944
|
+
if meta.get("importance") is not None:
|
|
945
|
+
item["importance"] = meta["importance"]
|
|
946
|
+
if meta.get("chunk_id"):
|
|
947
|
+
item["chunk_id"] = meta["chunk_id"]
|
|
948
|
+
# Session-level enrichment (Phase 7)
|
|
949
|
+
if meta.get("session_summary"):
|
|
950
|
+
item["session_summary"] = meta["session_summary"]
|
|
951
|
+
if meta.get("session_outcome"):
|
|
952
|
+
item["session_outcome"] = meta["session_outcome"]
|
|
953
|
+
if meta.get("session_quality") is not None:
|
|
954
|
+
item["session_quality"] = meta["session_quality"]
|
|
955
|
+
structured_results.append(item)
|
|
956
|
+
|
|
957
|
+
# Build text output (same as before)
|
|
958
|
+
output_parts.append(f"\n### Result {i + 1} (score: {score:.3f})")
|
|
959
|
+
enrichment_parts = []
|
|
960
|
+
if meta.get("intent"):
|
|
961
|
+
enrichment_parts.append(f"Intent: {meta['intent']}")
|
|
962
|
+
if meta.get("importance") is not None:
|
|
963
|
+
enrichment_parts.append(f"Importance: {meta['importance']:.0f}/10")
|
|
964
|
+
if meta.get("tags") and isinstance(meta["tags"], list):
|
|
965
|
+
enrichment_parts.append(f"Tags: {', '.join(str(t) for t in meta['tags'][:5])}")
|
|
966
|
+
project_display = item["project"]
|
|
967
|
+
if project_display == "unknown" and meta.get("contact_name"):
|
|
968
|
+
project_display = meta["contact_name"]
|
|
969
|
+
header = f"**Project:** {project_display} | **Type:** {meta.get('content_type', 'unknown')}"
|
|
970
|
+
if item.get("date"):
|
|
971
|
+
header += f" | **Date:** {item['date']}"
|
|
972
|
+
if item.get("source"):
|
|
973
|
+
header += f" | **Source:** {item['source']}"
|
|
974
|
+
output_parts.append(header)
|
|
975
|
+
if enrichment_parts:
|
|
976
|
+
output_parts.append(f"**{' | '.join(enrichment_parts)}**")
|
|
977
|
+
if meta.get("summary"):
|
|
978
|
+
output_parts.append(f"> {meta['summary']}")
|
|
979
|
+
if meta.get("session_summary"):
|
|
980
|
+
output_parts.append(f"**Session:** {meta['session_summary'][:200]}")
|
|
981
|
+
output_parts.append(f"**File:** `{meta.get('source_file', 'unknown')}`\n")
|
|
982
|
+
output_parts.append(doc[:1000] + ("..." if len(doc) > 1000 else ""))
|
|
983
|
+
output_parts.append("\n---")
|
|
984
|
+
|
|
985
|
+
structured = {
|
|
986
|
+
"query": query,
|
|
987
|
+
"total": len(structured_results),
|
|
988
|
+
"results": structured_results,
|
|
989
|
+
}
|
|
990
|
+
return ([TextContent(type="text", text="\n".join(output_parts))], structured)
|
|
991
|
+
|
|
992
|
+
except Exception as e:
|
|
993
|
+
return _error_result(f"Search error (query='{query[:50]}...'): {str(e)}")
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
async def _stats():
|
|
997
|
+
"""Get knowledge base statistics."""
|
|
998
|
+
try:
|
|
999
|
+
store = _get_vector_store()
|
|
1000
|
+
stats = store.get_stats()
|
|
1001
|
+
|
|
1002
|
+
output = f"""## BrainLayer Knowledge Base Stats
|
|
1003
|
+
|
|
1004
|
+
- **Total Chunks:** {stats["total_chunks"]}
|
|
1005
|
+
- **Projects:** {", ".join(stats["projects"][:15])}{"..." if len(stats["projects"]) > 15 else ""}
|
|
1006
|
+
- **Content Types:** {", ".join(stats["content_types"])}
|
|
1007
|
+
"""
|
|
1008
|
+
structured = {
|
|
1009
|
+
"total_chunks": stats["total_chunks"],
|
|
1010
|
+
"projects": stats["projects"],
|
|
1011
|
+
"content_types": stats["content_types"],
|
|
1012
|
+
}
|
|
1013
|
+
return ([TextContent(type="text", text=output)], structured)
|
|
1014
|
+
|
|
1015
|
+
except Exception as e:
|
|
1016
|
+
return _error_result(f"Stats error: {str(e)}")
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
async def _list_projects() -> list[TextContent]:
|
|
1020
|
+
"""List all projects."""
|
|
1021
|
+
try:
|
|
1022
|
+
store = _get_vector_store()
|
|
1023
|
+
stats = store.get_stats()
|
|
1024
|
+
|
|
1025
|
+
if not stats["projects"]:
|
|
1026
|
+
return [TextContent(type="text", text="No projects indexed yet.")]
|
|
1027
|
+
|
|
1028
|
+
output = "## Indexed Projects\n\n"
|
|
1029
|
+
for proj in sorted(stats["projects"]):
|
|
1030
|
+
output += f"- {proj}\n"
|
|
1031
|
+
|
|
1032
|
+
return [TextContent(type="text", text=output)]
|
|
1033
|
+
|
|
1034
|
+
except Exception as e:
|
|
1035
|
+
return _error_result(f"Error listing projects: {str(e)}")
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
async def _context(chunk_id: str, before: int = 3, after: int = 3) -> list[TextContent]:
|
|
1039
|
+
"""Get surrounding conversation context for a chunk."""
|
|
1040
|
+
try:
|
|
1041
|
+
store = _get_vector_store()
|
|
1042
|
+
result = store.get_context(chunk_id, before=before, after=after)
|
|
1043
|
+
|
|
1044
|
+
if result.get("error"):
|
|
1045
|
+
return _error_result(f"Unknown chunk_id '{chunk_id[:20]}...'. Use chunk_id from brainlayer_search results.")
|
|
1046
|
+
|
|
1047
|
+
if not result.get("context"):
|
|
1048
|
+
return [TextContent(type="text", text="No context available for this chunk.")]
|
|
1049
|
+
|
|
1050
|
+
output_parts = ["## Conversation Context\n"]
|
|
1051
|
+
|
|
1052
|
+
for chunk in result["context"]:
|
|
1053
|
+
marker = " **[TARGET]**" if chunk.get("is_target") else ""
|
|
1054
|
+
ctype = chunk.get("content_type", "unknown")
|
|
1055
|
+
pos = chunk.get("position", "?")
|
|
1056
|
+
output_parts.append(f"\n### Position {pos} ({ctype}){marker}\n")
|
|
1057
|
+
content = chunk.get("content", "")
|
|
1058
|
+
output_parts.append(content[:1500] + ("..." if len(content) > 1500 else ""))
|
|
1059
|
+
output_parts.append("\n---")
|
|
1060
|
+
|
|
1061
|
+
return [TextContent(type="text", text="\n".join(output_parts))]
|
|
1062
|
+
|
|
1063
|
+
except Exception as e:
|
|
1064
|
+
return _error_result(f"Context error: {str(e)}")
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
async def _file_timeline(
|
|
1068
|
+
file_path: str,
|
|
1069
|
+
project: str | None = None,
|
|
1070
|
+
limit: int = 50,
|
|
1071
|
+
) -> list[TextContent]:
|
|
1072
|
+
"""Get interaction timeline for a file."""
|
|
1073
|
+
try:
|
|
1074
|
+
store = _get_vector_store()
|
|
1075
|
+
interactions = store.get_file_timeline(file_path, project=project, limit=limit)
|
|
1076
|
+
|
|
1077
|
+
if not interactions:
|
|
1078
|
+
return [TextContent(type="text", text=f"No interactions found for '{file_path}'.")]
|
|
1079
|
+
|
|
1080
|
+
output_parts = [f"## File Timeline: {file_path}\n"]
|
|
1081
|
+
output_parts.append(f"Found {len(interactions)} interactions:\n")
|
|
1082
|
+
|
|
1083
|
+
for i, row in enumerate(interactions):
|
|
1084
|
+
ts = row.get("timestamp", "?")
|
|
1085
|
+
action = row.get("action", "?")
|
|
1086
|
+
session = row.get("session_id", "?")[:8]
|
|
1087
|
+
proj = row.get("project", "?")
|
|
1088
|
+
fp = row.get("file_path", file_path)
|
|
1089
|
+
output_parts.append(f"{i + 1}. **{action}** `{fp}` at {ts} (session: {session}, project: {proj})")
|
|
1090
|
+
|
|
1091
|
+
return [TextContent(type="text", text="\n".join(output_parts))]
|
|
1092
|
+
|
|
1093
|
+
except Exception as e:
|
|
1094
|
+
return _error_result(f"File timeline error: {str(e)}")
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
async def _operations(
|
|
1098
|
+
session_id: str,
|
|
1099
|
+
) -> list[TextContent]:
|
|
1100
|
+
"""Get operations for a session."""
|
|
1101
|
+
try:
|
|
1102
|
+
store = _get_vector_store()
|
|
1103
|
+
ops = store.get_session_operations(session_id)
|
|
1104
|
+
|
|
1105
|
+
if not ops:
|
|
1106
|
+
return [
|
|
1107
|
+
TextContent(
|
|
1108
|
+
type="text",
|
|
1109
|
+
text=(f"No operations for session '{session_id[:8]}...'."),
|
|
1110
|
+
)
|
|
1111
|
+
]
|
|
1112
|
+
|
|
1113
|
+
output_parts = [
|
|
1114
|
+
f"## Operations: {session_id[:8]}...\n",
|
|
1115
|
+
f"Found {len(ops)} operations:\n",
|
|
1116
|
+
]
|
|
1117
|
+
|
|
1118
|
+
for i, op in enumerate(ops):
|
|
1119
|
+
outcome = op.get("outcome", "unknown")
|
|
1120
|
+
ts = (op.get("started_at") or "?")[:19]
|
|
1121
|
+
output_parts.append(
|
|
1122
|
+
f"{i + 1}. **{op.get('operation_type', '?')}** — {op.get('summary') or '?'} [{outcome}] at {ts}"
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
return [
|
|
1126
|
+
TextContent(
|
|
1127
|
+
type="text",
|
|
1128
|
+
text="\n".join(output_parts),
|
|
1129
|
+
)
|
|
1130
|
+
]
|
|
1131
|
+
|
|
1132
|
+
except Exception as e:
|
|
1133
|
+
return _error_result(f"Operations error: {str(e)}")
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
async def _regression(
|
|
1137
|
+
file_path: str,
|
|
1138
|
+
project: str | None = None,
|
|
1139
|
+
) -> list[TextContent]:
|
|
1140
|
+
"""Analyze a file for regressions."""
|
|
1141
|
+
try:
|
|
1142
|
+
store = _get_vector_store()
|
|
1143
|
+
result = store.get_file_regression(file_path, project=project)
|
|
1144
|
+
|
|
1145
|
+
if not result["timeline"]:
|
|
1146
|
+
return [
|
|
1147
|
+
TextContent(
|
|
1148
|
+
type="text",
|
|
1149
|
+
text=(f"No interactions found for '{file_path}'."),
|
|
1150
|
+
)
|
|
1151
|
+
]
|
|
1152
|
+
|
|
1153
|
+
parts = [
|
|
1154
|
+
f"## Regression Analysis: {file_path}\n",
|
|
1155
|
+
f"Timeline: {len(result['timeline'])} interactions\n",
|
|
1156
|
+
]
|
|
1157
|
+
|
|
1158
|
+
if result["last_success"]:
|
|
1159
|
+
ls = result["last_success"]
|
|
1160
|
+
parts.append(
|
|
1161
|
+
f"**Last success:** {ls['timestamp']}"
|
|
1162
|
+
f" (session {ls['session_id'][:8]},"
|
|
1163
|
+
f" branch {ls.get('branch', '?')})\n"
|
|
1164
|
+
)
|
|
1165
|
+
else:
|
|
1166
|
+
parts.append("**No successful operations found**\n")
|
|
1167
|
+
|
|
1168
|
+
if result["changes_after"]:
|
|
1169
|
+
parts.append(f"**Changes after last success:** {len(result['changes_after'])}\n")
|
|
1170
|
+
for i, c in enumerate(result["changes_after"][:15]):
|
|
1171
|
+
ts = (c["timestamp"] or "?")[:19]
|
|
1172
|
+
branch = c.get("branch") or "?"
|
|
1173
|
+
parts.append(f"{i + 1}. {c['action']} at {ts} (branch: {branch})")
|
|
1174
|
+
|
|
1175
|
+
return [
|
|
1176
|
+
TextContent(
|
|
1177
|
+
type="text",
|
|
1178
|
+
text="\n".join(parts),
|
|
1179
|
+
)
|
|
1180
|
+
]
|
|
1181
|
+
|
|
1182
|
+
except Exception as e:
|
|
1183
|
+
return _error_result(f"Regression error: {str(e)}")
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
async def _plan_links(
|
|
1187
|
+
plan_name: str | None = None,
|
|
1188
|
+
session_id: str | None = None,
|
|
1189
|
+
project: str | None = None,
|
|
1190
|
+
) -> list[TextContent]:
|
|
1191
|
+
"""Query plan-linked sessions."""
|
|
1192
|
+
try:
|
|
1193
|
+
store = _get_vector_store()
|
|
1194
|
+
|
|
1195
|
+
if session_id:
|
|
1196
|
+
ctx = store.get_session_context(session_id)
|
|
1197
|
+
if not ctx:
|
|
1198
|
+
return [
|
|
1199
|
+
TextContent(
|
|
1200
|
+
type="text",
|
|
1201
|
+
text=f"No context for session '{session_id[:8]}'.",
|
|
1202
|
+
)
|
|
1203
|
+
]
|
|
1204
|
+
parts = [
|
|
1205
|
+
f"## Session {ctx['session_id'][:8]}\n",
|
|
1206
|
+
f"- Branch: {ctx.get('branch') or '?'}",
|
|
1207
|
+
f"- PR: #{ctx.get('pr_number') or '?'}",
|
|
1208
|
+
f"- Plan: {ctx.get('plan_name') or '(none)'}",
|
|
1209
|
+
f"- Phase: {ctx.get('plan_phase') or '(none)'}",
|
|
1210
|
+
f"- Story: {ctx.get('story_id') or '(none)'}",
|
|
1211
|
+
]
|
|
1212
|
+
return [
|
|
1213
|
+
TextContent(
|
|
1214
|
+
type="text",
|
|
1215
|
+
text="\n".join(parts),
|
|
1216
|
+
)
|
|
1217
|
+
]
|
|
1218
|
+
|
|
1219
|
+
sessions = store.get_sessions_by_plan(plan_name=plan_name, project=project)
|
|
1220
|
+
if not sessions:
|
|
1221
|
+
if plan_name:
|
|
1222
|
+
msg = f"No sessions linked to plan '{plan_name}'."
|
|
1223
|
+
else:
|
|
1224
|
+
msg = "No plan-linked sessions found."
|
|
1225
|
+
return [TextContent(type="text", text=msg)]
|
|
1226
|
+
|
|
1227
|
+
title = plan_name or "All Plans"
|
|
1228
|
+
parts = [f"## Sessions: {title}\n"]
|
|
1229
|
+
for s in sessions[:30]:
|
|
1230
|
+
sid = (s["session_id"] or "")[:8]
|
|
1231
|
+
branch = s.get("branch") or "?"
|
|
1232
|
+
pr = f"#{s['pr_number']}" if s.get("pr_number") else ""
|
|
1233
|
+
phase = s.get("plan_phase") or ""
|
|
1234
|
+
plan = s.get("plan_name") or ""
|
|
1235
|
+
started = (s.get("started_at") or "")[:19]
|
|
1236
|
+
parts.append(f"- {sid} | {plan}/{phase} | {branch} {pr} | {started}")
|
|
1237
|
+
|
|
1238
|
+
stats = store.get_plan_linking_stats()
|
|
1239
|
+
parts.append(f"\nTotal: {stats['linked_sessions']}/{stats['total_sessions']} linked")
|
|
1240
|
+
|
|
1241
|
+
return [
|
|
1242
|
+
TextContent(
|
|
1243
|
+
type="text",
|
|
1244
|
+
text="\n".join(parts),
|
|
1245
|
+
)
|
|
1246
|
+
]
|
|
1247
|
+
|
|
1248
|
+
except Exception as e:
|
|
1249
|
+
return _error_result(f"Plan links error: {str(e)}")
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
async def _think(
|
|
1253
|
+
context: str,
|
|
1254
|
+
project: str | None = None,
|
|
1255
|
+
max_results: int = 10,
|
|
1256
|
+
):
|
|
1257
|
+
"""Execute think — retrieve relevant memories for current task."""
|
|
1258
|
+
try:
|
|
1259
|
+
from ..engine import think
|
|
1260
|
+
|
|
1261
|
+
store = _get_vector_store()
|
|
1262
|
+
model = _get_embedding_model()
|
|
1263
|
+
|
|
1264
|
+
# Run embedding in thread to not block
|
|
1265
|
+
loop = asyncio.get_running_loop()
|
|
1266
|
+
|
|
1267
|
+
def _embed(text: str) -> list[float]:
|
|
1268
|
+
return model.embed_query(text)
|
|
1269
|
+
|
|
1270
|
+
# Normalize project
|
|
1271
|
+
normalized_project = _normalize_project_name(project)
|
|
1272
|
+
|
|
1273
|
+
result = await loop.run_in_executor(
|
|
1274
|
+
None,
|
|
1275
|
+
lambda: think(
|
|
1276
|
+
context=context,
|
|
1277
|
+
store=store,
|
|
1278
|
+
embed_fn=_embed,
|
|
1279
|
+
project=normalized_project,
|
|
1280
|
+
max_results=max_results,
|
|
1281
|
+
),
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
structured = {
|
|
1285
|
+
"query": result.query,
|
|
1286
|
+
"total": result.total,
|
|
1287
|
+
"decisions": [_memory_to_dict(i) for i in result.decisions],
|
|
1288
|
+
"patterns": [_memory_to_dict(i) for i in result.patterns],
|
|
1289
|
+
"bugs": [_memory_to_dict(i) for i in result.bugs],
|
|
1290
|
+
"context": [_memory_to_dict(i) for i in result.context],
|
|
1291
|
+
}
|
|
1292
|
+
return ([TextContent(type="text", text=result.format())], structured)
|
|
1293
|
+
|
|
1294
|
+
except Exception as e:
|
|
1295
|
+
return _error_result(f"Think error: {str(e)}")
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
async def _recall(
|
|
1299
|
+
file_path: str | None = None,
|
|
1300
|
+
topic: str | None = None,
|
|
1301
|
+
project: str | None = None,
|
|
1302
|
+
max_results: int = 10,
|
|
1303
|
+
):
|
|
1304
|
+
"""Execute recall — proactive context retrieval."""
|
|
1305
|
+
try:
|
|
1306
|
+
from ..engine import recall
|
|
1307
|
+
|
|
1308
|
+
store = _get_vector_store()
|
|
1309
|
+
model = _get_embedding_model()
|
|
1310
|
+
normalized_project = _normalize_project_name(project)
|
|
1311
|
+
|
|
1312
|
+
loop = asyncio.get_running_loop()
|
|
1313
|
+
|
|
1314
|
+
def _embed(text: str) -> list[float]:
|
|
1315
|
+
return model.embed_query(text)
|
|
1316
|
+
|
|
1317
|
+
result = await loop.run_in_executor(
|
|
1318
|
+
None,
|
|
1319
|
+
lambda: recall(
|
|
1320
|
+
store=store,
|
|
1321
|
+
embed_fn=_embed,
|
|
1322
|
+
file_path=file_path,
|
|
1323
|
+
topic=topic,
|
|
1324
|
+
project=normalized_project,
|
|
1325
|
+
max_results=max_results,
|
|
1326
|
+
),
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
structured = {
|
|
1330
|
+
"target": result.target,
|
|
1331
|
+
"file_history": [
|
|
1332
|
+
{
|
|
1333
|
+
"timestamp": (h.get("timestamp") or "")[:19],
|
|
1334
|
+
"action": h.get("action", ""),
|
|
1335
|
+
"session_id": h.get("session_id", ""),
|
|
1336
|
+
"file_path": h.get("file_path", ""),
|
|
1337
|
+
}
|
|
1338
|
+
for h in result.file_history
|
|
1339
|
+
],
|
|
1340
|
+
"related_chunks": [_memory_to_dict(c) for c in result.related_chunks],
|
|
1341
|
+
"session_summaries": [
|
|
1342
|
+
{
|
|
1343
|
+
"session_id": s.get("session_id", ""),
|
|
1344
|
+
"branch": s.get("branch", ""),
|
|
1345
|
+
"plan_name": s.get("plan_name", ""),
|
|
1346
|
+
"started_at": (s.get("started_at") or "")[:19],
|
|
1347
|
+
}
|
|
1348
|
+
for s in result.session_summaries
|
|
1349
|
+
],
|
|
1350
|
+
}
|
|
1351
|
+
return ([TextContent(type="text", text=result.format())], structured)
|
|
1352
|
+
|
|
1353
|
+
except Exception as e:
|
|
1354
|
+
return _error_result(f"Recall error: {str(e)}")
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
async def _sessions(
|
|
1358
|
+
project: str | None = None,
|
|
1359
|
+
days: int = 7,
|
|
1360
|
+
limit: int = 20,
|
|
1361
|
+
) -> list[TextContent]:
|
|
1362
|
+
"""Execute sessions — list recent sessions."""
|
|
1363
|
+
try:
|
|
1364
|
+
from ..engine import format_sessions, sessions
|
|
1365
|
+
|
|
1366
|
+
store = _get_vector_store()
|
|
1367
|
+
normalized_project = _normalize_project_name(project)
|
|
1368
|
+
|
|
1369
|
+
result = sessions(
|
|
1370
|
+
store=store,
|
|
1371
|
+
project=normalized_project,
|
|
1372
|
+
days=days,
|
|
1373
|
+
limit=limit,
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
return [TextContent(type="text", text=format_sessions(result, days=days))]
|
|
1377
|
+
|
|
1378
|
+
except Exception as e:
|
|
1379
|
+
return _error_result(f"Sessions error: {str(e)}")
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
async def _session_summary(session_id: str):
|
|
1383
|
+
"""Get enriched session summary."""
|
|
1384
|
+
try:
|
|
1385
|
+
store = _get_vector_store()
|
|
1386
|
+
enrichment = store.get_session_enrichment(session_id)
|
|
1387
|
+
|
|
1388
|
+
if not enrichment:
|
|
1389
|
+
return [
|
|
1390
|
+
TextContent(
|
|
1391
|
+
type="text",
|
|
1392
|
+
text=f"No enrichment data for session '{session_id[:8]}...'. Run 'brainlayer enrich-sessions' first.",
|
|
1393
|
+
)
|
|
1394
|
+
]
|
|
1395
|
+
|
|
1396
|
+
parts = [f"## Session Summary: {session_id[:8]}...\n"]
|
|
1397
|
+
|
|
1398
|
+
if enrichment.get("session_summary"):
|
|
1399
|
+
parts.append(f"**Summary:** {enrichment['session_summary']}\n")
|
|
1400
|
+
if enrichment.get("primary_intent"):
|
|
1401
|
+
parts.append(f"**Intent:** {enrichment['primary_intent']}")
|
|
1402
|
+
if enrichment.get("outcome"):
|
|
1403
|
+
parts.append(f"**Outcome:** {enrichment['outcome']}")
|
|
1404
|
+
if enrichment.get("session_quality_score"):
|
|
1405
|
+
parts.append(f"**Quality:** {enrichment['session_quality_score']}/10")
|
|
1406
|
+
if enrichment.get("complexity_score"):
|
|
1407
|
+
parts.append(f"**Complexity:** {enrichment['complexity_score']}/10")
|
|
1408
|
+
if enrichment.get("duration_seconds"):
|
|
1409
|
+
mins = enrichment["duration_seconds"] // 60
|
|
1410
|
+
parts.append(f"**Duration:** {mins} min")
|
|
1411
|
+
parts.append(
|
|
1412
|
+
f"**Messages:** {enrichment.get('message_count', 0)} "
|
|
1413
|
+
f"(user: {enrichment.get('user_message_count', 0)}, "
|
|
1414
|
+
f"assistant: {enrichment.get('assistant_message_count', 0)})\n"
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
if enrichment.get("decisions_made"):
|
|
1418
|
+
parts.append("### Decisions")
|
|
1419
|
+
for d in enrichment["decisions_made"]:
|
|
1420
|
+
if isinstance(d, dict):
|
|
1421
|
+
parts.append(f"- {d.get('decision', '?')} — *{d.get('rationale', '')}*")
|
|
1422
|
+
else:
|
|
1423
|
+
parts.append(f"- {d}")
|
|
1424
|
+
|
|
1425
|
+
if enrichment.get("corrections"):
|
|
1426
|
+
parts.append("\n### Corrections")
|
|
1427
|
+
for c in enrichment["corrections"]:
|
|
1428
|
+
if isinstance(c, dict):
|
|
1429
|
+
parts.append(f"- Wrong: {c.get('what_was_wrong', '?')} → Wanted: {c.get('what_user_wanted', '?')}")
|
|
1430
|
+
else:
|
|
1431
|
+
parts.append(f"- {c}")
|
|
1432
|
+
|
|
1433
|
+
if enrichment.get("learnings"):
|
|
1434
|
+
parts.append("\n### Learnings")
|
|
1435
|
+
for l in enrichment["learnings"]:
|
|
1436
|
+
parts.append(f"- {l}")
|
|
1437
|
+
|
|
1438
|
+
if enrichment.get("mistakes"):
|
|
1439
|
+
parts.append("\n### Mistakes")
|
|
1440
|
+
for m in enrichment["mistakes"]:
|
|
1441
|
+
parts.append(f"- {m}")
|
|
1442
|
+
|
|
1443
|
+
if enrichment.get("what_worked"):
|
|
1444
|
+
parts.append(f"\n**What worked:** {enrichment['what_worked']}")
|
|
1445
|
+
if enrichment.get("what_failed"):
|
|
1446
|
+
parts.append(f"**What failed:** {enrichment['what_failed']}")
|
|
1447
|
+
|
|
1448
|
+
if enrichment.get("topic_tags"):
|
|
1449
|
+
parts.append(f"\n**Tags:** {', '.join(enrichment['topic_tags'][:10])}")
|
|
1450
|
+
|
|
1451
|
+
return [TextContent(type="text", text="\n".join(parts))]
|
|
1452
|
+
|
|
1453
|
+
except Exception as e:
|
|
1454
|
+
return _error_result(f"Session summary error: {str(e)}")
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
async def _current_context(
|
|
1458
|
+
hours: int = 24,
|
|
1459
|
+
):
|
|
1460
|
+
"""Execute current_context — lightweight session awareness."""
|
|
1461
|
+
try:
|
|
1462
|
+
from ..engine import current_context
|
|
1463
|
+
|
|
1464
|
+
store = _get_vector_store()
|
|
1465
|
+
result = current_context(store=store, hours=hours)
|
|
1466
|
+
|
|
1467
|
+
structured = {
|
|
1468
|
+
"active_projects": result.active_projects,
|
|
1469
|
+
"active_branches": result.active_branches,
|
|
1470
|
+
"active_plan": result.active_plan,
|
|
1471
|
+
"recent_files": result.recent_files,
|
|
1472
|
+
"recent_sessions": [
|
|
1473
|
+
{
|
|
1474
|
+
"session_id": s.session_id,
|
|
1475
|
+
"project": s.project,
|
|
1476
|
+
"branch": s.branch,
|
|
1477
|
+
"started_at": s.started_at[:19] if s.started_at else "",
|
|
1478
|
+
"plan_name": s.plan_name,
|
|
1479
|
+
}
|
|
1480
|
+
for s in result.recent_sessions
|
|
1481
|
+
],
|
|
1482
|
+
}
|
|
1483
|
+
return ([TextContent(type="text", text=result.format())], structured)
|
|
1484
|
+
|
|
1485
|
+
except Exception as e:
|
|
1486
|
+
return _error_result(f"Current context error: {str(e)}")
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
async def _store(
|
|
1490
|
+
content: str,
|
|
1491
|
+
memory_type: str,
|
|
1492
|
+
project: str | None = None,
|
|
1493
|
+
tags: list[str] | None = None,
|
|
1494
|
+
importance: int | None = None,
|
|
1495
|
+
):
|
|
1496
|
+
"""Store a memory into BrainLayer."""
|
|
1497
|
+
try:
|
|
1498
|
+
from ..store import store_memory
|
|
1499
|
+
|
|
1500
|
+
store = _get_vector_store()
|
|
1501
|
+
model = _get_embedding_model()
|
|
1502
|
+
normalized_project = _normalize_project_name(project)
|
|
1503
|
+
|
|
1504
|
+
loop = asyncio.get_running_loop()
|
|
1505
|
+
|
|
1506
|
+
def _embed(text: str) -> list[float]:
|
|
1507
|
+
return model.embed_query(text)
|
|
1508
|
+
|
|
1509
|
+
result = await loop.run_in_executor(
|
|
1510
|
+
None,
|
|
1511
|
+
lambda: store_memory(
|
|
1512
|
+
store=store,
|
|
1513
|
+
embed_fn=_embed,
|
|
1514
|
+
content=content,
|
|
1515
|
+
memory_type=memory_type,
|
|
1516
|
+
project=normalized_project,
|
|
1517
|
+
tags=tags,
|
|
1518
|
+
importance=importance,
|
|
1519
|
+
),
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
# Format text response
|
|
1523
|
+
chunk_id = result["id"]
|
|
1524
|
+
parts = [f"Stored memory `{chunk_id}`"]
|
|
1525
|
+
if result["related"]:
|
|
1526
|
+
parts.append(f"\n**Related memories ({len(result['related'])}):**")
|
|
1527
|
+
for r in result["related"]:
|
|
1528
|
+
summary = r.get("summary") or r.get("content", "")[:100]
|
|
1529
|
+
parts.append(f"- {summary}")
|
|
1530
|
+
|
|
1531
|
+
structured = {
|
|
1532
|
+
"chunk_id": chunk_id,
|
|
1533
|
+
"related": result["related"],
|
|
1534
|
+
}
|
|
1535
|
+
return ([TextContent(type="text", text="\n".join(parts))], structured)
|
|
1536
|
+
|
|
1537
|
+
except ValueError as e:
|
|
1538
|
+
return _error_result(f"Validation error: {str(e)}")
|
|
1539
|
+
except Exception as e:
|
|
1540
|
+
return _error_result(f"Store error: {str(e)}")
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def serve():
|
|
1544
|
+
"""Start the MCP server using stdio.
|
|
1545
|
+
|
|
1546
|
+
Note: MCP uses stdin/stdout for communication, not network ports.
|
|
1547
|
+
This is designed for integration with Claude Code via mcpServers config.
|
|
1548
|
+
"""
|
|
1549
|
+
|
|
1550
|
+
async def main():
|
|
1551
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
1552
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
1553
|
+
|
|
1554
|
+
asyncio.run(main())
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
if __name__ == "__main__":
|
|
1558
|
+
serve()
|