@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.
@@ -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
@@ -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.2.1"},
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 their full SKILL.md bodies. The client should inject the "
187
- "returned content into the LLM's context. Returns up to 7 skills. "
188
- "Pass project_root (workspace path) for per-repo SQLite in .skillforge/ "
189
- "and learning; else use env SKILLFORGE_PROJECT_ROOT or global data dir. "
190
- "Optional session_id for reroute stats; optional user_id for multi-user."
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
- return {"content": [{"type": "text", "text": "No prompt provided."}]}
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
- pr = self._project_root_from_args(args)
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
- # Build response: a header explaining what was loaded, then the skill bodies
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:_ `{db_path}`",
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
- for n in picked_names:
391
- s = self.skills.get(n)
392
- if s:
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": "\n".join(b for b in blocks if b is not None)}],
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"):