@heytherevibin/skillforge 0.10.0 → 0.11.7

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/CONTRIBUTING.md +5 -3
  3. package/README.md +37 -345
  4. package/RELEASING.md +8 -7
  5. package/STRATEGY.md +2 -2
  6. package/bin/cli.js +297 -52
  7. package/ci/test-user-env-profile.cjs +65 -0
  8. package/docs/README.md +14 -0
  9. package/docs/architecture-and-data.md +90 -0
  10. package/docs/cli-reference.md +57 -0
  11. package/docs/environment-and-configuration.md +76 -0
  12. package/docs/getting-started.md +88 -0
  13. package/docs/mcp-integration.md +75 -0
  14. package/docs/troubleshooting.md +50 -0
  15. package/lib/templates/claude-code-skillforge-global.md +3 -3
  16. package/lib/templates/cursor-skillforge-global.md +6 -2
  17. package/lib/user-env-profile.js +141 -0
  18. package/package.json +3 -2
  19. package/python/app/agent_cli.py +334 -0
  20. package/python/app/explain_route.py +170 -0
  21. package/python/app/health_cli.py +13 -0
  22. package/python/app/main.py +131 -48
  23. package/python/app/materialize.py +150 -68
  24. package/python/app/mcp_contract.py +2 -1
  25. package/python/app/mcp_operator.py +252 -0
  26. package/python/app/mcp_server.py +290 -118
  27. package/python/app/npm_pkg_version.py +38 -0
  28. package/python/app/pick_diversify.py +51 -0
  29. package/python/app/replay_cli.py +145 -0
  30. package/python/app/route_cli.py +251 -87
  31. package/python/app/route_cli_pick.py +35 -0
  32. package/python/app/route_policies.py +18 -3
  33. package/python/app/route_quality.py +70 -1
  34. package/python/app/router_llm.py +85 -0
  35. package/python/app/router_mode.py +21 -0
  36. package/python/app/routing_signals.py +7 -1
  37. package/python/app/skill_manifest.py +67 -0
  38. package/python/app/skills_author_cli.py +117 -0
  39. package/python/app/tips_cli.py +37 -0
  40. package/python/app/tools_cli.py +276 -0
  41. package/python/fixtures/route_eval/smoke.json +5 -0
  42. package/python/requirements.txt +1 -0
  43. package/python/tests/test_capabilities_bundle.py +33 -0
  44. package/python/tests/test_materialize_hosts.py +108 -0
  45. package/python/tests/test_mcp_contract.py +1 -1
  46. package/python/tests/test_mcp_initialize_clientinfo.py +26 -0
  47. package/python/tests/test_mcp_operator.py +84 -0
  48. package/python/tests/test_npm_pkg_version.py +21 -0
  49. package/python/tests/test_pick_diversify.py +47 -0
  50. package/python/tests/test_replay_cli.py +31 -0
  51. package/python/tests/test_route_cli_pick.py +25 -0
  52. package/python/tests/test_route_policies.py +29 -0
  53. package/python/tests/test_route_quality.py +72 -0
  54. package/python/tests/test_router_llm.py +63 -0
  55. package/python/tests/test_router_mode_env.py +21 -0
  56. package/python/tests/test_routing_signals.py +20 -0
  57. package/python/tests/test_skill_manifest.py +48 -0
  58. package/python/tests/test_tools_cli.py +69 -0
@@ -9,6 +9,8 @@ Tools exposed:
9
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
+ capabilities — bundled snapshot (semver, MCP schema version, tool names, user_env_profile commands, router_snapshot) for session start.
13
+ get_router_status, project_index_status, weights_snapshot, events_recent — read-only operator introspection.
12
14
 
13
15
  Run as: python -m app.mcp_server
