@heytherevibin/skillforge 0.2.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +89 -56
- package/RELEASING.md +1 -1
- package/SECURITY.md +2 -2
- package/STRATEGY.md +1 -3
- package/bin/cli.js +32 -138
- package/package.json +2 -2
- package/python/app/chunking.py +116 -0
- package/python/app/context_fusion.py +77 -0
- package/python/app/events_cli.py +1 -1
- package/python/app/index_cli.py +89 -0
- package/python/app/main.py +632 -229
- package/python/app/mcp_contract.py +121 -0
- package/python/app/mcp_server.py +304 -30
- package/python/app/project_index.py +600 -0
- package/python/app/redaction.py +128 -0
- package/python/app/route_cli.py +42 -19
- package/python/app/route_policies.py +133 -0
- package/python/app/routing_signals.py +95 -0
- package/python/requirements.txt +1 -4
- package/python/tests/test_chunking.py +34 -0
- package/python/tests/test_context_fusion.py +45 -0
- package/python/tests/test_mcp_contract.py +137 -0
- package/python/tests/test_project_index.py +76 -0
- package/python/tests/test_redaction.py +51 -0
- package/python/tests/test_route_policies.py +115 -0
- package/python/tests/test_routing_signals.py +77 -0
- package/python/app/auth.py +0 -63
- package/python/app/cli.py +0 -78
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Shared MCP response shape for ``route_skills`` (and CLI parity).
|
|
2
|
+
|
|
3
|
+
Versioned ``_meta`` with ``sources[]`` and ``budget``. Schema **1.1** adds
|
|
4
|
+
per-chunk ``sources`` when ``context_items`` (RAG chunks) are returned from Phase 1.
|
|
5
|
+
Schema **1.2** adds ``kind: file`` sources and project chunk char counts in ``budget``.
|
|
6
|
+
Schema **1.4** adds optional ``context_redaction`` (hit counts when scrubbing is on).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping, Protocol
|
|
12
|
+
|
|
13
|
+
from app.redaction import redaction_enabled, redact_display_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _SkillBody(Protocol):
|
|
17
|
+
name: str
|
|
18
|
+
body: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MCP_RESPONSE_SCHEMA_VERSION = "1.4"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_route_skills_meta(
|
|
25
|
+
*,
|
|
26
|
+
result: dict[str, Any],
|
|
27
|
+
picked_names: list[str],
|
|
28
|
+
user_id: str,
|
|
29
|
+
db_path: Path | str,
|
|
30
|
+
skills_map: Mapping[str, _SkillBody],
|
|
31
|
+
response_text: str,
|
|
32
|
+
error: str | None = None,
|
|
33
|
+
context_items: list[dict[str, Any]] | None = None,
|
|
34
|
+
fusion: dict[str, Any] | None = None,
|
|
35
|
+
context_redaction: dict[str, Any] | None = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Build ``_meta`` for a route_skills-style response (success or structured error)."""
|
|
38
|
+
sources: list[dict[str, Any]] = []
|
|
39
|
+
chars_skill = 0
|
|
40
|
+
chars_project = 0
|
|
41
|
+
|
|
42
|
+
if context_items:
|
|
43
|
+
for c in context_items:
|
|
44
|
+
tlen = len(c.get("text") or "")
|
|
45
|
+
ref_path = c.get("path")
|
|
46
|
+
if ref_path:
|
|
47
|
+
row = {
|
|
48
|
+
"kind": "file",
|
|
49
|
+
"ref": ref_path,
|
|
50
|
+
"line_start": c.get("line_start"),
|
|
51
|
+
"line_end": c.get("line_end"),
|
|
52
|
+
"score": round(float(c.get("score", 0.0)), 6),
|
|
53
|
+
}
|
|
54
|
+
if c.get("mmr_rank") is not None:
|
|
55
|
+
row["mmr_rank"] = int(c["mmr_rank"])
|
|
56
|
+
sources.append(row)
|
|
57
|
+
chars_project += tlen
|
|
58
|
+
else:
|
|
59
|
+
row = {
|
|
60
|
+
"kind": "skill",
|
|
61
|
+
"ref": c["skill"],
|
|
62
|
+
"line_start": c.get("line_start"),
|
|
63
|
+
"line_end": c.get("line_end"),
|
|
64
|
+
"score": round(float(c.get("score", 0.0)), 6),
|
|
65
|
+
}
|
|
66
|
+
if c.get("mmr_rank") is not None:
|
|
67
|
+
row["mmr_rank"] = int(c["mmr_rank"])
|
|
68
|
+
sources.append(row)
|
|
69
|
+
chars_skill += tlen
|
|
70
|
+
else:
|
|
71
|
+
for n in picked_names:
|
|
72
|
+
s = skills_map.get(n)
|
|
73
|
+
if s is not None:
|
|
74
|
+
sources.append({
|
|
75
|
+
"kind": "skill",
|
|
76
|
+
"ref": n,
|
|
77
|
+
"line_start": None,
|
|
78
|
+
"line_end": None,
|
|
79
|
+
"score": None,
|
|
80
|
+
})
|
|
81
|
+
chars_skill += len(s.body)
|
|
82
|
+
|
|
83
|
+
chars_body = chars_skill + chars_project
|
|
84
|
+
|
|
85
|
+
candidates_raw = result.get("candidates") or []
|
|
86
|
+
candidates_preview: list[dict[str, Any]] = []
|
|
87
|
+
for item in candidates_raw[:15]:
|
|
88
|
+
if isinstance(item, tuple) and len(item) == 2:
|
|
89
|
+
sk, sc = item
|
|
90
|
+
name = getattr(sk, "name", None)
|
|
91
|
+
if name is not None:
|
|
92
|
+
candidates_preview.append({"name": name, "score": round(float(sc), 6)})
|
|
93
|
+
|
|
94
|
+
meta: dict[str, Any] = {
|
|
95
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
96
|
+
"sources": sources,
|
|
97
|
+
"budget": {
|
|
98
|
+
"chars_skill_bodies": chars_skill,
|
|
99
|
+
"chars_project_chunks": chars_project,
|
|
100
|
+
"chars_context_items_total": chars_body,
|
|
101
|
+
"chars_response_total": len(response_text),
|
|
102
|
+
"est_tokens_approx": max(1, len(response_text) // 4),
|
|
103
|
+
},
|
|
104
|
+
"picked": list(picked_names),
|
|
105
|
+
"reasoning": result.get("reasoning"),
|
|
106
|
+
"session_id": result.get("session_id"),
|
|
107
|
+
"user_id": user_id,
|
|
108
|
+
"rerouted": result.get("rerouted"),
|
|
109
|
+
"change_pct": round(float(result.get("change", 0)) * 100, 1),
|
|
110
|
+
"route_ms": round(float(result.get("route_ms", 0)), 1),
|
|
111
|
+
"orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
|
|
112
|
+
"candidates_preview": candidates_preview,
|
|
113
|
+
"context_items_count": len(context_items or []),
|
|
114
|
+
}
|
|
115
|
+
if fusion is not None and fusion.get("enabled"):
|
|
116
|
+
meta["fusion"] = fusion
|
|
117
|
+
if context_redaction is not None:
|
|
118
|
+
meta["context_redaction"] = context_redaction
|
|
119
|
+
if error:
|
|
120
|
+
meta["error"] = error
|
|
121
|
+
return meta
|
package/python/app/mcp_server.py
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
MCP server for skillforge.
|
|
3
3
|
|
|
4
4
|
Exposes skill routing as MCP tools so MCP-aware clients (Claude Desktop,
|
|
5
|
-
Claude Code, Cursor, etc.) can use the orchestrator
|
|
6
|
-
HTTP server.
|
|
5
|
+
Claude Code, Cursor, etc.) can use the orchestrator locally.
|
|
7
6
|
|
|
8
7
|
Tools exposed:
|
|
9
8
|
route_skills / skillforge_bootstrap — routing (+ optional project materialize).
|
|
9
|
+
search_skills / explain_route / get_skill — retrieval, debugging, deterministic fetch.
|
|
10
10
|
materialize_project — .cursor/rules, docs/SKILLFORGE-PRD.md, CLAUDE.md block.
|
|
11
11
|
list_skills, skill_feedback, skill_referenced, disable_skill.
|
|
12
12
|
|
|
@@ -25,7 +25,10 @@ from pathlib import Path
|
|
|
25
25
|
|
|
26
26
|
from app.db_paths import resolve_orchestrator_db
|
|
27
27
|
from app.main import (
|
|
28
|
+
TOP_K_CANDIDATES,
|
|
29
|
+
MAX_ACTIVE_SKILLS,
|
|
28
30
|
build_router_and_skills,
|
|
31
|
+
format_context_items_markdown,
|
|
29
32
|
init_db,
|
|
30
33
|
load_all_skills,
|
|
31
34
|
log_event,
|
|
@@ -36,6 +39,10 @@ from app.main import (
|
|
|
36
39
|
Router,
|
|
37
40
|
)
|
|
38
41
|
from app.materialize import materialize_project_files
|
|
42
|
+
from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION, build_route_skills_meta
|
|
43
|
+
from app.redaction import redaction_enabled, redact_display_path
|
|
44
|
+
from app.route_policies import load_route_policies_config, merge_policy_includes
|
|
45
|
+
from app.routing_signals import build_route_query_text
|
|
39
46
|
|
|
40
47
|
|
|
41
48
|
def _env_truthy(name: str, default: str = "1") -> bool:
|
|
@@ -82,7 +89,7 @@ class MCPServer:
|
|
|
82
89
|
self._db_cache: dict[str, sqlite3.Connection] = {}
|
|
83
90
|
|
|
84
91
|
def _mcp_user_id(self, args: dict) -> str:
|
|
85
|
-
"""Per-tool user namespace for weights/sessions/events
|
|
92
|
+
"""Per-tool user namespace for weights/sessions/events."""
|
|
86
93
|
raw = (
|
|
87
94
|
args.get("user_id")
|
|
88
95
|
or os.getenv("SKILLFORGE_MCP_USER_ID", "")
|
|
@@ -97,6 +104,15 @@ class MCPServer:
|
|
|
97
104
|
env = os.getenv("SKILLFORGE_PROJECT_ROOT", "").strip()
|
|
98
105
|
return env or None
|
|
99
106
|
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _include_project_rag_from_args(args: dict) -> bool:
|
|
109
|
+
v = args.get("include_project_rag")
|
|
110
|
+
if v is True:
|
|
111
|
+
return True
|
|
112
|
+
if isinstance(v, str) and v.strip().lower() in ("1", "true", "yes"):
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
|
|
100
116
|
def _get_con(self, args: dict):
|
|
101
117
|
path = resolve_orchestrator_db(self._project_root_from_args(args))
|
|
102
118
|
key = str(path)
|
|
@@ -173,7 +189,7 @@ class MCPServer:
|
|
|
173
189
|
return {
|
|
174
190
|
"protocolVersion": "2024-11-05",
|
|
175
191
|
"capabilities": caps,
|
|
176
|
-
"serverInfo": {"name": "skillforge", "version": "0.
|
|
192
|
+
"serverInfo": {"name": "skillforge", "version": "0.7.1"},
|
|
177
193
|
}
|
|
178
194
|
|
|
179
195
|
def handle_tools_list(self, params):
|
|
@@ -183,11 +199,14 @@ class MCPServer:
|
|
|
183
199
|
"name": "route_skills",
|
|
184
200
|
"description": (
|
|
185
201
|
"Route the user's prompt to the most relevant skills from the catalog "
|
|
186
|
-
"and return
|
|
187
|
-
"
|
|
188
|
-
"Pass project_root
|
|
189
|
-
"
|
|
190
|
-
"
|
|
202
|
+
"and return SKILL.md context (full body or RAG chunks per CONTEXT_MODE). "
|
|
203
|
+
"Returns up to 7 skills. "
|
|
204
|
+
"Pass project_root for per-repo SQLite in .skillforge/ and learning. "
|
|
205
|
+
"Optional include_project_rag merges top chunks from `skillforge index` into context. "
|
|
206
|
+
"On success, _meta includes schema_version ("
|
|
207
|
+
f"{MCP_RESPONSE_SCHEMA_VERSION}), sources[] (kind skill or file), "
|
|
208
|
+
"budget (chars_skill_bodies, chars_project_chunks), fusion (MMR when combined index+RAG), "
|
|
209
|
+
"candidates_preview, context_items_count."
|
|
191
210
|
),
|
|
192
211
|
"inputSchema": {
|
|
193
212
|
"type": "object",
|
|
@@ -197,6 +216,14 @@ class MCPServer:
|
|
|
197
216
|
"type": "string",
|
|
198
217
|
"description": "Repo/workspace root — stores orchestrator state in .skillforge/",
|
|
199
218
|
},
|
|
219
|
+
"include_project_rag": {
|
|
220
|
+
"type": "boolean",
|
|
221
|
+
"description": (
|
|
222
|
+
"If true, append top chunks from the project index in the same DB "
|
|
223
|
+
"(see skillforge index). Requires project_root."
|
|
224
|
+
),
|
|
225
|
+
"default": False,
|
|
226
|
+
},
|
|
200
227
|
"conversation": {
|
|
201
228
|
"type": "array",
|
|
202
229
|
"description": "Optional recent messages for context",
|
|
@@ -208,12 +235,80 @@ class MCPServer:
|
|
|
208
235
|
},
|
|
209
236
|
"user_id": {
|
|
210
237
|
"type": "string",
|
|
211
|
-
"description": "Logical user id for weights/sessions/events
|
|
238
|
+
"description": "Logical user id for weights/sessions/events",
|
|
212
239
|
},
|
|
213
240
|
},
|
|
214
241
|
"required": ["prompt"],
|
|
215
242
|
},
|
|
216
243
|
},
|
|
244
|
+
{
|
|
245
|
+
"name": "search_skills",
|
|
246
|
+
"description": (
|
|
247
|
+
"Embedding-only retrieval: top skills for a query with similarity scores "
|
|
248
|
+
"and descriptions (no Haiku, no full route). Use to explore the catalog."
|
|
249
|
+
),
|
|
250
|
+
"inputSchema": {
|
|
251
|
+
"type": "object",
|
|
252
|
+
"properties": {
|
|
253
|
+
"query": {"type": "string", "description": "Search query or task text"},
|
|
254
|
+
"limit": {
|
|
255
|
+
"type": "integer",
|
|
256
|
+
"description": f"Max skills to return (default {TOP_K_CANDIDATES})",
|
|
257
|
+
},
|
|
258
|
+
"project_root": {"type": "string"},
|
|
259
|
+
"user_id": {"type": "string"},
|
|
260
|
+
},
|
|
261
|
+
"required": ["query"],
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"name": "explain_route",
|
|
266
|
+
"description": (
|
|
267
|
+
"Debug routing: embedding facets for the shortlist (same query text as route_skills when "
|
|
268
|
+
"`conversation` is passed — conversation-aware when SKILLFORGE_ROUTER_CONV_MAX_TURNS > 0), "
|
|
269
|
+
"optional Haiku rerank, Haiku/embedding-only pick with reasoning, and policy merge audit. "
|
|
270
|
+
"Does not write sessions or increment uses."
|
|
271
|
+
),
|
|
272
|
+
"inputSchema": {
|
|
273
|
+
"type": "object",
|
|
274
|
+
"properties": {
|
|
275
|
+
"prompt": {"type": "string"},
|
|
276
|
+
"conversation": {"type": "array", "items": {"type": "object"}},
|
|
277
|
+
"limit": {
|
|
278
|
+
"type": "integer",
|
|
279
|
+
"description": "Max shortlist rows in facets (default TOP_K)",
|
|
280
|
+
},
|
|
281
|
+
"project_root": {"type": "string"},
|
|
282
|
+
"user_id": {"type": "string"},
|
|
283
|
+
},
|
|
284
|
+
"required": ["prompt"],
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"name": "get_skill",
|
|
289
|
+
"description": (
|
|
290
|
+
"Load one skill by name: full SKILL.md body or a short summary. "
|
|
291
|
+
"Use for deterministic workflows when you already know the skill name."
|
|
292
|
+
),
|
|
293
|
+
"inputSchema": {
|
|
294
|
+
"type": "object",
|
|
295
|
+
"properties": {
|
|
296
|
+
"skill_name": {"type": "string"},
|
|
297
|
+
"format": {
|
|
298
|
+
"type": "string",
|
|
299
|
+
"enum": ["full", "summary"],
|
|
300
|
+
"description": "summary = description + first ~8k chars of body",
|
|
301
|
+
"default": "full",
|
|
302
|
+
},
|
|
303
|
+
"max_chars": {
|
|
304
|
+
"type": "integer",
|
|
305
|
+
"description": "If > 0, truncate body to this many characters",
|
|
306
|
+
"default": 0,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
"required": ["skill_name"],
|
|
310
|
+
},
|
|
311
|
+
},
|
|
217
312
|
{
|
|
218
313
|
"name": "list_skills",
|
|
219
314
|
"description": (
|
|
@@ -317,6 +412,11 @@ class MCPServer:
|
|
|
317
412
|
"session_id": {"type": "string"},
|
|
318
413
|
"user_id": {"type": "string"},
|
|
319
414
|
"merge": {"type": "boolean", "default": True},
|
|
415
|
+
"include_project_rag": {
|
|
416
|
+
"type": "boolean",
|
|
417
|
+
"description": "Same as route_skills: merge indexed project file chunks into context.",
|
|
418
|
+
"default": False,
|
|
419
|
+
},
|
|
320
420
|
},
|
|
321
421
|
"required": ["prompt", "project_root"],
|
|
322
422
|
},
|
|
@@ -330,6 +430,12 @@ class MCPServer:
|
|
|
330
430
|
|
|
331
431
|
if name == "route_skills":
|
|
332
432
|
return await self._tool_route_skills(args)
|
|
433
|
+
if name == "search_skills":
|
|
434
|
+
return self._tool_search_skills(args)
|
|
435
|
+
if name == "explain_route":
|
|
436
|
+
return await self._tool_explain_route(args)
|
|
437
|
+
if name == "get_skill":
|
|
438
|
+
return self._tool_get_skill(args)
|
|
333
439
|
if name == "list_skills":
|
|
334
440
|
return self._tool_list_skills(args)
|
|
335
441
|
if name == "skill_feedback":
|
|
@@ -349,8 +455,25 @@ class MCPServer:
|
|
|
349
455
|
conversation = args.get("conversation", [])
|
|
350
456
|
session_id = args.get("session_id") or None
|
|
351
457
|
user_id = self._mcp_user_id(args)
|
|
458
|
+
pr = self._project_root_from_args(args)
|
|
459
|
+
db_path = resolve_orchestrator_db(pr)
|
|
460
|
+
|
|
352
461
|
if not prompt.strip():
|
|
353
|
-
|
|
462
|
+
err_text = "No prompt provided."
|
|
463
|
+
return {
|
|
464
|
+
"content": [{"type": "text", "text": err_text}],
|
|
465
|
+
"isError": True,
|
|
466
|
+
"_meta": build_route_skills_meta(
|
|
467
|
+
result={"candidates": []},
|
|
468
|
+
picked_names=[],
|
|
469
|
+
user_id=user_id,
|
|
470
|
+
db_path=db_path,
|
|
471
|
+
skills_map=self.skills or {},
|
|
472
|
+
response_text=err_text,
|
|
473
|
+
error="empty_prompt",
|
|
474
|
+
),
|
|
475
|
+
}
|
|
476
|
+
|
|
354
477
|
con = self._get_con(args)
|
|
355
478
|
result = await run_route_turn(
|
|
356
479
|
con,
|
|
@@ -359,11 +482,12 @@ class MCPServer:
|
|
|
359
482
|
conversation,
|
|
360
483
|
user_id=user_id,
|
|
361
484
|
session_id=session_id,
|
|
485
|
+
project_root=pr,
|
|
486
|
+
include_project_rag=self._include_project_rag_from_args(args),
|
|
362
487
|
)
|
|
363
488
|
picked_names = result["picked_names"]
|
|
364
489
|
reasoning = result["reasoning"]
|
|
365
|
-
|
|
366
|
-
db_path = resolve_orchestrator_db(pr)
|
|
490
|
+
context_items = result.get("context_items") or []
|
|
367
491
|
if pr:
|
|
368
492
|
try:
|
|
369
493
|
d = Path(pr).expanduser().resolve() / ".skillforge"
|
|
@@ -375,35 +499,184 @@ class MCPServer:
|
|
|
375
499
|
"reasoning": reasoning,
|
|
376
500
|
"route_ms": round(result["route_ms"], 1),
|
|
377
501
|
"user_id": user_id,
|
|
502
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
503
|
+
"context_mode": self.router.context_mode,
|
|
504
|
+
"context_items_count": len(context_items),
|
|
505
|
+
"project_rag_items_count": (result.get("event") or {}).get("project_rag_items_count", 0),
|
|
378
506
|
}
|
|
379
507
|
(d / "last_route.json").write_text(json.dumps(snap, indent=2), encoding="utf-8")
|
|
380
508
|
except OSError:
|
|
381
509
|
pass
|
|
382
510
|
|
|
383
|
-
|
|
511
|
+
db_disp = redact_display_path(db_path) if redaction_enabled() else str(db_path)
|
|
384
512
|
blocks = [
|
|
385
|
-
f"# Skillforge — routed {len(picked_names)} skill(s)",
|
|
386
|
-
f"_DB:_ `{
|
|
513
|
+
f"# Skillforge — routed {len(picked_names)} skill(s); context=`{self.router.context_mode}`",
|
|
514
|
+
f"_DB:_ `{db_disp}`",
|
|
387
515
|
f"_Reasoning: {reasoning}_" if reasoning else "",
|
|
388
516
|
"",
|
|
389
517
|
]
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
blocks.append(f"---\n## Skill: {s.name}\n\n{s.body}\n")
|
|
394
|
-
if not picked_names:
|
|
518
|
+
if context_items:
|
|
519
|
+
blocks.append(format_context_items_markdown(context_items))
|
|
520
|
+
elif not picked_names:
|
|
395
521
|
blocks.append("_No skills matched this prompt closely enough to load._")
|
|
522
|
+
response_text = "\n".join(b for b in blocks if b is not None)
|
|
523
|
+
meta = build_route_skills_meta(
|
|
524
|
+
result=result,
|
|
525
|
+
picked_names=picked_names,
|
|
526
|
+
user_id=user_id,
|
|
527
|
+
db_path=db_path,
|
|
528
|
+
skills_map=self.skills,
|
|
529
|
+
response_text=response_text,
|
|
530
|
+
context_items=context_items,
|
|
531
|
+
fusion=(result.get("event") or {}).get("context_fusion"),
|
|
532
|
+
context_redaction=(result.get("event") or {}).get("context_redaction"),
|
|
533
|
+
)
|
|
396
534
|
return {
|
|
397
|
-
"content": [{"type": "text", "text":
|
|
535
|
+
"content": [{"type": "text", "text": response_text}],
|
|
536
|
+
"_meta": meta,
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
def _tool_search_skills(self, args):
|
|
540
|
+
query = (args.get("query") or "").strip()
|
|
541
|
+
user_id = self._mcp_user_id(args)
|
|
542
|
+
pr = self._project_root_from_args(args)
|
|
543
|
+
db_path = resolve_orchestrator_db(pr)
|
|
544
|
+
if not query:
|
|
545
|
+
return {
|
|
546
|
+
"content": [{"type": "text", "text": "query is required."}],
|
|
547
|
+
"isError": True,
|
|
548
|
+
}
|
|
549
|
+
try:
|
|
550
|
+
limit = int(args.get("limit") or TOP_K_CANDIDATES)
|
|
551
|
+
except (TypeError, ValueError):
|
|
552
|
+
limit = TOP_K_CANDIDATES
|
|
553
|
+
limit = max(1, min(limit, 50))
|
|
554
|
+
con = self._get_con(args)
|
|
555
|
+
facets = self.router.shortlist_with_facets(query, con, k=limit, user_id=user_id)
|
|
556
|
+
lines = ["# search_skills — embedding shortlist", ""]
|
|
557
|
+
for f in facets:
|
|
558
|
+
lines.append(
|
|
559
|
+
f"- **{f['name']}** (cos {f['cosine_similarity']}, score {f['routing_score']}): "
|
|
560
|
+
f"{(f.get('description_preview') or '')[:220]}"
|
|
561
|
+
)
|
|
562
|
+
text = "\n".join(lines)
|
|
563
|
+
return {
|
|
564
|
+
"content": [{"type": "text", "text": text}],
|
|
398
565
|
"_meta": {
|
|
399
|
-
"
|
|
400
|
-
"
|
|
401
|
-
"
|
|
402
|
-
"
|
|
403
|
-
"
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
566
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
567
|
+
"tool": "search_skills",
|
|
568
|
+
"orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
|
|
569
|
+
"results": facets,
|
|
570
|
+
"count": len(facets),
|
|
571
|
+
},
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async def _tool_explain_route(self, args):
|
|
575
|
+
prompt = (args.get("prompt") or "").strip()
|
|
576
|
+
conversation = args.get("conversation") or []
|
|
577
|
+
user_id = self._mcp_user_id(args)
|
|
578
|
+
pr = self._project_root_from_args(args)
|
|
579
|
+
db_path = resolve_orchestrator_db(pr)
|
|
580
|
+
if not prompt:
|
|
581
|
+
return {
|
|
582
|
+
"content": [{"type": "text", "text": "prompt is required."}],
|
|
583
|
+
"isError": True,
|
|
584
|
+
}
|
|
585
|
+
try:
|
|
586
|
+
limit = int(args.get("limit") or TOP_K_CANDIDATES)
|
|
587
|
+
except (TypeError, ValueError):
|
|
588
|
+
limit = TOP_K_CANDIDATES
|
|
589
|
+
limit = max(1, min(limit, 50))
|
|
590
|
+
con = self._get_con(args)
|
|
591
|
+
route_query = build_route_query_text(prompt, conversation)
|
|
592
|
+
facets = self.router.shortlist_with_facets(route_query, con, k=limit, user_id=user_id)
|
|
593
|
+
candidates = self.router.shortlist(route_query, con, user_id=user_id)
|
|
594
|
+
candidates = await self.router.rerank_candidates_haiku(route_query, conversation, candidates)
|
|
595
|
+
picked, reasoning = await self.router.pick_final(
|
|
596
|
+
prompt, conversation, candidates, route_query=route_query
|
|
597
|
+
)
|
|
598
|
+
policies_cfg = load_route_policies_config(pr)
|
|
599
|
+
merged, policy_audit = merge_policy_includes(
|
|
600
|
+
prompt,
|
|
601
|
+
list(picked),
|
|
602
|
+
policies_cfg,
|
|
603
|
+
self.router._by_name,
|
|
604
|
+
con,
|
|
605
|
+
user_id,
|
|
606
|
+
max_active=MAX_ACTIVE_SKILLS,
|
|
607
|
+
)
|
|
608
|
+
router_mode = "full" if self.router.anthropic else "embedding-only"
|
|
609
|
+
explain = {
|
|
610
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
611
|
+
"tool": "explain_route",
|
|
612
|
+
"orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
|
|
613
|
+
"router_mode": router_mode,
|
|
614
|
+
"embedding_shortlist": facets,
|
|
615
|
+
"picked_before_policy": list(picked),
|
|
616
|
+
"picked_after_policy": merged,
|
|
617
|
+
"router_reasoning": reasoning,
|
|
618
|
+
"policy": {
|
|
619
|
+
"rules_loaded": len(policies_cfg.get("rules") or [])
|
|
620
|
+
if isinstance(policies_cfg.get("rules"), list)
|
|
621
|
+
else 0,
|
|
622
|
+
"audit": policy_audit,
|
|
623
|
+
},
|
|
624
|
+
}
|
|
625
|
+
lines = [
|
|
626
|
+
"# explain_route — routing diagnostics (no DB writes)",
|
|
627
|
+
"",
|
|
628
|
+
f"**Router:** {router_mode}",
|
|
629
|
+
f"**Picked (router):** {', '.join(picked) if picked else '_(none)_'}",
|
|
630
|
+
f"**After policies:** {', '.join(merged) if merged else '_(none)_'}",
|
|
631
|
+
f"**Reasoning:** {reasoning}" if reasoning else "**Reasoning:** _(n/a)_",
|
|
632
|
+
"",
|
|
633
|
+
"## Shortlist (embedding)",
|
|
634
|
+
]
|
|
635
|
+
for f in facets[:15]:
|
|
636
|
+
lines.append(
|
|
637
|
+
f"- `{f['name']}` cos={f['cosine_similarity']} weight={f['learned_weight']} "
|
|
638
|
+
f"score={f['routing_score']}"
|
|
639
|
+
)
|
|
640
|
+
if policy_audit:
|
|
641
|
+
lines.extend(["", "## Policy audit"])
|
|
642
|
+
for row in policy_audit[:30]:
|
|
643
|
+
lines.append(f"- {row}")
|
|
644
|
+
body = "\n".join(lines)
|
|
645
|
+
return {"content": [{"type": "text", "text": body}], "_meta": explain}
|
|
646
|
+
|
|
647
|
+
def _tool_get_skill(self, args):
|
|
648
|
+
name = (args.get("skill_name") or "").strip()
|
|
649
|
+
fmt = (args.get("format") or "full").strip().lower()
|
|
650
|
+
if fmt not in ("full", "summary"):
|
|
651
|
+
fmt = "full"
|
|
652
|
+
max_chars = args.get("max_chars")
|
|
653
|
+
try:
|
|
654
|
+
mc = int(max_chars) if max_chars is not None else 0
|
|
655
|
+
except (TypeError, ValueError):
|
|
656
|
+
mc = 0
|
|
657
|
+
if not name or name not in self.skills:
|
|
658
|
+
return {
|
|
659
|
+
"content": [{"type": "text", "text": f"Unknown skill: {name or '(empty)'}"}],
|
|
660
|
+
"isError": True,
|
|
661
|
+
}
|
|
662
|
+
s = self.skills[name]
|
|
663
|
+
if fmt == "summary":
|
|
664
|
+
body = f"{s.description}\n\n---\n\n{(s.body or '')[:8000]}"
|
|
665
|
+
else:
|
|
666
|
+
body = s.body or ""
|
|
667
|
+
if mc > 0:
|
|
668
|
+
body = body[:mc]
|
|
669
|
+
header = f"# get_skill: `{name}`\n**Source:** {s.source} · **format:** {fmt}\n\n"
|
|
670
|
+
text = header + body
|
|
671
|
+
return {
|
|
672
|
+
"content": [{"type": "text", "text": text}],
|
|
673
|
+
"_meta": {
|
|
674
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
675
|
+
"tool": "get_skill",
|
|
676
|
+
"skill_name": name,
|
|
677
|
+
"source": s.source,
|
|
678
|
+
"format": fmt,
|
|
679
|
+
"chars": len(body),
|
|
407
680
|
},
|
|
408
681
|
}
|
|
409
682
|
|
|
@@ -514,6 +787,7 @@ class MCPServer:
|
|
|
514
787
|
"conversation": conversation,
|
|
515
788
|
"session_id": session_id,
|
|
516
789
|
"user_id": user_id,
|
|
790
|
+
"include_project_rag": self._include_project_rag_from_args(args),
|
|
517
791
|
}
|
|
518
792
|
)
|
|
519
793
|
if route.get("isError"):
|