@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.
- package/CHANGELOG.md +53 -0
- package/CONTRIBUTING.md +5 -3
- package/README.md +37 -345
- package/RELEASING.md +8 -7
- 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,145 @@
|
|
|
1
|
+
"""Replay SQLite events chronologically for a session or window (CLI debugging).
|
|
2
|
+
|
|
3
|
+
Not a deterministic 're-invoke routing' sandbox — timelines are reconstructed from persisted events."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import sqlite3
|
|
10
|
+
|
|
11
|
+
from app.db_paths import resolve_orchestrator_db
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _replay_rows(
|
|
15
|
+
con: sqlite3.Connection,
|
|
16
|
+
*,
|
|
17
|
+
session_id: str | None,
|
|
18
|
+
user_id: str,
|
|
19
|
+
newest_first_snapshot: bool,
|
|
20
|
+
limit: int,
|
|
21
|
+
) -> list[tuple[float, str | None, str | None, str | None]]:
|
|
22
|
+
uid = (user_id or "").strip()
|
|
23
|
+
lim = max(1, min(int(limit), 5000))
|
|
24
|
+
|
|
25
|
+
cur: sqlite3.Cursor
|
|
26
|
+
if session_id and session_id.strip():
|
|
27
|
+
sid = session_id.strip()
|
|
28
|
+
cur = con.execute(
|
|
29
|
+
"""
|
|
30
|
+
SELECT ts, session_id, event_type, payload
|
|
31
|
+
FROM events
|
|
32
|
+
WHERE user_id = ? AND session_id = ?
|
|
33
|
+
ORDER BY ts ASC
|
|
34
|
+
LIMIT ?
|
|
35
|
+
""",
|
|
36
|
+
(uid, sid, lim),
|
|
37
|
+
)
|
|
38
|
+
else:
|
|
39
|
+
order = "DESC" if newest_first_snapshot else "ASC"
|
|
40
|
+
cur = con.execute(
|
|
41
|
+
f"""
|
|
42
|
+
SELECT ts, session_id, event_type, payload
|
|
43
|
+
FROM events
|
|
44
|
+
WHERE user_id = ?
|
|
45
|
+
ORDER BY ts {order}
|
|
46
|
+
LIMIT ?
|
|
47
|
+
""",
|
|
48
|
+
(uid, lim),
|
|
49
|
+
)
|
|
50
|
+
rows = [(float(ts), sid, et, payload) for ts, sid, et, payload in cur.fetchall()]
|
|
51
|
+
return rows
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_human(ts: float, sid: str | None, et: str | None, payload_raw: str | None) -> str:
|
|
55
|
+
payload: dict[str, object] | str = {}
|
|
56
|
+
if payload_raw:
|
|
57
|
+
try:
|
|
58
|
+
payload = json.loads(payload_raw)
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
payload = payload_raw
|
|
61
|
+
sid_short = ((sid or "-")[:12]) if sid else "-"
|
|
62
|
+
bullet = f"- ts={ts:.3f} session={sid_short} event={et or '?'} "
|
|
63
|
+
if et == "route" and isinstance(payload, dict):
|
|
64
|
+
picked = payload.get("picked_names") or payload.get("picked") or ""
|
|
65
|
+
bullet += f" picked={picked!r}"
|
|
66
|
+
if payload.get("host_pick_shortlist"):
|
|
67
|
+
bullet += " [shortlist]"
|
|
68
|
+
elif isinstance(payload, dict):
|
|
69
|
+
js = json.dumps(payload, ensure_ascii=False)
|
|
70
|
+
bullet += js[:260]
|
|
71
|
+
if len(js) > 260:
|
|
72
|
+
bullet += "…"
|
|
73
|
+
else:
|
|
74
|
+
bullet += str(payload)[:260]
|
|
75
|
+
return bullet
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
ap = argparse.ArgumentParser(description="Replay orchestrator SQLite events as a chronological timeline.")
|
|
80
|
+
ap.add_argument(
|
|
81
|
+
"--project-root",
|
|
82
|
+
default="",
|
|
83
|
+
help="Workspace root (uses <root>/.skillforge/orchestrator.db)",
|
|
84
|
+
)
|
|
85
|
+
ap.add_argument("--session-id", default="", help="If set, only events for this session_id (chrono ASC)")
|
|
86
|
+
ap.add_argument(
|
|
87
|
+
"--user",
|
|
88
|
+
default="",
|
|
89
|
+
metavar="USER_ID",
|
|
90
|
+
help="Logical MCP user namespace (matches SKILLFORGE_MCP_USER_ID)",
|
|
91
|
+
)
|
|
92
|
+
ap.add_argument(
|
|
93
|
+
"--limit",
|
|
94
|
+
type=int,
|
|
95
|
+
default=200,
|
|
96
|
+
help="Max rows (default 200, max 5000). Per-session replay uses chronological ASC.",
|
|
97
|
+
)
|
|
98
|
+
ap.add_argument(
|
|
99
|
+
"--newest-first",
|
|
100
|
+
action="store_true",
|
|
101
|
+
help="Without --session-id, take the newest slice (DESC) instead of oldest ASC snapshot",
|
|
102
|
+
)
|
|
103
|
+
ap.add_argument(
|
|
104
|
+
"--json",
|
|
105
|
+
action="store_true",
|
|
106
|
+
help="Emit JSON array [{ts,session_id,event_type,payload}, …]",
|
|
107
|
+
)
|
|
108
|
+
args = ap.parse_args()
|
|
109
|
+
|
|
110
|
+
db_path = resolve_orchestrator_db((args.project_root or "").strip() or None)
|
|
111
|
+
if not db_path.exists():
|
|
112
|
+
print(f"No database yet: {db_path}")
|
|
113
|
+
raise SystemExit(1)
|
|
114
|
+
|
|
115
|
+
con = sqlite3.connect(str(db_path))
|
|
116
|
+
sess = (args.session_id or "").strip() or None
|
|
117
|
+
rows_raw = _replay_rows(
|
|
118
|
+
con,
|
|
119
|
+
session_id=sess,
|
|
120
|
+
user_id=args.user,
|
|
121
|
+
newest_first_snapshot=bool(args.newest_first),
|
|
122
|
+
limit=args.limit,
|
|
123
|
+
)
|
|
124
|
+
con.close()
|
|
125
|
+
|
|
126
|
+
if args.json:
|
|
127
|
+
out: list[dict[str, object]] = []
|
|
128
|
+
for ts, sid, et, pay in rows_raw:
|
|
129
|
+
try:
|
|
130
|
+
parsed: object = json.loads(pay) if pay else {}
|
|
131
|
+
except json.JSONDecodeError:
|
|
132
|
+
parsed = pay or ""
|
|
133
|
+
out.append({"ts": ts, "session_id": sid, "event_type": et, "payload": parsed})
|
|
134
|
+
print(json.dumps(out, indent=2))
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
print(f"# skillforge replay db={db_path}")
|
|
138
|
+
sess_display = sess if sess else "(all sessions)"
|
|
139
|
+
print(f"# user_id={args.user!r} session_filter={sess_display!r} rows={len(rows_raw)}")
|
|
140
|
+
for ts, sid, et, pay in rows_raw:
|
|
141
|
+
print(_format_human(ts, sid, et, pay))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
package/python/app/route_cli.py
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
"""Terminal routing — same pipeline as MCP ``route_skills`` (
|
|
1
|
+
"""Terminal routing — same pipeline as MCP ``route_skills`` (scripting + interactive host-pick)."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import sys
|
|
8
9
|
import time
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
from app.db_paths import resolve_orchestrator_db
|
|
13
|
+
from app.explain_route import compute_explain_route
|
|
12
14
|
from app.main import (
|
|
15
|
+
TOP_K_CANDIDATES,
|
|
16
|
+
SKILLFORGE_ROUTER_MODE,
|
|
13
17
|
build_router_and_skills,
|
|
14
18
|
format_context_items_markdown,
|
|
15
19
|
init_db,
|
|
@@ -17,137 +21,296 @@ from app.main import (
|
|
|
17
21
|
)
|
|
18
22
|
from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION, build_route_skills_meta
|
|
19
23
|
from app.redaction import redaction_enabled, redact_display_path
|
|
24
|
+
from app.route_cli_pick import parse_interactive_skill_pick
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _truthy_route_env(name: str, default: str = "0") -> bool:
|
|
28
|
+
return os.getenv(name, default).strip().lower() not in ("0", "false", "no", "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _interactive_enabled(args: argparse.Namespace, *, stdin_tty: bool) -> bool:
|
|
32
|
+
if args.no_interactive:
|
|
33
|
+
return False
|
|
34
|
+
if args.interactive:
|
|
35
|
+
return stdin_tty
|
|
36
|
+
return _truthy_route_env("SKILLFORGE_ROUTE_INTERACTIVE") and stdin_tty
|
|
20
37
|
|
|
21
38
|
|
|
22
39
|
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
23
|
-
p = argparse.ArgumentParser(description="Route a prompt through Skillforge (
|
|
40
|
+
p = argparse.ArgumentParser(description="Route a prompt through Skillforge (parity with MCP route_skills).")
|
|
41
|
+
p.add_argument("prompt_parts", nargs="*", help="Prompt (positional words, joined). Or use --prompt.")
|
|
42
|
+
p.add_argument("--prompt", "-p", default="", help="Alternative prompt string.")
|
|
43
|
+
p.add_argument("--project-root", default="", help="Workspace root → project .skillforge/orchestrator.db.")
|
|
44
|
+
p.add_argument("--session-id", default="", help="Stable session id.")
|
|
45
|
+
p.add_argument("--user-id", default="", help="Logical MCP user scope (weights/events).")
|
|
24
46
|
p.add_argument(
|
|
25
|
-
"
|
|
26
|
-
nargs="*",
|
|
27
|
-
help="Prompt text (whitespace-joined). Prefer this or use --prompt.",
|
|
28
|
-
)
|
|
29
|
-
p.add_argument("--prompt", "-p", default="", help="Prompt string (alternative to positional words).")
|
|
30
|
-
p.add_argument(
|
|
31
|
-
"--project-root",
|
|
47
|
+
"--picked-names",
|
|
32
48
|
default="",
|
|
33
|
-
help="
|
|
49
|
+
help="Comma-separated catalog ids — host finalize (parity with MCP picked_names).",
|
|
34
50
|
)
|
|
35
|
-
p.add_argument("--
|
|
36
|
-
p.add_argument("--
|
|
51
|
+
p.add_argument("--json-meta", action="store_true", help="Print route_meta JSON on stderr after stdout (legacy).")
|
|
52
|
+
p.add_argument("--json", action="store_true", help="One JSON envelope on stdout (scripting-friendly).")
|
|
37
53
|
p.add_argument(
|
|
38
|
-
"--
|
|
39
|
-
|
|
40
|
-
help="
|
|
54
|
+
"--explain",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Diagnostics: embedding shortlist + router hypothesis (explain_route pipeline). ",
|
|
41
57
|
)
|
|
42
|
-
p.add_argument("--json-meta", action="store_true", help="Print routing metadata as JSON on stderr after output.")
|
|
43
58
|
p.add_argument(
|
|
44
|
-
"--
|
|
59
|
+
"--explain-only",
|
|
45
60
|
action="store_true",
|
|
46
|
-
help="
|
|
61
|
+
help="Only diagnostics — no finalize (ignores picked-names). ",
|
|
47
62
|
)
|
|
63
|
+
p.add_argument("--explain-limit", type=int, default=0, metavar="N", help="explain shortlist width (≤50). ")
|
|
64
|
+
p.add_argument("-i", "--interactive", action="store_true", help="TTY prompt after host-mode shortlist. ")
|
|
65
|
+
p.add_argument("--no-interactive", action="store_true", help="Disable env-driven auto prompts. ")
|
|
66
|
+
p.add_argument("--quiet", action="store_true", help="Less stderr chatter during bootstrap. ")
|
|
67
|
+
p.add_argument("--include-project-rag", action="store_true", help="Needs --project-root. ")
|
|
48
68
|
return p.parse_args(argv)
|
|
49
69
|
|
|
50
70
|
|
|
71
|
+
def _markdown_route(result: dict, *, sid: str, db_disp: str, router) -> str:
|
|
72
|
+
picked_names = result["picked_names"]
|
|
73
|
+
reasoning = result["reasoning"]
|
|
74
|
+
context_items = result.get("context_items") or []
|
|
75
|
+
if result.get("host_pick_shortlist"):
|
|
76
|
+
return (
|
|
77
|
+
(result.get("host_pick_markdown") or "").strip() + f"\n\n---\n_session_id:_ `{sid}` · _DB:_ `{db_disp}`"
|
|
78
|
+
).strip()
|
|
79
|
+
blocks = [
|
|
80
|
+
f"# Skillforge — routed {len(picked_names)} skill(s); context=`{router.context_mode}`",
|
|
81
|
+
f"_DB:_ `{db_disp}`",
|
|
82
|
+
f"_Reasoning: {reasoning}_" if reasoning else "",
|
|
83
|
+
"",
|
|
84
|
+
]
|
|
85
|
+
if context_items:
|
|
86
|
+
blocks.append(format_context_items_markdown(context_items))
|
|
87
|
+
elif not picked_names:
|
|
88
|
+
blocks.append("_No skills matched this prompt closely enough to load._")
|
|
89
|
+
return "\n".join(b for b in blocks if b is not None)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _meta(result: dict, *, md_out: str, user_id: str, db_path, skills_map) -> dict:
|
|
93
|
+
m = build_route_skills_meta(
|
|
94
|
+
result=result,
|
|
95
|
+
picked_names=list(result["picked_names"]),
|
|
96
|
+
user_id=user_id,
|
|
97
|
+
db_path=db_path,
|
|
98
|
+
skills_map=skills_map,
|
|
99
|
+
response_text=md_out,
|
|
100
|
+
context_items=result.get("context_items"),
|
|
101
|
+
fusion=(result.get("event") or {}).get("context_fusion"),
|
|
102
|
+
context_redaction=(result.get("event") or {}).get("context_redaction"),
|
|
103
|
+
)
|
|
104
|
+
if result.get("host_pick_shortlist"):
|
|
105
|
+
m["host_pick_shortlist"] = True
|
|
106
|
+
m["host_pick_candidates"] = result.get("host_pick_candidates") or []
|
|
107
|
+
return m
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _explain_lim(v: int) -> int:
|
|
111
|
+
lim = TOP_K_CANDIDATES if v <= 0 else v
|
|
112
|
+
return max(1, min(int(lim), 50))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _write_last_route(rr: dict, *, pr: str | None, user_id: str, router, picks_via_interactive: list[str]) -> None:
|
|
116
|
+
if not pr:
|
|
117
|
+
return
|
|
118
|
+
try:
|
|
119
|
+
root = Path(pr).expanduser().resolve()
|
|
120
|
+
(root / ".skillforge").mkdir(parents=True, exist_ok=True)
|
|
121
|
+
snap = {
|
|
122
|
+
"ts": time.time(),
|
|
123
|
+
"session_id": rr["session_id"],
|
|
124
|
+
"picked": rr["picked_names"],
|
|
125
|
+
"reasoning": rr["reasoning"],
|
|
126
|
+
"route_ms": round(rr["route_ms"], 1),
|
|
127
|
+
"user_id": user_id,
|
|
128
|
+
"source": "cli_route",
|
|
129
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
130
|
+
"context_mode": router.context_mode,
|
|
131
|
+
"context_items_count": len(rr.get("context_items") or []),
|
|
132
|
+
"project_rag_items_count": (rr.get("event") or {}).get("project_rag_items_count", 0),
|
|
133
|
+
"host_pick_shortlist": bool(rr.get("host_pick_shortlist")),
|
|
134
|
+
"picked_via_interactive": picks_via_interactive,
|
|
135
|
+
}
|
|
136
|
+
(root / ".skillforge" / "last_route.json").write_text(json.dumps(snap, indent=2), encoding="utf-8")
|
|
137
|
+
except OSError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
51
141
|
async def _run(args: argparse.Namespace) -> int:
|
|
52
142
|
text = " ".join(args.prompt_parts).strip() or args.prompt.strip()
|
|
53
143
|
if not text:
|
|
54
|
-
print("skillforge route: provide a prompt
|
|
144
|
+
print("skillforge route: provide a prompt.", file=sys.stderr)
|
|
55
145
|
return 2
|
|
56
146
|
|
|
147
|
+
stdin_tty = sys.stdin.isatty()
|
|
57
148
|
pr = (args.project_root or "").strip() or None
|
|
149
|
+
explain_only = bool(args.explain_only)
|
|
150
|
+
picked_ini = "" if explain_only else (args.picked_names or "").strip()
|
|
151
|
+
picks_ini = [x.strip() for x in picked_ini.split(",") if x.strip()] if picked_ini else []
|
|
152
|
+
cli_picked = picked_ini != ""
|
|
153
|
+
|
|
58
154
|
if args.include_project_rag and not pr:
|
|
59
155
|
print("skillforge route: --include-project-rag requires --project-root.", file=sys.stderr)
|
|
60
156
|
return 2
|
|
157
|
+
|
|
158
|
+
tty_interactive = (
|
|
159
|
+
not cli_picked
|
|
160
|
+
and SKILLFORGE_ROUTER_MODE == "host"
|
|
161
|
+
and stdin_tty
|
|
162
|
+
and _interactive_enabled(args, stdin_tty=stdin_tty)
|
|
163
|
+
)
|
|
164
|
+
|
|
61
165
|
db_path = resolve_orchestrator_db(pr)
|
|
62
166
|
con = init_db(db_path)
|
|
63
167
|
db_disp = redact_display_path(db_path) if redaction_enabled() else str(db_path)
|
|
64
|
-
|
|
65
|
-
router, skills = await asyncio.to_thread(build_router_and_skills, log=True, log_prefix="[skillforge-route]")
|
|
66
|
-
session_id = args.session_id.strip() or None
|
|
67
168
|
user_id = args.user_id.strip()
|
|
169
|
+
session_id_arg = args.session_id.strip() or None
|
|
68
170
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
171
|
+
explain_md = None
|
|
172
|
+
explain_meta = None
|
|
173
|
+
want_diag = bool(args.explain or explain_only)
|
|
72
174
|
|
|
73
175
|
try:
|
|
176
|
+
router, skills_map = await asyncio.to_thread(
|
|
177
|
+
build_router_and_skills, log=(not args.quiet), log_prefix="[skillforge-route]"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if want_diag:
|
|
181
|
+
explain_md, explain_meta = await compute_explain_route(
|
|
182
|
+
router,
|
|
183
|
+
con,
|
|
184
|
+
prompt=text,
|
|
185
|
+
conversation=[],
|
|
186
|
+
limit=_explain_lim(int(args.explain_limit)),
|
|
187
|
+
user_id=user_id,
|
|
188
|
+
project_root=pr,
|
|
189
|
+
db_path=db_path,
|
|
190
|
+
)
|
|
191
|
+
if explain_only:
|
|
192
|
+
if args.json:
|
|
193
|
+
print(
|
|
194
|
+
json.dumps({
|
|
195
|
+
"tool": "skillforge-route",
|
|
196
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
197
|
+
"phase": "explain_only",
|
|
198
|
+
"explain": explain_meta,
|
|
199
|
+
"explain_markdown": explain_md or "",
|
|
200
|
+
"orchestrator_db": db_disp,
|
|
201
|
+
"prompt": text,
|
|
202
|
+
}, indent=2),
|
|
203
|
+
flush=True,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
print(explain_md or "", flush=True)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
picks_interactive_track: list[str] = []
|
|
210
|
+
|
|
74
211
|
result = await run_route_turn(
|
|
75
212
|
con,
|
|
76
213
|
router,
|
|
77
214
|
text,
|
|
78
215
|
conversation=[],
|
|
79
216
|
user_id=user_id,
|
|
80
|
-
session_id=
|
|
217
|
+
session_id=session_id_arg,
|
|
81
218
|
project_root=pr,
|
|
82
219
|
include_project_rag=bool(args.include_project_rag),
|
|
83
|
-
picked_names_from_host=
|
|
84
|
-
picked_names_from_host_supplied=
|
|
220
|
+
picked_names_from_host=picks_ini if cli_picked else None,
|
|
221
|
+
picked_names_from_host_supplied=cli_picked,
|
|
85
222
|
)
|
|
86
|
-
finally:
|
|
87
|
-
con.close()
|
|
88
223
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
224
|
+
if tty_interactive and result.get("host_pick_shortlist"):
|
|
225
|
+
sid = result["session_id"]
|
|
226
|
+
rows = result.get("host_pick_candidates") or []
|
|
227
|
+
md0 = _markdown_route(result, sid=sid, db_disp=db_disp, router=router)
|
|
228
|
+
meta0 = _meta(result, md_out=md0, user_id=user_id, db_path=db_path, skills_map=skills_map)
|
|
229
|
+
hint = "(ranks 1,3… or ids; empty skips finalize)"
|
|
230
|
+
if args.json:
|
|
231
|
+
print(
|
|
232
|
+
json.dumps({
|
|
233
|
+
"tool": "skillforge-route",
|
|
234
|
+
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
235
|
+
"phase": "host_shortlist_prompt",
|
|
236
|
+
"prompt": text,
|
|
237
|
+
"session_id": sid,
|
|
238
|
+
"route_meta": meta0,
|
|
239
|
+
"route_markdown": md0,
|
|
240
|
+
"orchestrator_db": db_disp,
|
|
241
|
+
**({"explain": explain_meta} if explain_meta else {}),
|
|
242
|
+
}, indent=2),
|
|
243
|
+
flush=True,
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
print(md0, flush=True)
|
|
247
|
+
if explain_md:
|
|
248
|
+
print(f"\n---\n{explain_md}\n", file=sys.stderr)
|
|
93
249
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
250
|
+
ip = ""
|
|
251
|
+
try:
|
|
252
|
+
ip = input("" if args.quiet else f"skillforge route {hint}: ")
|
|
253
|
+
except EOFError:
|
|
254
|
+
ip = ""
|
|
255
|
+
chosen = parse_interactive_skill_pick(ip, rows)
|
|
256
|
+
|
|
257
|
+
if not chosen:
|
|
258
|
+
if args.json_meta:
|
|
259
|
+
print(json.dumps(meta0, indent=2), file=sys.stderr)
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
picks_interactive_track = chosen
|
|
263
|
+
result = await run_route_turn(
|
|
264
|
+
con,
|
|
265
|
+
router,
|
|
266
|
+
text,
|
|
267
|
+
conversation=[],
|
|
268
|
+
user_id=user_id,
|
|
269
|
+
session_id=sid,
|
|
270
|
+
project_root=pr,
|
|
271
|
+
include_project_rag=bool(args.include_project_rag),
|
|
272
|
+
picked_names_from_host=chosen,
|
|
273
|
+
picked_names_from_host_supplied=True,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
sid = result["session_id"]
|
|
277
|
+
md_final = _markdown_route(result, sid=sid, db_disp=db_disp, router=router)
|
|
278
|
+
mf = _meta(result, md_out=md_final, user_id=user_id, db_path=db_path, skills_map=skills_map)
|
|
279
|
+
phase = (
|
|
280
|
+
"host_shortlist_static"
|
|
281
|
+
if result.get("host_pick_shortlist")
|
|
282
|
+
else "context"
|
|
283
|
+
)
|
|
284
|
+
_write_last_route(result, pr=pr, user_id=user_id, router=router, picks_via_interactive=picks_interactive_track)
|
|
285
|
+
|
|
286
|
+
if args.json:
|
|
287
|
+
env = {
|
|
288
|
+
"tool": "skillforge-route",
|
|
106
289
|
"schema_version": MCP_RESPONSE_SCHEMA_VERSION,
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
"
|
|
290
|
+
"phase": phase,
|
|
291
|
+
"prompt": text,
|
|
292
|
+
"session_id": sid,
|
|
293
|
+
"route_meta": mf,
|
|
294
|
+
"route_markdown": md_final,
|
|
295
|
+
"orchestrator_db": db_disp,
|
|
111
296
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
297
|
+
if picks_interactive_track:
|
|
298
|
+
env["picked_via_interactive"] = picks_interactive_track
|
|
299
|
+
if explain_meta:
|
|
300
|
+
env["explain"] = explain_meta
|
|
301
|
+
print(json.dumps(env, indent=2), flush=True)
|
|
302
|
+
else:
|
|
303
|
+
print(md_final, flush=True)
|
|
304
|
+
if args.explain and explain_md:
|
|
305
|
+
print(f"\n---\n{explain_md}\n", file=sys.stderr)
|
|
115
306
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
blocks = [
|
|
121
|
-
f"# Skillforge — routed {len(picked_names)} skill(s); context=`{router.context_mode}`",
|
|
122
|
-
f"_DB:_ `{db_disp}`",
|
|
123
|
-
f"_Reasoning: {reasoning}_" if reasoning else "",
|
|
124
|
-
"",
|
|
125
|
-
]
|
|
126
|
-
if context_items:
|
|
127
|
-
blocks.append(format_context_items_markdown(context_items))
|
|
128
|
-
elif not picked_names:
|
|
129
|
-
blocks.append("_No skills matched this prompt closely enough to load._")
|
|
130
|
-
response_text = "\n".join(b for b in blocks if b is not None)
|
|
131
|
-
print(response_text)
|
|
132
|
-
|
|
133
|
-
if args.json_meta:
|
|
134
|
-
meta = build_route_skills_meta(
|
|
135
|
-
result=result,
|
|
136
|
-
picked_names=picked_names,
|
|
137
|
-
user_id=user_id,
|
|
138
|
-
db_path=db_path,
|
|
139
|
-
skills_map=skills,
|
|
140
|
-
response_text=response_text,
|
|
141
|
-
context_items=context_items,
|
|
142
|
-
fusion=(result.get("event") or {}).get("context_fusion"),
|
|
143
|
-
context_redaction=(result.get("event") or {}).get("context_redaction"),
|
|
144
|
-
)
|
|
145
|
-
if result.get("host_pick_shortlist"):
|
|
146
|
-
meta["host_pick_shortlist"] = True
|
|
147
|
-
meta["host_pick_candidates"] = result.get("host_pick_candidates") or []
|
|
148
|
-
print(json.dumps(meta, indent=2), file=sys.stderr)
|
|
307
|
+
if args.json_meta:
|
|
308
|
+
print(json.dumps(mf, indent=2), file=sys.stderr)
|
|
309
|
+
|
|
310
|
+
return 0
|
|
149
311
|
|
|
150
|
-
|
|
312
|
+
finally:
|
|
313
|
+
con.close()
|
|
151
314
|
|
|
152
315
|
|
|
153
316
|
def main(argv: list[str] | None = None) -> None:
|
|
@@ -157,3 +320,4 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
157
320
|
|
|
158
321
|
if __name__ == "__main__":
|
|
159
322
|
main()
|
|
323
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Parse interactive host-pick tokens (numbers ↔ skill ids)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_interactive_skill_pick(line: str, host_pick_rows: list[dict[str, Any]]) -> list[str]:
|
|
8
|
+
"""Map user input (`1`, `2,3`, `foo-skill`) onto catalog ids using ``rank`` from rows."""
|
|
9
|
+
raw = (line or "").strip()
|
|
10
|
+
if not raw or raw.lower() in ("q", "quit", "exit"):
|
|
11
|
+
return []
|
|
12
|
+
seen: dict[str, None] = {}
|
|
13
|
+
by_rank: dict[int, str] = {}
|
|
14
|
+
for r in host_pick_rows:
|
|
15
|
+
rk = r.get("rank")
|
|
16
|
+
nid = str(r.get("name") or r.get("id") or "").strip()
|
|
17
|
+
if rk is None or not nid:
|
|
18
|
+
continue
|
|
19
|
+
try:
|
|
20
|
+
ir = int(rk)
|
|
21
|
+
except (TypeError, ValueError):
|
|
22
|
+
continue
|
|
23
|
+
by_rank[ir] = nid
|
|
24
|
+
|
|
25
|
+
for part in raw.replace(";", ",").split(","):
|
|
26
|
+
chunk = part.strip().strip("`").strip()
|
|
27
|
+
if not chunk:
|
|
28
|
+
continue
|
|
29
|
+
if chunk.isdigit():
|
|
30
|
+
name = by_rank.get(int(chunk))
|
|
31
|
+
if name:
|
|
32
|
+
seen.setdefault(name, None)
|
|
33
|
+
else:
|
|
34
|
+
seen.setdefault(chunk, None)
|
|
35
|
+
return list(seen.keys())
|
|
@@ -39,6 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
import json
|
|
40
40
|
import os
|
|
41
41
|
import re
|
|
42
|
+
import sys
|
|
42
43
|
import sqlite3
|
|
43
44
|
from pathlib import Path
|
|
44
45
|
from typing import Any
|
|
@@ -51,7 +52,12 @@ def load_route_policies_config(project_root: str | None) -> dict[str, Any]:
|
|
|
51
52
|
try:
|
|
52
53
|
data = json.loads(raw_env)
|
|
53
54
|
return data if isinstance(data, dict) else {"rules": []}
|
|
54
|
-
except json.JSONDecodeError:
|
|
55
|
+
except json.JSONDecodeError as exc:
|
|
56
|
+
print(
|
|
57
|
+
"[skillforge] SKILLFORGE_ROUTE_POLICIES is set but invalid JSON — policies ignored.",
|
|
58
|
+
str(exc),
|
|
59
|
+
file=sys.stderr,
|
|
60
|
+
)
|
|
55
61
|
return {"rules": []}
|
|
56
62
|
|
|
57
63
|
paths: list[Path] = []
|
|
@@ -66,9 +72,18 @@ def load_route_policies_config(project_root: str | None) -> dict[str, Any]:
|
|
|
66
72
|
for p in paths:
|
|
67
73
|
if p.is_file():
|
|
68
74
|
try:
|
|
69
|
-
|
|
75
|
+
raw = p.read_text(encoding="utf-8")
|
|
76
|
+
except OSError:
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(raw)
|
|
70
80
|
return data if isinstance(data, dict) else {"rules": []}
|
|
71
|
-
except
|
|
81
|
+
except json.JSONDecodeError as exc:
|
|
82
|
+
print(
|
|
83
|
+
f"[skillforge] invalid JSON in route policies file — skipping {p}:",
|
|
84
|
+
str(exc),
|
|
85
|
+
file=sys.stderr,
|
|
86
|
+
)
|
|
72
87
|
continue
|
|
73
88
|
return {"rules": []}
|
|
74
89
|
|