14
16
  Speaks MCP over stdio (the protocol's standard transport for local servers).
@@ -26,7 +28,6 @@ from pathlib import Path
26
28
  from app.db_paths import resolve_orchestrator_db
27
29
  from app.main import (
28
30
  TOP_K_CANDIDATES,
29
- MAX_ACTIVE_SKILLS,
30
31
  SKILLFORGE_ROUTER_MODE,
31
32
  build_router_and_skills,
32
33
  format_context_items_markdown,
@@ -39,17 +40,28 @@ from app.main import (
39
40
  update_skill_stat,
40
41
  Router,
41
42
  )
42
- from app.materialize import materialize_project_files
43
+ from app.explain_route import compute_explain_route
44
+ from app.materialize import materialize_project_files, resolve_materialize_hosts_argument
43
45
  from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION, build_route_skills_meta
46
+ from app.mcp_operator import (
47
+ EVENTS_META_ROW_CAP,
48
+ build_capabilities_bundle,
49
+ build_router_status_dict,
50
+ events_recent_rows,
51
+ format_capabilities_markdown,
52
+ format_events_markdown,
53
+ format_project_index_markdown,
54
+ format_router_status_markdown,
55
+ project_index_status_dict,
56
+ )
57
+ from app.npm_pkg_version import published_package_version
44
58
  from app.redaction import redaction_enabled, redact_display_path
45
59
  from app.route_policies import (
46
- build_routing_overlay_payload,
47
60
  load_route_policies_config,
48
- merge_policy_includes,
49
61
  merge_project_notes_into_route_query,
50
62
  parse_routing_overlay,
51
63
  )
52
- from app.routing_signals import build_route_query_text
64
+ from app.routing_signals import build_route_query_text, skill_routing_card
53
65
 
54
66
 
55
67
  def _env_truthy(name: str, default: str = "1") -> bool:
@@ -94,6 +106,8 @@ class MCPServer:
94
106
  self._catalog_manifest: tuple[tuple[str, int], ...] | None = None
95
107
  self._reload_lock: asyncio.Lock | None = None
96
108
  self._db_cache: dict[str, sqlite3.Connection] = {}
109
+ self._mcp_client_name = ""
110
+ self._mcp_client_title = ""
97
111
 
98
112
  def _mcp_user_id(self, args: dict) -> str:
99
113
  """Per-tool user namespace for weights/sessions/events."""
@@ -141,10 +155,10 @@ class MCPServer:
141
155
  asyncio.create_task(self._watch_skills_poll_loop(interval))
142
156
 
143
157
  def _reload_catalog_sync(self):
144
- skills = load_all_skills()
158
+ skills = load_all_skills(manifest_log_prefix="[skillforge-mcp]")
145
159
  embed_model = self.router.embed_model
146
- anthropic = self.router.anthropic
147
- self.router = Router(skills, embed_model, anthropic)
160
+ router_llm = self.router.router_llm
161
+ self.router = Router(skills, embed_model, router_llm)
148
162
  self.skills = {s.name: s for s in skills}
149
163
  print(f"[skillforge-mcp] Hot-reloaded {len(skills)} skills", file=sys.stderr)
150
164
 
@@ -190,13 +204,20 @@ class MCPServer:
190
204
  # ---- MCP handlers ----
191
205
 
192
206
  def handle_initialize(self, params):
207
+ ci = params.get("clientInfo")
208
+ if isinstance(ci, dict):
209
+ self._mcp_client_name = str(ci.get("name") or "").strip()
210
+ self._mcp_client_title = str(ci.get("title") or "").strip()
211
+ else:
212
+ self._mcp_client_name = ""
213
+ self._mcp_client_title = ""
193
214
  caps: dict = {"tools": {}}
194
215
  if _mcp_tools_list_changed_capability():
195
216
  caps["tools"]["listChanged"] = True
196
217
  return {
197
218
  "protocolVersion": "2024-11-05",
198
219
  "capabilities": caps,
199
- "serverInfo": {"name": "skillforge", "version": "0.10.0"},
220
+ "serverInfo": {"name": "skillforge", "version": published_package_version()},
200
221
  }
201
222
 
202
223
  def handle_tools_list(self, params):
@@ -205,12 +226,12 @@ class MCPServer:
205
226
  {
206
227
  "name": "route_skills",
207
228
  "description": (
208
- "Two-step when SKILLFORGE_ROUTER_MODE=host (no in-process router LLM): (1) call with prompt "
229
+ "Default SKILLFORGE_ROUTER_MODE=host (two-step, no in-process router LLM): (1) call with prompt "
209
230
  "only — returns a tight numbered shortlist + session_id; (2) call again with the same prompt "
210
231
  "and picked_names (JSON array of exact catalog ids from the list) to load SKILL.md chunks. "
211
- "With auto router modes, one call returns context. Optional conversation, project_root, "
212
- "include_project_rag. picked_names may also be passed in embedding/full mode to skip "
213
- "auto-pick and use the host-provided list."
232
+ "Set SKILLFORGE_ROUTER_MODE=auto (or embedding/full) for one-call routing when configured. "
233
+ "Optional conversation, project_root, include_project_rag. picked_names may also be passed "
234
+ "in embedding/full/auto to skip auto-pick and use the host-provided list."
214
235
  ),
215
236
  "inputSchema": {
216
237
  "type": "object",
@@ -299,7 +320,8 @@ class MCPServer:
299
320
  {
300
321
  "name": "get_skill",
301
322
  "description": (
302
- "Load one skill by name: full SKILL.md body or a short summary. "
323
+ "Load one skill by name: full SKILL.md body, short summary (~8k opener), "
324
+ "or routing `card` (title/description/triggers only). "
303
325
  "Use for deterministic workflows when you already know the skill name."
304
326
  ),
305
327
  "inputSchema": {
@@ -308,8 +330,11 @@ class MCPServer:
308
330
  "skill_name": {"type": "string"},
309
331
  "format": {
310
332
  "type": "string",
311
- "enum": ["full", "summary"],
312
- "description": "summary = description + first ~8k chars of body",
333
+ "enum": ["card", "summary", "full"],
334
+ "description": (
335
+ "card = routing-style card (minimal tokens); summary = description + first ~8k of body; "
336
+ "full = entire SKILL.md body"
337
+ ),
313
338
  "default": "full",
314
339
  },
315
340
  "max_chars": {
@@ -386,12 +411,12 @@ class MCPServer:
386
411
  {
387
412
  "name": "materialize_project",
388
413
  "description": (
389
- "Write project-local Skillforge files: .cursor/rules/skillforge.mdc, "
390
- ".cursor/commands/skillforge.md (Cursor /skillforge), "
391
- ".claude/commands/skillforge.md (Claude Code /skillforge), "
392
- "docs/SKILLFORGE-PRD.md, and a CLAUDE.md section. "
393
- "Pass project_root (workspace path) and skill_names from route_skills. "
394
- "Hosts must supply project_root; MCP does not infer cwd."
414
+ "Write project-local Skillforge files under project_root. Default hosts=auto: SKILLFORGE_MATERIALIZE_HOSTS "
415
+ "if set, else infer Cursor vs Claude from MCP initialize clientInfo, else both. Cursor "
416
+ "(.cursor/rules + .cursor/commands), Claude Code (.claude/commands), docs/SKILLFORGE-PRD.md, "
417
+ "and CLAUDE.md markers. hosts=cursor: Cursor + docs only. hosts=claude_code: Claude + docs + CLAUDE.md "
418
+ "(no .cursor/). hosts=both: write all host trees. With hosts=auto, SKILLFORGE_MATERIALIZE_HOSTS overrides "
419
+ "client inference when set. Pass skill_names from route_skills."
395
420
  ),
396
421
  "inputSchema": {
397
422
  "type": "object",
@@ -402,12 +427,21 @@ class MCPServer:
402
427
  "items": {"type": "string"},
403
428
  "description": "Skill names from the last route_skills result",
404
429
  },
430
+ "hosts": {
431
+ "type": "string",
432
+ "enum": ["auto", "both", "cursor", "claude_code"],
433
+ "default": "auto",
434
+ "description": (
435
+ "`auto`: SKILLFORGE_MATERIALIZE_HOSTS if set (both|cursor|claude_code), else MCP clientInfo "
436
+ "name/title (cursor / claude), else both."
437
+ ),
438
+ },
405
439
  "merge": {
406
440
  "type": "boolean",
407
441
  "description": (
408
- "If false and .cursor/rules/skillforge.mdc, "
409
- ".cursor/commands/skillforge.md, or "
410
- ".claude/commands/skillforge.md exists, skip overwriting those files"
442
+ "If false and a targeted file already exists (.cursor/rules/skillforge.mdc, "
443
+ ".cursor/commands/skillforge.md, or .claude/commands/skillforge.md), skip overwriting it "
444
+ "for hosts that apply."
411
445
  ),
412
446
  "default": True,
413
447
  },
@@ -435,10 +469,88 @@ class MCPServer:
435
469
  "description": "Same as route_skills: merge indexed project file chunks into context.",
436
470
  "default": False,
437
471
  },
472
+ "hosts": {
473
+ "type": "string",
474
+ "enum": ["auto", "both", "cursor", "claude_code"],
475
+ "default": "auto",
476
+ "description": "Passed to materialize_project after route (same semantics as materialize_project).",
477
+ },
438
478
  },
439
479
  "required": ["prompt", "project_root"],
440
480
  },
441
481
  },
482
+ {
483
+ "name": "capabilities",
484
+ "description": (
485
+ "Session bootstrap: MCP response schema version, package semver from package.json (or env override), "
486
+ "ordered MCP tool names, progressive loading hints (`get_skill` formats), manifest/replay/`user_env_profile` "
487
+ "CLI pointers (~/.skillforge/env `config path|init|validate`), and router_snapshot matching get_router_status. Read-only."
488
+ ),
489
+ "inputSchema": {"type": "object", "properties": {}},
490
+ },
491
+ {
492
+ "name": "get_router_status",
493
+ "description": (
494
+ "Read-only: effective SKILLFORGE_ROUTER_MODE, hybrid + rerank toggles from env, embed/router "
495
+ "model ids, shortlist/active caps, and whether Anthropic routing is wired (depends on MCP "
496
+ "process env)."
497
+ ),
498
+ "inputSchema": {"type": "object", "properties": {}},
499
+ },
500
+ {
501
+ "name": "project_index_status",
502
+ "description": (
503
+ "Read-only: counts and last index metadata from the project orchestrator DB "
504
+ "(requires project_root or SKILLFORGE_PROJECT_ROOT). No network."
505
+ ),
506
+ "inputSchema": {
507
+ "type": "object",
508
+ "properties": {
509
+ "project_root": {
510
+ "type": "string",
511
+ "description": "Workspace root that owns .skillforge/orchestrator.db",
512
+ },
513
+ },
514
+ },
515
+ },
516
+ {
517
+ "name": "weights_snapshot",
518
+ "description": (
519
+ "Read-only: export learned skill_weights for this user_id + DB (same shape as "
520
+ "`skillforge weights export`). Optional project_root / user_id."
521
+ ),
522
+ "inputSchema": {
523
+ "type": "object",
524
+ "properties": {
525
+ "project_root": {"type": "string"},
526
+ "user_id": {"type": "string"},
527
+ },
528
+ },
529
+ },
530
+ {
531
+ "name": "events_recent",
532
+ "description": (
533
+ "Read-only: recent SQLite events (route, host_shortlist, feedback, …) for user_id, "
534
+ "newest first. Markdown lists at most 150 rows; `_meta.rows` JSON payloads cap at 100. "
535
+ "Optional event_type filter."
536
+ ),
537
+ "inputSchema": {
538
+ "type": "object",
539
+ "properties": {
540
+ "project_root": {"type": "string"},
541
+ "user_id": {"type": "string"},
542
+ "limit": {
543
+ "type": "integer",
544
+ "description": "Max rows fetched from SQLite (default 25, max 500). Preview/Meta caps apply separately.",
545
+ "default": 25,
546
+ },
547
+ "event_type": {
548
+ "type": "string",
549
+ "description": "Optional exact event_type filter (e.g. route, host_shortlist)",
550
+ },
551
+ },
552
+ },
553
+ },
442
554
  ]
443
555
  }
444
556
 
@@ -466,6 +578,16 @@ class MCPServer:
466
578
  return self._tool_materialize_project(args)
467
579
  if name == "skillforge_bootstrap":
468
580
  return await self._tool_skillforge_bootstrap(args)
581
+ if name == "capabilities":
582
+ return self._tool_capabilities(args)
583
+ if name == "get_router_status":
584
+ return self._tool_get_router_status(args)
585
+ if name == "project_index_status":
586
+ return self._tool_project_index_status(args)
587
+ if name == "weights_snapshot":
588
+ return self._tool_weights_snapshot(args)
589
+ if name == "events_recent":
590
+ return self._tool_events_recent(args)
469
591
  raise ValueError(f"Unknown tool: {name}")
470
592
 
471
593
  async def _tool_route_skills(self, args):
@@ -643,101 +765,26 @@ class MCPServer:
643
765
  limit = TOP_K_CANDIDATES
644
766
  limit = max(1, min(limit, 50))
645
767
  con = self._get_con(args)
646
- policies_cfg = load_route_policies_config(pr)
647
- overlay_audit = []
648
- exclude_skills, routing_boosts, project_notes = parse_routing_overlay(
649
- policies_cfg,
650
- by_name=self.router._by_name,
651
- audit_out=overlay_audit,
652
- )
653
- route_query = merge_project_notes_into_route_query(
654
- build_route_query_text(prompt, conversation),
655
- project_notes,
656
- pr,
657
- )
658
- facets = self.router.shortlist_with_facets(
659
- route_query,
768
+ body, explain = await compute_explain_route(
769
+ self.router,
660
770
  con,
661
- k=limit,
771
+ prompt=prompt,
772
+ conversation=conversation,
773
+ limit=limit,
662
774
  user_id=user_id,
663
- exclude_skills=exclude_skills,
664
- routing_boosts=routing_boosts,
665
- )
666
- candidates = self.router.shortlist(
667
- route_query,
668
- con,
669
- limit,
670
- user_id,
671
- exclude_skills=exclude_skills,
672
- routing_boosts=routing_boosts,
673
- )
674
- candidates = await self.router.rerank_candidates_haiku(route_query, conversation, candidates)
675
- picked, reasoning = await self.router.pick_final(
676
- prompt, conversation, candidates, route_query=route_query
677
- )
678
- merged, policy_audit = merge_policy_includes(
679
- prompt,
680
- list(picked),
681
- policies_cfg,
682
- self.router._by_name,
683
- con,
684
- user_id,
685
- max_active=MAX_ACTIVE_SKILLS,
686
- )
687
- router_mode = "full" if self.router.anthropic else "embedding-only"
688
- notes_effective = bool(project_notes.strip() and (pr or "").strip())
689
- routing_ov = build_routing_overlay_payload(
690
- project_root=pr or "",
691
- exclude_skills=exclude_skills,
692
- routing_boosts=routing_boosts,
693
- project_notes_applied=notes_effective,
694
- project_notes_len=len(project_notes) if project_notes else 0,
695
- audit=overlay_audit,
775
+ project_root=pr,
776
+ db_path=db_path,
696
777
  )
697
- explain = {
698
- "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
699
- "tool": "explain_route",
700
- "orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
701
- "router_mode": router_mode,
702
- "embedding_shortlist": facets,
703
- "picked_before_policy": list(picked),
704
- "picked_after_policy": merged,
705
- "router_reasoning": reasoning,
706
- "policy": {
707
- "rules_loaded": len(policies_cfg.get("rules") or [])
708
- if isinstance(policies_cfg.get("rules"), list)
709
- else 0,
710
- "audit": policy_audit,
711
- },
712
- }
713
- if routing_ov is not None:
714
- explain["routing_overlay"] = routing_ov
715
- lines = [
716
- "# explain_route — routing diagnostics (no DB writes)",
717
- "",
718
- f"**Router:** {router_mode}",
719
- f"**Picked (router):** {', '.join(picked) if picked else '_(none)_'}",
720
- f"**After policies:** {', '.join(merged) if merged else '_(none)_'}",
721
- f"**Reasoning:** {reasoning}" if reasoning else "**Reasoning:** _(n/a)_",
722
- "",
723
- "## Shortlist (embedding)",
724
- ]
725
- for f in facets[:15]:
726
- lines.append(
727
- f"- `{f['name']}` cos={f['cosine_similarity']} weight={f['learned_weight']} "
728
- f"score={f['routing_score']}"
729
- )
730
- if policy_audit:
731
- lines.extend(["", "## Policy audit"])
732
- for row in policy_audit[:30]:
733
- lines.append(f"- {row}")
734
- body = "\n".join(lines)
735
778
  return {"content": [{"type": "text", "text": body}], "_meta": explain}
736
779
 
737
780
  def _tool_get_skill(self, args):
738
781
  name = (args.get("skill_name") or "").strip()
739
- fmt = (args.get("format") or "full").strip().lower()
740
- if fmt not in ("full", "summary"):
782
+ fmt_raw = (args.get("format") or "full").strip().lower()
783
+ if fmt_raw in ("card", "minimal"):
784
+ fmt = "card"
785
+ elif fmt_raw == "summary":
786
+ fmt = "summary"
787
+ else:
741
788
  fmt = "full"
742
789
  max_chars = args.get("max_chars")
743
790
  try:
@@ -750,7 +797,9 @@ class MCPServer:
750
797
  "isError": True,
751
798
  }
752
799
  s = self.skills[name]
753
- if fmt == "summary":
800
+ if fmt == "card":
801
+ body = skill_routing_card(s)
802
+ elif fmt == "summary":
754
803
  body = f"{s.description}\n\n---\n\n{(s.body or '')[:8000]}"
755
804
  else:
756
805
  body = s.body or ""
@@ -850,7 +899,20 @@ class MCPServer:
850
899
  valid = [n for n in names_raw if isinstance(n, str) and n in self.skills]
851
900
  desc = {n: self.skills[n].description for n in valid}
852
901
  try:
853
- out = materialize_project_files(root, valid, desc, merge=bool(merge))
902
+ mh = args.get("hosts")
903
+ if mh is None:
904
+ mh_arg: str | None = None
905
+ elif isinstance(mh, str):
906
+ mh_arg = mh.strip() or None
907
+ else:
908
+ mh_arg = str(mh).strip() or None
909
+ hosts_arg, hres = resolve_materialize_hosts_argument(
910
+ mh_arg,
911
+ client_name=getattr(self, "_mcp_client_name", ""),
912
+ client_title=getattr(self, "_mcp_client_title", ""),
913
+ )
914
+ out = materialize_project_files(root, valid, desc, merge=bool(merge), hosts=hosts_arg)
915
+ out.update(hres)
854
916
  except ValueError as e:
855
917
  return {"content": [{"type": "text", "text": str(e)}], "isError": True}
856
918
  lines = ["# Skillforge — materialized project files", "", "Written:", *[f"- {p}" for p in out["written"]], ""]
@@ -865,8 +927,8 @@ class MCPServer:
865
927
  merge = args.get("merge", True)
866
928
  if SKILLFORGE_ROUTER_MODE == "host":
867
929
  msg = (
868
- "skillforge_bootstrap does not support SKILLFORGE_ROUTER_MODE=host (two-step routing). "
869
- "Set SKILLFORGE_ROUTER_MODE=embedding for one-shot bootstrap, or call route_skills twice "
930
+ "skillforge_bootstrap does not support host routing (SKILLFORGE_ROUTER_MODE=host, the default). "
931
+ "Set SKILLFORGE_ROUTER_MODE=embedding or auto for one-shot bootstrap, or call route_skills twice "
870
932
  "(shortlist then picked_names) and materialize_project yourself."
871
933
  )
872
934
  return {"content": [{"type": "text", "text": msg}], "isError": True}
@@ -891,7 +953,12 @@ class MCPServer:
891
953
  return route
892
954
  picked = (route.get("_meta") or {}).get("picked") or []
893
955
  mat = self._tool_materialize_project(
894
- {"project_root": root, "skill_names": picked, "merge": merge}
956
+ {
957
+ "project_root": root,
958
+ "skill_names": picked,
959
+ "merge": merge,
960
+ "hosts": args.get("hosts"),
961
+ }
895
962
  )
896
963
  if mat.get("isError"):
897
964
  return mat
@@ -908,6 +975,111 @@ class MCPServer:
908
975
  },
909
976
  }
