@heytherevibin/skillforge 0.10.1 → 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.
- package/CHANGELOG.md +49 -0
- package/CONTRIBUTING.md +5 -3
- package/README.md +37 -345
- package/RELEASING.md +7 -6
- package/STRATEGY.md +2 -2
- package/bin/cli.js +297 -52
- package/ci/test-user-env-profile.cjs +65 -0
- package/docs/README.md +14 -0
- package/docs/architecture-and-data.md +90 -0
- package/docs/cli-reference.md +57 -0
- package/docs/environment-and-configuration.md +76 -0
- package/docs/getting-started.md +88 -0
- package/docs/mcp-integration.md +75 -0
- package/docs/troubleshooting.md +50 -0
- package/lib/templates/claude-code-skillforge-global.md +3 -3
- package/lib/templates/cursor-skillforge-global.md +6 -2
- package/lib/user-env-profile.js +141 -0
- package/package.json +3 -2
- package/python/app/agent_cli.py +334 -0
- package/python/app/explain_route.py +170 -0
- package/python/app/health_cli.py +13 -0
- package/python/app/main.py +131 -48
- package/python/app/materialize.py +150 -68
- package/python/app/mcp_contract.py +2 -1
- package/python/app/mcp_operator.py +252 -0
- package/python/app/mcp_server.py +290 -118
- package/python/app/npm_pkg_version.py +38 -0
- package/python/app/pick_diversify.py +51 -0
- package/python/app/replay_cli.py +145 -0
- package/python/app/route_cli.py +251 -87
- package/python/app/route_cli_pick.py +35 -0
- package/python/app/route_policies.py +18 -3
- package/python/app/route_quality.py +70 -1
- package/python/app/router_llm.py +85 -0
- package/python/app/router_mode.py +21 -0
- package/python/app/routing_signals.py +7 -1
- package/python/app/skill_manifest.py +67 -0
- package/python/app/skills_author_cli.py +117 -0
- package/python/app/tips_cli.py +37 -0
- package/python/app/tools_cli.py +276 -0
- package/python/fixtures/route_eval/smoke.json +5 -0
- package/python/requirements.txt +1 -0
- package/python/tests/test_capabilities_bundle.py +33 -0
- package/python/tests/test_materialize_hosts.py +108 -0
- package/python/tests/test_mcp_contract.py +1 -1
- package/python/tests/test_mcp_initialize_clientinfo.py +26 -0
- package/python/tests/test_mcp_operator.py +84 -0
- package/python/tests/test_npm_pkg_version.py +21 -0
- package/python/tests/test_pick_diversify.py +47 -0
- package/python/tests/test_replay_cli.py +31 -0
- package/python/tests/test_route_cli_pick.py +25 -0
- package/python/tests/test_route_policies.py +29 -0
- package/python/tests/test_route_quality.py +72 -0
- package/python/tests/test_router_llm.py +63 -0
- package/python/tests/test_router_mode_env.py +21 -0
- package/python/tests/test_routing_signals.py +20 -0
- package/python/tests/test_skill_manifest.py +48 -0
- 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
|
package/python/app/health_cli.py
CHANGED
|
@@ -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)
|