@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
@@ -0,0 +1,334 @@
1
+ """
2
+ ``skillforge agent`` — OpenAI-compatible chat completions + Skillforge MCP tool handlers.
3
+
4
+ Requires ``openai`` and a reachable ``/v1`` API (Ollama, LiteLLM, OpenAI, etc.).
5
+ Read-only MCP tools only (no writes) for v1.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import sys
14
+ from typing import Any
15
+
16
+
17
+ TOOLS: list[dict[str, Any]] = [
18
+ {
19
+ "type": "function",
20
+ "function": {
21
+ "name": "route_skills",
22
+ "description": "Route prompts to SKILL.md snippets (MCP parity). Supports host-mode two-step if ROUTER_MODE=host.",
23
+ "parameters": {
24
+ "type": "object",
25
+ "properties": {
26
+ "prompt": {"type": "string"},
27
+ "project_root": {"type": "string"},
28
+ "session_id": {"type": "string"},
29
+ "user_id": {"type": "string"},
30
+ "picked_names": {"type": "array", "items": {"type": "string"}},
31
+ "include_project_rag": {"type": "boolean"},
32
+ },
33
+ "required": ["prompt"],
34
+ },
35
+ },
36
+ },
37
+ {
38
+ "type": "function",
39
+ "function": {
40
+ "name": "search_skills",
41
+ "description": "Embedding-only similarity shortlist.",
42
+ "parameters": {
43
+ "type": "object",
44
+ "properties": {
45
+ "query": {"type": "string"},
46
+ "limit": {"type": "integer"},
47
+ "project_root": {"type": "string"},
48
+ "user_id": {"type": "string"},
49
+ },
50
+ "required": ["query"],
51
+ },
52
+ },
53
+ },
54
+ {
55
+ "type": "function",
56
+ "function": {
57
+ "name": "explain_route",
58
+ "description": "Routing diagnostics without session commits.",
59
+ "parameters": {
60
+ "type": "object",
61
+ "properties": {
62
+ "prompt": {"type": "string"},
63
+ "limit": {"type": "integer"},
64
+ "project_root": {"type": "string"},
65
+ "user_id": {"type": "string"},
66
+ },
67
+ "required": ["prompt"],
68
+ },
69
+ },
70
+ },
71
+ {
72
+ "type": "function",
73
+ "function": {
74
+ "name": "get_skill",
75
+ "description": "Fetch one SKILL by catalog id.",
76
+ "parameters": {
77
+ "type": "object",
78
+ "properties": {
79
+ "skill_name": {"type": "string"},
80
+ "format": {"type": "string", "enum": ["card", "summary", "full"]},
81
+ "max_chars": {"type": "integer"},
82
+ },
83
+ "required": ["skill_name"],
84
+ },
85
+ },
86
+ },
87
+ {
88
+ "type": "function",
89
+ "function": {
90
+ "name": "list_skills",
91
+ "description": "List catalog skills plus usage weights.",
92
+ "parameters": {"type": "object", "properties": {"project_root": {"type": "string"}, "user_id": {"type": "string"}}},
93
+ },
94
+ },
95
+ {
96
+ "type": "function",
97
+ "function": {
98
+ "name": "capabilities",
99
+ "description": "Package + MCP semver + advertised tools bundle.",
100
+ "parameters": {"type": "object", "properties": {}},
101
+ },
102
+ },
103
+ {
104
+ "type": "function",
105
+ "function": {
106
+ "name": "get_router_status",
107
+ "description": "Read-only routing env snapshot.",
108
+ "parameters": {"type": "object", "properties": {}},
109
+ },
110
+ },
111
+ {
112
+ "type": "function",
113
+ "function": {
114
+ "name": "project_index_status",
115
+ "description": "Project RAG sqlite stats.",
116
+ "parameters": {
117
+ "type": "object",
118
+ "properties": {"project_root": {"type": "string"}},
119
+ "required": ["project_root"],
120
+ },
121
+ },
122
+ },
123
+ {
124
+ "type": "function",
125
+ "function": {
126
+ "name": "events_recent",
127
+ "description": "Recent orchestrator SQLite events.",
128
+ "parameters": {
129
+ "type": "object",
130
+ "properties": {
131
+ "limit": {"type": "integer"},
132
+ "event_type": {"type": "string"},
133
+ "project_root": {"type": "string"},
134
+ "user_id": {"type": "string"},
135
+ },
136
+ },
137
+ },
138
+ },
139
+ ]
140
+
141
+
142
+ def _tool_payload_text(payload: dict[str, Any], *, limit: int) -> str:
143
+ parts: list[str] = []
144
+ for b in payload.get("content") or []:
145
+ if isinstance(b, dict) and b.get("type") == "text":
146
+ parts.append(str(b.get("text") or ""))
147
+ txt = "\n".join(parts).strip()
148
+ if payload.get("isError"):
149
+ txt = "(tool_error)\n" + txt
150
+ if len(txt) > limit:
151
+ return txt[: limit - 80] + "\n… [truncated for context budget]"
152
+ return txt
153
+
154
+
155
+ def _assistant_message_dict(msg: Any) -> dict[str, Any]:
156
+ out: dict[str, Any] = {"role": "assistant"}
157
+ if msg.content:
158
+ out["content"] = msg.content
159
+ if getattr(msg, "tool_calls", None):
160
+ tc_list = []
161
+ for tc in msg.tool_calls:
162
+ fn = getattr(tc, "function", None)
163
+ if fn:
164
+ tc_list.append({
165
+ "id": getattr(tc, "id", ""),
166
+ "type": "function",
167
+ "function": {
168
+ "name": getattr(fn, "name", "") or "",
169
+ "arguments": getattr(fn, "arguments", "") or "",
170
+ },
171
+ })
172
+ out["tool_calls"] = tc_list
173
+ return out
174
+
175
+
176
+ def _inject_project_root(args_dict: dict[str, Any], pr: str) -> dict[str, Any]:
177
+ merged = dict(args_dict)
178
+ existing = merged.get("project_root")
179
+ if isinstance(existing, str):
180
+ stripped = existing.strip()
181
+ merged["project_root"] = (stripped or pr) if pr else stripped
182
+ return merged
183
+ if pr:
184
+ merged["project_root"] = pr
185
+ return merged
186
+
187
+ async def run_agent_round(
188
+ *,
189
+ server: MCPServer,
190
+ client: Any,
191
+ model: str,
192
+ messages: list[dict[str, Any]],
193
+ default_project_root: str,
194
+ inner_cap: int,
195
+ max_rounds: int = 14,
196
+ ) -> tuple[str | None, list[dict[str, Any]]]:
197
+ reply: str | None = None
198
+ for _ in range(max_rounds):
199
+ resp = await client.chat.completions.create(
200
+ model=model,
201
+ messages=messages,
202
+ tools=TOOLS,
203
+ tool_choice="auto",
204
+ temperature=0.2,
205
+ )
206
+ msg = resp.choices[0].message
207
+ messages.append(_assistant_message_dict(msg))
208
+
209
+ tc_list = getattr(msg, "tool_calls", None)
210
+ if not tc_list:
211
+ reply = msg.content if msg.content else None
212
+ return reply, messages
213
+
214
+ for tc in tc_list:
215
+ fn = getattr(tc, "function", None)
216
+ name = (getattr(fn, "name", None) or "").strip()
217
+ raw_args = getattr(fn, "arguments", None) if fn else "{}"
218
+ try:
219
+ parsed = json.loads(raw_args or "{}")
220
+ except json.JSONDecodeError:
221
+ parsed = {}
222
+ merged = _inject_project_root(parsed, default_project_root)
223
+ payload = await server.handle_tools_call({"name": name, "arguments": merged})
224
+ text = _tool_payload_text(payload, limit=inner_cap)
225
+ messages.append({
226
+ "role": "tool",
227
+ "tool_call_id": getattr(tc, "id", "") or "",
228
+ "content": text,
229
+ })
230
+ reply = "(max_tool_rounds_reached)"
231
+ return reply, messages
232
+
233
+
234
+ async def async_main() -> int:
235
+ p = argparse.ArgumentParser(description="Skillforge standalone agent · OpenAI-compatible tools surface.")
236
+ p.add_argument("--model", metavar="MODEL", default=os.getenv("SKILLFORGE_AGENT_MODEL", "").strip() or "")
237
+ p.add_argument("--base-url", metavar="URL", default=os.getenv("OPENAI_API_BASE", "").strip() or "")
238
+ p.add_argument(
239
+ "--api-key",
240
+ metavar="KEY",
241
+ default=os.getenv("SKILLFORGE_AGENT_API_KEY") or os.getenv("OPENAI_API_KEY", ""),
242
+ )
243
+ p.add_argument("--project-root", metavar="PATH", default=os.getenv("SKILLFORGE_PROJECT_ROOT", "").strip() or "")
244
+ p.add_argument("--prompt", metavar="TEXT", default="", help="One-shot user message then exit.")
245
+ ns = p.parse_args()
246
+
247
+ try:
248
+ from openai import AsyncOpenAI
249
+ except ImportError:
250
+ sys.stderr.write(
251
+ "Missing `openai` package — run `skillforge install` to refresh ~/.skillforge/venv deps, "
252
+ "or pip install `openai` into that venv.\n",
253
+ )
254
+ return 2
255
+
256
+ base = ns.base_url or os.getenv("SKILLFORGE_AGENT_API_BASE") or os.getenv("OPENAI_API_BASE", "").strip() or "http://localhost:11434/v1"
257
+ api_key = (ns.api_key or "").strip() or "ollama"
258
+ model = (ns.model or "").strip() or os.getenv("SKILLFORGE_AGENT_MODEL", "").strip() or os.getenv(
259
+ "SKILLFORGE_OPENAI_ROUTER_MODEL", "").strip() or "llama3.2"
260
+
261
+ inner_cap = max(2048, int(os.getenv("SKILLFORGE_AGENT_TOOL_CHAR_CAP", "12000")))
262
+ proj = ns.project_root.strip()
263
+
264
+ sys.stderr.write("[skillforge-agent] Starting MCP-backed tool runtime...\n")
265
+ sys.stderr.flush()
266
+ server = MCPServer()
267
+ await server.setup()
268
+
269
+ system = (
270
+ "You are the Skillforge terminal agent.\n"
271
+ "Use MCP tools (`route_skills`, `search_skills`, …) instead of hallucinating SKILL content.\n"
272
+ "Prefer `route_skills` after `search_skills` when narrowing which skills matter.\n"
273
+ "Honor host-mode Skillforge: repeat `route_skills` with `picked_names` when the UI lists a shortlist.\n"
274
+ "Keep answers actionable and cite retrieved skill excerpts only after calling tools.\n"
275
+ )
276
+ messages: list[dict[str, Any]] = [{"role": "system", "content": system}]
277
+
278
+ client = AsyncOpenAI(api_key=api_key, base_url=base.rstrip("/"))
279
+
280
+ if ns.prompt.strip():
281
+ user_line = ns.prompt.strip()
282
+ messages.append({"role": "user", "content": user_line})
283
+ reply, messages = await run_agent_round(
284
+ server=server,
285
+ client=client,
286
+ model=model,
287
+ messages=messages,
288
+ default_project_root=proj,
289
+ inner_cap=inner_cap,
290
+ )
291
+ if reply:
292
+ sys.stdout.write(reply.strip() + "\n")
293
+ sys.stdout.flush()
294
+ return 0
295
+
296
+ if not sys.stdin.isatty(): # pragma: no cover
297
+ sys.stderr.write("Interactive mode requires a TTY (pipe --prompt=… instead).\n")
298
+ return 2
299
+
300
+ while True:
301
+ try:
302
+ line = input("\nYou> ").strip()
303
+ except (EOFError, KeyboardInterrupt):
304
+ print()
305
+ return 0
306
+ if not line:
307
+ continue
308
+ if line.lower() in ("quit", "/quit", ":q", "/exit"):
309
+ return 0
310
+ messages.append({"role": "user", "content": line})
311
+ reply, messages = await run_agent_round(
312
+ server=server,
313
+ client=client,
314
+ model=model,
315
+ messages=messages,
316
+ default_project_root=proj,
317
+ inner_cap=inner_cap,
318
+ )
319
+ if reply:
320
+ print("\nAssistant>\n", reply.strip(), sep="")
321
+ else:
322
+ print("\nAssistant> (empty)")
323
+ if len(messages) > 140:
324
+ trim = [{"role": "system", "content": system}]
325
+ trim.extend(messages[-120:])
326
+ messages = trim
327
+
328
+
329
+ def main() -> None:
330
+ raise SystemExit(asyncio.run(async_main()))
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()
@@ -0,0 +1,170 @@
1
+ """Shared ``explain_route`` logic (MCP ``explain_route`` + ``skillforge route --explain``)."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from app.main import MAX_ACTIVE_SKILLS, Router
9
+ from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION
10
+ from app.pick_diversify import diversify_picked_names
11
+ from app.redaction import redaction_enabled, redact_display_path
12
+ from app.route_policies import (
13
+ build_routing_overlay_payload,
14
+ load_route_policies_config,
15
+ merge_policy_includes,
16
+ merge_project_notes_into_route_query,
17
+ parse_routing_overlay,
18
+ )
19
+ from app.routing_signals import build_route_query_text
20
+
21
+
22
+ def sanitize_explain_payload(explain: dict[str, Any]) -> dict[str, Any]:
23
+ """Make explain meta JSON-safe (numpy scalars → Python floats)."""
24
+
25
+ import numpy as np
26
+
27
+ def _walk(o: Any) -> Any:
28
+ if isinstance(o, dict):
29
+ return {str(k): _walk(v) for k, v in o.items()}
30
+ if isinstance(o, list):
31
+ return [_walk(x) for x in o]
32
+ if isinstance(o, (str, bool, type(None))):
33
+ return o
34
+ if isinstance(o, (int,)):
35
+ return o
36
+ if isinstance(o, (float,)):
37
+ return o
38
+ if isinstance(o, np.floating):
39
+ return float(o)
40
+ if isinstance(o, np.integer):
41
+ return int(o)
42
+ if isinstance(o, np.ndarray):
43
+ return o.tolist()
44
+ try:
45
+ return float(o)
46
+ except (TypeError, ValueError):
47
+ return str(o)
48
+
49
+ return _walk(explain)
50
+
51
+
52
+ async def compute_explain_route(
53
+ router: Router,
54
+ con: sqlite3.Connection,
55
+ *,
56
+ prompt: str,
57
+ conversation: list[Any],
58
+ limit: int,
59
+ user_id: str,
60
+ project_root: str | None,
61
+ db_path: Path,
62
+ ) -> tuple[str, dict[str, Any]]:
63
+ """Return Markdown body + ``_meta`` shape matching MCP ``explain_route`` (does not touch sessions/events)."""
64
+ policies_cfg = load_route_policies_config(project_root)
65
+ overlay_audit: list[Any] = []
66
+ exclude_skills, routing_boosts, project_notes = parse_routing_overlay(
67
+ policies_cfg,
68
+ by_name=router._by_name,
69
+ audit_out=overlay_audit,
70
+ )
71
+ route_query = merge_project_notes_into_route_query(
72
+ build_route_query_text(prompt, conversation),
73
+ project_notes,
74
+ project_root,
75
+ )
76
+ facets = router.shortlist_with_facets(
77
+ route_query,
78
+ con,
79
+ k=limit,
80
+ user_id=user_id,
81
+ exclude_skills=exclude_skills,
82
+ routing_boosts=routing_boosts,
83
+ )
84
+ candidates = router.shortlist(
85
+ route_query,
86
+ con,
87
+ limit,
88
+ user_id,
89
+ exclude_skills=exclude_skills,
90
+ routing_boosts=routing_boosts,
91
+ )
92
+ candidates = await router.rerank_candidates_haiku(route_query, conversation, candidates)
93
+ picked_router, reasoning = await router.pick_final(
94
+ prompt,
95
+ conversation,
96
+ candidates,
97
+ route_query=route_query,
98
+ )
99
+ picked_before_pol, div_meta = diversify_picked_names(list(picked_router), router._by_name)
100
+ merged, policy_audit = merge_policy_includes(
101
+ prompt,
102
+ picked_before_pol,
103
+ policies_cfg,
104
+ router._by_name,
105
+ con,
106
+ user_id,
107
+ max_active=MAX_ACTIVE_SKILLS,
108
+ )
109
+ router_mode = "full" if router.router_llm else "embedding-only"
110
+ notes_effective = bool(project_notes.strip() and (project_root or "").strip())
111
+ routing_ov = build_routing_overlay_payload(
112
+ project_root=project_root or "",
113
+ exclude_skills=exclude_skills,
114
+ routing_boosts=routing_boosts,
115
+ project_notes_applied=notes_effective,
116
+ project_notes_len=len(project_notes) if project_notes else 0,
117
+ audit=overlay_audit,
118
+ )
119
+ explain_raw: dict[str, Any] = {
120
+ "schema_version": MCP_RESPONSE_SCHEMA_VERSION,
121
+ "tool": "explain_route",
122
+ "orchestrator_db": redact_display_path(db_path) if redaction_enabled() else str(db_path),
123
+ "router_mode": router_mode,
124
+ "embedding_shortlist": facets,
125
+ "picked_router": picked_router,
126
+ "picked_before_policy": picked_before_pol,
127
+ "picked_after_policy": merged,
128
+ "pick_diversify": div_meta,
129
+ "router_reasoning": reasoning,
130
+ "policy": {
131
+ "rules_loaded": len(policies_cfg.get("rules") or [])
132
+ if isinstance(policies_cfg.get("rules"), list)
133
+ else 0,
134
+ "audit": policy_audit,
135
+ },
136
+ }
137
+ if routing_ov is not None:
138
+ explain_raw["routing_overlay"] = routing_ov
139
+
140
+ explain = sanitize_explain_payload(explain_raw)
141
+
142
+ lines = [
143
+ "# explain_route — routing diagnostics (no session writes)",
144
+ "",
145
+ f"**Router:** {router_mode}",
146
+ f"**Picked (router):** {', '.join(picked_router) if picked_router else '_(none)_'}",
147
+ ]
148
+ if div_meta.get("applied"):
149
+ lines.append(
150
+ f"**After pick_diversify:** {', '.join(picked_before_pol) if picked_before_pol else '_(none)_'}"
151
+ )
152
+ lines.extend(
153
+ [
154
+ f"**After policies:** {', '.join(merged) if merged else '_(none)_'}",
155
+ f"**Reasoning:** {reasoning}" if reasoning else "**Reasoning:** _(n/a)_",
156
+ "",
157
+ "## Shortlist (embedding)",
158
+ ]
159
+ )
160
+ for f in facets[:15]:
161
+ lines.append(
162
+ f"- `{f['name']}` cos={f['cosine_similarity']} weight={f['learned_weight']} "
163
+ f"score={f['routing_score']}"
164
+ )
165
+ if policy_audit:
166
+ lines.extend(["", "## Policy audit"])
167
+ for row in policy_audit[:30]:
168
+ lines.append(f"- {row}")
169
+ body = "\n".join(lines)
170
+ return body, explain
@@ -79,6 +79,14 @@ def run_health(*, quick: bool, project_root: str, json_out: bool) -> int:
79
79
  "error": u_err,
80
80
  })
81
81
 
82
+ env_profile = Path.home() / ".skillforge" / "env"
83
+ checks.append({
84
+ "name": "user_env_profile",
85
+ "ok": True,
86
+ "path": str(env_profile),
87
+ "present": env_profile.is_file(),
88
+ })
89
+
82
90
  pr = (project_root or "").strip() or None
83
91
  db_path = resolve_orchestrator_db(pr)
84
92
  db_ok = True
@@ -142,6 +150,11 @@ def run_health(*, quick: bool, project_root: str, json_out: bool) -> int:
142
150
  print(f" SKILL.md count: {c['skill_md_count']}", file=sys.stderr)
143
151
  if c.get("skill_count") is not None:
144
152
  print(f" router skills: {c['skill_count']}", file=sys.stderr)
153
+ if c.get("present") is not None:
154
+ if c["present"]:
155
+ print(f" present: yes", file=sys.stderr)
156
+ else:
157
+ print(f" present: no · optional (`skillforge config init`)", file=sys.stderr)
145
158
  if c.get("error"):
146
159
  print(f" error: {c['error']}", file=sys.stderr)
147
160
  print("health: ok" if not failed else "health: failed", file=sys.stderr)