910
977
 
978
+ def _tool_capabilities(self, args):
979
+ sc = len(self.skills) if self.skills else 0
980
+ bundle = build_capabilities_bundle(self.router, skill_count=sc)
981
+ text = format_capabilities_markdown(bundle)
982
+ db_path = resolve_orchestrator_db(self._project_root_from_args(args))
983
+ return {
984
+ "content": [{"type": "text", "text": text}],
985
+ "_meta": {
986
+ "tool": "capabilities",
987
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
988
+ "bundle": bundle,
989
+ "orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
990
+ },
991
+ }
992
+
993
+ def _tool_get_router_status(self, args):
994
+ sc = len(self.skills) if self.skills else 0
995
+ snap = build_router_status_dict(self.router, skill_count=sc)
996
+ text = format_router_status_markdown(snap)
997
+ db_path = resolve_orchestrator_db(self._project_root_from_args(args))
998
+ return {
999
+ "content": [{"type": "text", "text": text}],
1000
+ "_meta": {
1001
+ "tool": "get_router_status",
1002
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
1003
+ "snapshot": snap,
1004
+ "orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
1005
+ },
1006
+ }
1007
+
1008
+ def _tool_project_index_status(self, args):
1009
+ pr = self._project_root_from_args(args)
1010
+ if not pr:
1011
+ return {
1012
+ "content": [{
1013
+ "type": "text",
1014
+ "text": "project_root argument or SKILLFORGE_PROJECT_ROOT is required.",
1015
+ }],
1016
+ "isError": True,
1017
+ }
1018
+ con = self._get_con(args)
1019
+ stats = project_index_status_dict(con)
1020
+ root_path = Path(pr).expanduser().resolve()
1021
+ text = format_project_index_markdown(stats, root_path)
1022
+ db_path = resolve_orchestrator_db(pr)
1023
+ return {
1024
+ "content": [{"type": "text", "text": text}],
1025
+ "_meta": {
1026
+ "tool": "project_index_status",
1027
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
1028
+ "project_root": str(root_path),
1029
+ "orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
1030
+ **stats,
1031
+ },
1032
+ }
1033
+
1034
+ def _tool_weights_snapshot(self, args):
1035
+ from app.weights_cli import export_weights
1036
+
1037
+ user_id = self._mcp_user_id(args)
1038
+ con = self._get_con(args)
1039
+ snap = export_weights(con, user_id)
1040
+ blob = json.dumps(snap, indent=2)
1041
+ db_path = resolve_orchestrator_db(self._project_root_from_args(args))
1042
+ return {
1043
+ "content": [{"type": "text", "text": f"# Skillforge — weights_snapshot\n\n```json\n{blob}\n```"}],
1044
+ "_meta": {
1045
+ "tool": "weights_snapshot",
1046
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
1047
+ "user_id": user_id,
1048
+ "row_count": len(snap.get("rows") or []),
1049
+ "orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
1050
+ },
1051
+ }
1052
+
1053
+ def _tool_events_recent(self, args):
1054
+ raw_lim = args.get("limit")
1055
+ try:
1056
+ limit = int(raw_lim) if raw_lim is not None else 25
1057
+ except (TypeError, ValueError):
1058
+ limit = 25
1059
+ et_raw = args.get("event_type")
1060
+ et = str(et_raw).strip() if isinstance(et_raw, str) and et_raw.strip() else None
1061
+ user_id = self._mcp_user_id(args)
1062
+ con = self._get_con(args)
1063
+ rows = events_recent_rows(con, limit=limit, user_id=user_id, event_type=et)
1064
+ text = format_events_markdown(rows)
1065
+ db_path = resolve_orchestrator_db(self._project_root_from_args(args))
1066
+ meta_cap = EVENTS_META_ROW_CAP
1067
+ meta_rows = rows[:meta_cap]
1068
+ return {
1069
+ "content": [{"type": "text", "text": text}],
1070
+ "_meta": {
1071
+ "tool": "events_recent",
1072
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
1073
+ "requested_limit": limit,
1074
+ "returned_count": len(rows),
1075
+ "user_id": user_id,
1076
+ "event_type_filter": et,
1077
+ "truncated_meta_rows": len(rows) > meta_cap,
1078
+ "rows": meta_rows,
1079
+ "orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
1080
+ },
1081
+ }
1082
+
911
1083
  # ---- JSON-RPC dispatcher ----
