@heytherevibin/skillforge 0.2.1 → 0.7.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 +32 -0
- package/README.md +44 -53
- 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 +380 -214
- package/python/app/mcp_contract.py +121 -0
- package/python/app/mcp_server.py +80 -28
- 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/requirements.txt +0 -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/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
|
@@ -26,6 +26,7 @@ from pathlib import Path
|
|
|
26
26
|
from app.db_paths import resolve_orchestrator_db
|
|
27
27
|
from app.main import (
|
|
28
28
|
build_router_and_skills,
|
|
29
|
+
format_context_items_markdown,
|
|
29
30
|
init_db,
|
|
30
31
|
load_all_skills,
|
|
31
32
|
log_event,
|
|
@@ -36,6 +37,8 @@ from app.main import (
|
|
|
36
37
|
Router,
|
|
37
38
|
)
|
|
38
39
|
from app.materialize import materialize_project_files
|
|
40
|
+
from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION, build_route_skills_meta
|
|
41
|
+
from app.redaction import redaction_enabled, redact_display_path
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
def _env_truthy(name: str, default: str = "1") -> bool:
|
|
@@ -97,6 +100,15 @@ class MCPServer:
|
|
|
97
100
|
env = os.getenv("SKILLFORGE_PROJECT_ROOT", "").strip()
|
|
98
101
|
return env or None
|
|
99
102
|
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _include_project_rag_from_args(args: dict) -> bool:
|
|
105
|
+
v = args.get("include_project_rag")
|
|
106
|
+
if v is True:
|
|
107
|
+
return True
|
|
108
|
+
if isinstance(v, str) and v.strip().lower() in ("1", "true", "yes"):
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
100
112
|
def _get_con(self, args: dict):
|
|
101
113
|
path = resolve_orchestrator_db(self._project_root_from_args(args))
|
|
102
114
|
key = str(path)
|
|
@@ -173,7 +185,7 @@ class MCPServer:
|
|
|
173
185
|
return {
|
|
174
186
|
"protocolVersion": "2024-11-05",
|
|
175
187
|
"capabilities": caps,
|
|
176
|
-
"serverInfo": {"name": "skillforge", "version": "0.
|
|
188
|
+
"serverInfo": {"name": "skillforge", "version": "0.7.0"},
|
|
177
189
|
}
|
|
178
190
|
|
|
179
191
|
def handle_tools_list(self, params):
|
|
@@ -183,11 +195,14 @@ class MCPServer:
|
|
|
183
195
|
"name": "route_skills",
|
|
184
196
|
"description": (
|
|
185
197
|
"Route the user's prompt to the most relevant skills from the catalog "
|
|
186
|
-
"and return
|
|
187
|
-
"
|
|
188
|
-
"Pass project_root
|
|
189
|
-
"
|
|
190
|
-
"
|
|
198
|
+
"and return SKILL.md context (full body or RAG chunks per CONTEXT_MODE). "
|
|
199
|
+
"Returns up to 7 skills. "
|
|
200
|
+
"Pass project_root for per-repo SQLite in .skillforge/ and learning. "
|
|
201
|
+
"Optional include_project_rag merges top chunks from `skillforge index` into context. "
|
|
202
|
+
"On success, _meta includes schema_version ("
|
|
203
|
+
f"{MCP_RESPONSE_SCHEMA_VERSION}), sources[] (kind skill or file), "
|
|
204
|
+
"budget (chars_skill_bodies, chars_project_chunks), fusion (MMR when combined index+RAG), "
|
|
205
|
+
"candidates_preview, context_items_count."
|
|
191
206
|
),
|
|
192
207
|
"inputSchema": {
|
|
193
208
|
"type": "object",
|
|
@@ -197,6 +212,14 @@ class MCPServer:
|
|
|
197
212
|
"type": "string",
|
|
198
213
|
"description": "Repo/workspace root — stores orchestrator state in .skillforge/",
|
|
199
214
|
},
|
|
215
|
+
"include_project_rag": {
|
|
216
|
+
"type": "boolean",
|
|
217
|
+
"description": (
|
|
218
|
+
"If true, append top chunks from the project index in the same DB "
|
|
219
|
+
"(see skillforge index). Requires project_root."
|
|
220
|
+
),
|
|
221
|
+
"default": False,
|
|
222
|
+
},
|
|
200
223
|
"conversation": {
|
|
201
224
|
"type": "array",
|
|
202
225
|
"description": "Optional recent messages for context",
|
|
@@ -317,6 +340,11 @@ class MCPServer:
|
|
|
317
340
|
"session_id": {"type": "string"},
|
|
318
341
|
"user_id": {"type": "string"},
|
|
319
342
|
"merge": {"type": "boolean", "default": True},
|
|
343
|
+
"include_project_rag": {
|
|
344
|
+
"type": "boolean",
|
|
345
|
+
"description": "Same as route_skills: merge indexed project file chunks into context.",
|
|
346
|
+
"default": False,
|
|
347
|
+
},
|
|
320
348
|
},
|
|
321
349
|
"required": ["prompt", "project_root"],
|
|
322
350
|
},
|
|
@@ -349,8 +377,25 @@ class MCPServer:
|
|
|
349
377
|
conversation = args.get("conversation", [])
|
|
350
378
|
session_id = args.get("session_id") or None
|
|
351
379
|
user_id = self._mcp_user_id(args)
|
|
380
|
+
pr = self._project_root_from_args(args)
|
|
381
|
+
db_path = resolve_orchestrator_db(pr)
|
|
382
|
+
|
|
352
383
|
if not prompt.strip():
|
|
353
|
-
|
|
384
|
+
err_text = "No prompt provided."
|
|
385
|
+
return {
|
|
386
|
+
"content": [{"type": "text", "text": err_text}],
|
|
387
|
+
"isError": True,
|
|
388
|
+
"_meta": build_route_skills_meta(
|
|
389
|
+
result={"candidates": []},
|
|
390
|
+
picked_names=[],
|
|
391
|
+
user_id=user_id,
|
|
392
|
+
db_path=db_path,
|
|
393
|
+
skills_map=self.skills or {},
|
|
394
|
+
response_text=err_text,
|
|
395
|
+
error="empty_prompt",
|
|
396
|
+
),
|
|
397
|
+
}
|
|
398
|
+
|
|
354
399
|
con = self._get_con(args)
|
|
355
400
|
result = await run_route_turn(
|
|
356
401
|
con,
|
|
@@ -359,11 +404,12 @@ class MCPServer:
|
|
|
359
404
|
conversation,
|
|
360
405
|
user_id=user_id,
|
|
361
406
|
session_id=session_id,
|
|
407
|
+
project_root=pr,
|
|
408
|
+
include_project_rag=self._include_project_rag_from_args(args),
|
|
362
409
|
)
|
|
363
410
|
picked_names = result["picked_names"]
|
|
364
411
|
reasoning = result["reasoning"]
|
|
365
|
-
|
|
366
|
-
db_path = resolve_orchestrator_db(pr)
|
|
412
|
+
context_items = result.get("context_items") or []
|
|
367
413
|
if pr:
|
|
368
414
|
try:
|
|
369
415
|
d = Path(pr).expanduser().resolve() / ".skillforge"
|
|
@@ -375,36 +421,41 @@ class MCPServer:
|
|
|
375
421
|
"reasoning": reasoning,
|
|
376
422
|
"route_ms": round(result["route_ms"], 1),
|
|
377
423
|
"user_id": user_id,
|
|
424
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
425
|
+
"context_mode": self.router.context_mode,
|
|
426
|
+
"context_items_count": len(context_items),
|
|
427
|
+
"project_rag_items_count": (result.get("event") or {}).get("project_rag_items_count", 0),
|
|
378
428
|
}
|
|
379
429
|
(d / "last_route.json").write_text(json.dumps(snap, indent=2), encoding="utf-8")
|
|
380
430
|
except OSError:
|
|
381
431
|
pass
|
|
382
432
|
|
|
383
|
-
|
|
433
|
+
db_disp = redact_display_path(db_path) if redaction_enabled() else str(db_path)
|
|
384
434
|
blocks = [
|
|
385
|
-
f"# Skillforge — routed {len(picked_names)} skill(s)",
|
|
386
|
-
f"_DB:_ `{
|
|
435
|
+
f"# Skillforge — routed {len(picked_names)} skill(s); context=`{self.router.context_mode}`",
|
|
436
|
+
f"_DB:_ `{db_disp}`",
|
|
387
437
|
f"_Reasoning: {reasoning}_" if reasoning else "",
|
|
388
438
|
"",
|
|
389
439
|
]
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
blocks.append(f"---\n## Skill: {s.name}\n\n{s.body}\n")
|
|
394
|
-
if not picked_names:
|
|
440
|
+
if context_items:
|
|
441
|
+
blocks.append(format_context_items_markdown(context_items))
|
|
442
|
+
elif not picked_names:
|
|
395
443
|
blocks.append("_No skills matched this prompt closely enough to load._")
|
|
444
|
+
response_text = "\n".join(b for b in blocks if b is not None)
|
|
445
|
+
meta = build_route_skills_meta(
|
|
446
|
+
result=result,
|
|
447
|
+
picked_names=picked_names,
|
|
448
|
+
user_id=user_id,
|
|
449
|
+
db_path=db_path,
|
|
450
|
+
skills_map=self.skills,
|
|
451
|
+
response_text=response_text,
|
|
452
|
+
context_items=context_items,
|
|
453
|
+
fusion=(result.get("event") or {}).get("context_fusion"),
|
|
454
|
+
context_redaction=(result.get("event") or {}).get("context_redaction"),
|
|
455
|
+
)
|
|
396
456
|
return {
|
|
397
|
-
"content": [{"type": "text", "text":
|
|
398
|
-
"_meta":
|
|
399
|
-
"picked": picked_names,
|
|
400
|
-
"reasoning": reasoning,
|
|
401
|
-
"session_id": result["session_id"],
|
|
402
|
-
"user_id": user_id,
|
|
403
|
-
"rerouted": result["rerouted"],
|
|
404
|
-
"change_pct": round(result["change"] * 100, 1),
|
|
405
|
-
"route_ms": round(result["route_ms"], 1),
|
|
406
|
-
"orchestrator_db": str(db_path),
|
|
407
|
-
},
|
|
457
|
+
"content": [{"type": "text", "text": response_text}],
|
|
458
|
+
"_meta": meta,
|
|
408
459
|
}
|
|
409
460
|
|
|
410
461
|
def _tool_list_skills(self, args):
|
|
@@ -514,6 +565,7 @@ class MCPServer:
|
|
|
514
565
|
"conversation": conversation,
|
|
515
566
|
"session_id": session_id,
|
|
516
567
|
"user_id": user_id,
|
|
568
|
+
"include_project_rag": self._include_project_rag_from_args(args),
|
|
517
569
|
}
|
|
518
570
|
)
|
|
519
571
|
if route.get("isError"):
|