912
1084
 
913
1085
  async def dispatch(self, request):
@@ -0,0 +1,38 @@
1
+ """Published npm semver for MCP ``serverInfo`` (keeps MCP in sync with ``package/package.json``)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+ NPM_PACKAGE_NAME = "@heytherevibin/skillforge"
10
+
11
+
12
+ @lru_cache(maxsize=1)
13
+ def published_package_version() -> str:
14
+ """Return ``version`` from the nearest ancestor ``package.json`` for this npm package.
15
+
16
+ Fallback ``0.0.0`` when the file tree does not contain the manifest (e.g. partial copy).
17
+ Optional override: ``SKILLFORGE_MCP_SERVER_VERSION`` for operators embedding a custom string.
18
+ """
19
+ override = os.getenv("SKILLFORGE_MCP_SERVER_VERSION", "").strip()
20
+ if override:
21
+ return override
22
+ here = Path(__file__).resolve()
23
+ for d in here.parents:
24
+ pj = d / "package.json"
25
+ if not pj.is_file():
26
+ continue
27
+ try:
28
+ data = json.loads(pj.read_text(encoding="utf-8"))
29
+ except (OSError, json.JSONDecodeError, TypeError):
30
+ continue
31
+ if data.get("name") == NPM_PACKAGE_NAME:
32
+ v = str(data.get("version") or "").strip()
33
+ return v or "0.0.0"
34
+ return "0.0.0"
35
+
36
+
37
+ def clear_version_cache_for_tests() -> None:
38
+ published_package_version.cache_clear()
@@ -0,0 +1,51 @@
1
+ """Optional post-pick diversification (cap skills per source before policy merge)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Any, Mapping
6
+
7
+
8
+ def _env_truthy_pick_diversify() -> bool:
9
+ return os.getenv("SKILLFORGE_PICK_DIVERSIFY", "").strip().lower() in ("1", "true", "yes")
10
+
11
+
12
+ def diversify_picked_names(
13
+ names: list[str],
14
+ by_name: Mapping[str, Any],
15
+ *,
16
+ max_per_source: int | None = None,
17
+ ) -> tuple[list[str], dict[str, Any]]:
18
+ """Drop excess picks from the same skill ``source`` while preserving order.
19
+
20
+ Returns ``(names_out, meta)``. When disabled, ``meta.applied`` is False and names are unchanged.
21
+ """
22
+ meta: dict[str, Any] = {
23
+ "applied": False,
24
+ "dropped": [],
25
+ "max_per_source": max_per_source,
26
+ }
27
+ if not _env_truthy_pick_diversify():
28
+ return list(names), meta
29
+
30
+ mps = max_per_source
31
+ if mps is None:
32
+ raw = os.getenv("SKILLFORGE_PICK_MAX_PER_SOURCE", "2").strip()
33
+ try:
34
+ mps = int(raw)
35
+ except ValueError:
36
+ mps = 2
37
+ mps = max(1, int(mps))
38
+ meta["applied"] = True
39
+ meta["max_per_source"] = mps
40
+
41
+ counts: dict[str, int] = {}
42
+ out: list[str] = []
43
+ for n in names:
44
+ sk = by_name.get(n)
45
+ src = getattr(sk, "source", None) or "unknown"
46
+ if counts.get(src, 0) >= mps:
47
+ meta["dropped"].append(n)
48
+ continue
49
+ counts[src] = counts.get(src, 0) + 1
50
+ out.append(n)
51
+ return out, meta