@heytherevibin/skillforge 0.7.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +30 -19
- package/README.md +248 -198
- package/RELEASING.md +19 -7
- package/SECURITY.md +61 -13
- package/STRATEGY.md +40 -14
- package/bin/cli.js +112 -5
- package/ci/bundle-gate.json +4 -0
- package/lib/host-setup.js +312 -0
- package/lib/templates/claude-code-skillforge-global.md +19 -0
- package/lib/templates/cursor-skillforge-global.md +16 -0
- package/package.json +3 -2
- package/python/app/eval_cli.py +133 -0
- package/python/app/feedback_meta.py +96 -0
- package/python/app/health_cli.py +160 -0
- package/python/app/main.py +502 -26
- package/python/app/materialize.py +72 -4
- package/python/app/mcp_contract.py +13 -1
- package/python/app/mcp_server.py +344 -25
- package/python/app/route_cli.py +32 -13
- package/python/app/route_eval_harness.py +98 -0
- package/python/app/route_policies.py +243 -0
- package/python/app/route_quality.py +99 -0
- package/python/app/routing_signals.py +155 -0
- package/python/app/weights_cli.py +152 -0
- package/python/fixtures/route_eval/smoke.json +18 -0
- package/python/requirements.txt +1 -0
- package/python/tests/test_feedback_weights.py +77 -0
- package/python/tests/test_materialize.py +51 -0
- package/python/tests/test_mcp_contract.py +117 -0
- package/python/tests/test_route_eval_harness.py +45 -0
- package/python/tests/test_route_policies.py +115 -0
- package/python/tests/test_route_quality.py +120 -0
- package/python/tests/test_routing_overlay.py +55 -0
- package/python/tests/test_routing_signals.py +112 -0
package/python/app/main.py
CHANGED
|
@@ -8,7 +8,6 @@ Live usage: `skillforge events --watch` (terminal).
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
import asyncio
|
|
12
11
|
import json
|
|
13
12
|
import os
|
|
14
13
|
import sqlite3
|
|
@@ -33,6 +32,23 @@ from app.project_index import (
|
|
|
33
32
|
retrieve_project_context_items,
|
|
34
33
|
)
|
|
35
34
|
from app.redaction import redaction_enabled, redact_secret_patterns, sanitize_context_items
|
|
35
|
+
from app.feedback_meta import build_feedback_effect
|
|
36
|
+
from app.route_policies import (
|
|
37
|
+
build_routing_overlay_payload,
|
|
38
|
+
load_route_policies_config,
|
|
39
|
+
merge_policy_includes,
|
|
40
|
+
merge_project_notes_into_route_query,
|
|
41
|
+
parse_routing_overlay,
|
|
42
|
+
)
|
|
43
|
+
from app.route_quality import build_route_quality, coerce_route_float
|
|
44
|
+
from app.routing_signals import (
|
|
45
|
+
build_route_query_text,
|
|
46
|
+
host_pick_shortlist_lines,
|
|
47
|
+
keyword_overlap_scores,
|
|
48
|
+
normalize_minmax,
|
|
49
|
+
skill_routing_card,
|
|
50
|
+
tokenize_skills_query,
|
|
51
|
+
)
|
|
36
52
|
|
|
37
53
|
# ---------- Config (env-driven so the Node wrapper controls paths) ----------
|
|
38
54
|
BUNDLED_SKILLS = Path(os.getenv("SKILLFORGE_BUNDLED_SKILLS", "./skills"))
|
|
@@ -47,7 +63,7 @@ ROUTER_MODEL = os.getenv("SKILLFORGE_ROUTER_MODEL", "claude-haiku-4-5-20251001")
|
|
|
47
63
|
TOP_K_CANDIDATES = int(os.getenv("SKILLFORGE_TOP_K", "15"))
|
|
48
64
|
MAX_ACTIVE_SKILLS = int(os.getenv("SKILLFORGE_MAX_ACTIVE", "7"))
|
|
49
65
|
REROUTE_THRESHOLD = float(os.getenv("SKILLFORGE_REROUTE_THRESHOLD", "0.4"))
|
|
50
|
-
# "" | "full" | "embedding" — embedding skips Haiku
|
|
66
|
+
# "" | "full" | "embedding" | "host" — embedding skips Haiku; host skips in-process pick (MCP must pass picked_names).
|
|
51
67
|
SKILLFORGE_ROUTER_MODE = os.getenv("SKILLFORGE_ROUTER_MODE", "").strip().lower()
|
|
52
68
|
# chunks: RAG-style line-bounded chunks from picked skills. full_body: inject entire SKILL.md per pick (legacy).
|
|
53
69
|
SKILLFORGE_CONTEXT_MODE = os.getenv("SKILLFORGE_CONTEXT_MODE", "chunks").strip().lower()
|
|
@@ -60,6 +76,21 @@ FUSION_FULL_BODY_PREVIEW_CHARS = max(400, int(os.getenv("SKILLFORGE_FUSION_FULL_
|
|
|
60
76
|
CONTEXT_OVERHEAD_SKILL = 48
|
|
61
77
|
CONTEXT_OVERHEAD_FILE = 56
|
|
62
78
|
|
|
79
|
+
ROUTER_HYBRID_MODE = os.getenv("SKILLFORGE_ROUTER_HYBRID", "off").strip().lower()
|
|
80
|
+
ROUTER_HYBRID_ALPHA = max(0.0, min(1.0, float(os.getenv("SKILLFORGE_ROUTER_HYBRID_ALPHA", "0.72"))))
|
|
81
|
+
ROUTER_PROMPT_HISTORY_MSGS = max(1, int(os.getenv("SKILLFORGE_ROUTER_PROMPT_HISTORY_MSGS", "8")))
|
|
82
|
+
ROUTER_PROMPT_HISTORY_CHARS = max(80, int(os.getenv("SKILLFORGE_ROUTER_PROMPT_HISTORY_CHARS", "360")))
|
|
83
|
+
ROUTER_CATALOG_PREVIEW_CHARS = max(80, int(os.getenv("SKILLFORGE_ROUTER_CATALOG_PREVIEW_CHARS", "280")))
|
|
84
|
+
HAIKU_RERANK_MAX = max(3, int(os.getenv("SKILLFORGE_HAIKU_RERANK_MAX", str(TOP_K_CANDIDATES))))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _hybrid_mode_active(mode: str) -> bool:
|
|
88
|
+
return mode not in ("", "off", "0", "false", "no")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _env_truthy(name: str, default: str = "0") -> bool:
|
|
92
|
+
return os.getenv(name, default).strip().lower() not in ("0", "false", "no", "")
|
|
93
|
+
|
|
63
94
|
|
|
64
95
|
def _context_budget_unified() -> int:
|
|
65
96
|
raw = os.getenv("SKILLFORGE_CONTEXT_BUDGET_CHARS", "").strip()
|
|
@@ -83,6 +114,12 @@ def build_router_and_skills(
|
|
|
83
114
|
if mode == "embedding":
|
|
84
115
|
anthropic = None
|
|
85
116
|
router_note = "embedding-only (SKILLFORGE_ROUTER_MODE=embedding)"
|
|
117
|
+
elif mode == "host":
|
|
118
|
+
anthropic = None
|
|
119
|
+
router_note = (
|
|
120
|
+
"host-pick (SKILLFORGE_ROUTER_MODE=host): no in-process router LLM; "
|
|
121
|
+
"first route_skills call returns a shortlist — call again with picked_names"
|
|
122
|
+
)
|
|
86
123
|
elif mode == "full":
|
|
87
124
|
if key:
|
|
88
125
|
anthropic = AsyncAnthropic()
|
|
@@ -123,6 +160,8 @@ class Skill:
|
|
|
123
160
|
source: str # "bundled" | "user"
|
|
124
161
|
disabled: bool = False
|
|
125
162
|
embedding: np.ndarray | None = None
|
|
163
|
+
triggers: str = ""
|
|
164
|
+
anti_triggers: str = ""
|
|
126
165
|
|
|
127
166
|
|
|
128
167
|
def parse_skill_md(path: Path, source: str) -> Skill | None:
|
|
@@ -138,6 +177,8 @@ def parse_skill_md(path: Path, source: str) -> Skill | None:
|
|
|
138
177
|
name = path.parent.name
|
|
139
178
|
title = name.replace("-", " ").title()
|
|
140
179
|
description = ""
|
|
180
|
+
triggers = ""
|
|
181
|
+
anti_triggers = ""
|
|
141
182
|
body = text
|
|
142
183
|
if text.startswith("---"):
|
|
143
184
|
end = text.find("---", 3)
|
|
@@ -167,6 +208,10 @@ def parse_skill_md(path: Path, source: str) -> Skill | None:
|
|
|
167
208
|
title = v
|
|
168
209
|
elif k == "description":
|
|
169
210
|
description = v
|
|
211
|
+
elif k in ("triggers", "trigger"):
|
|
212
|
+
triggers = v
|
|
213
|
+
elif k in ("anti_triggers", "anti-triggers"):
|
|
214
|
+
anti_triggers = v
|
|
170
215
|
i += 1
|
|
171
216
|
if not description:
|
|
172
217
|
for chunk in body.split("\n\n"):
|
|
@@ -174,7 +219,15 @@ def parse_skill_md(path: Path, source: str) -> Skill | None:
|
|
|
174
219
|
if chunk and not chunk.startswith("#"):
|
|
175
220
|
description = chunk[:500]
|
|
176
221
|
break
|
|
177
|
-
return Skill(
|
|
222
|
+
return Skill(
|
|
223
|
+
name=name,
|
|
224
|
+
title=title,
|
|
225
|
+
description=description,
|
|
226
|
+
body=body,
|
|
227
|
+
source=source,
|
|
228
|
+
triggers=triggers,
|
|
229
|
+
anti_triggers=anti_triggers,
|
|
230
|
+
)
|
|
178
231
|
|
|
179
232
|
|
|
180
233
|
def load_all_skills() -> list[Skill]:
|
|
@@ -325,8 +378,26 @@ class Router:
|
|
|
325
378
|
"full_body",
|
|
326
379
|
) else "chunks"
|
|
327
380
|
self._by_name: dict[str, Skill] = {s.name: s for s in skills}
|
|
328
|
-
|
|
329
|
-
|
|
381
|
+
self._hybrid_mode = ROUTER_HYBRID_MODE
|
|
382
|
+
self._hybrid_alpha = ROUTER_HYBRID_ALPHA
|
|
383
|
+
self._routing_cards = [skill_routing_card(s) for s in skills]
|
|
384
|
+
self._bm25 = None
|
|
385
|
+
if self._hybrid_mode == "bm25" and skills:
|
|
386
|
+
try:
|
|
387
|
+
from rank_bm25 import BM25Okapi
|
|
388
|
+
|
|
389
|
+
toks = [tokenize_skills_query(c) for c in self._routing_cards]
|
|
390
|
+
if any(toks):
|
|
391
|
+
self._bm25 = BM25Okapi(toks)
|
|
392
|
+
except ImportError:
|
|
393
|
+
print(
|
|
394
|
+
"[skillforge] SKILLFORGE_ROUTER_HYBRID=bm25 but rank-bm25 is not installed; "
|
|
395
|
+
"using keyword overlap for sparse signal.",
|
|
396
|
+
file=sys.stderr,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
texts = self._routing_cards
|
|
400
|
+
print(f"[skillforge] Embedding {len(skills)} skills (summary cards)...", file=sys.stderr)
|
|
330
401
|
embeddings = embed_model.encode(texts, show_progress_bar=False, convert_to_numpy=True)
|
|
331
402
|
for s, e in zip(skills, embeddings):
|
|
332
403
|
s.embedding = e / np.linalg.norm(e)
|
|
@@ -355,32 +426,138 @@ class Router:
|
|
|
355
426
|
self._chunk_embeddings = ce
|
|
356
427
|
print(
|
|
357
428
|
f"[skillforge] Ready. {len(skills)} skills; chunk matrix {self._chunk_embeddings.shape}; "
|
|
358
|
-
f"context_mode={self.context_mode}",
|
|
429
|
+
f"context_mode={self.context_mode}; router_hybrid={self._hybrid_mode}",
|
|
359
430
|
file=sys.stderr,
|
|
360
431
|
)
|
|
361
432
|
else:
|
|
362
433
|
print(
|
|
363
434
|
f"[skillforge] Ready. {len(skills)} skills, matrix shape: {self.matrix.shape}; "
|
|
364
|
-
f"context_mode={self.context_mode}",
|
|
435
|
+
f"context_mode={self.context_mode}; router_hybrid={self._hybrid_mode}",
|
|
365
436
|
file=sys.stderr,
|
|
366
437
|
)
|
|
367
438
|
|
|
368
|
-
def
|
|
369
|
-
if
|
|
370
|
-
return
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
439
|
+
def _sparse_scores(self, route_query: str) -> np.ndarray:
|
|
440
|
+
if not _hybrid_mode_active(self._hybrid_mode):
|
|
441
|
+
return np.zeros(len(self.skills), dtype=np.float64)
|
|
442
|
+
if self._hybrid_mode == "keyword":
|
|
443
|
+
return keyword_overlap_scores(route_query, self._routing_cards)
|
|
444
|
+
if self._hybrid_mode == "bm25":
|
|
445
|
+
if self._bm25 is not None:
|
|
446
|
+
q = tokenize_skills_query(route_query)
|
|
447
|
+
if not q:
|
|
448
|
+
return np.zeros(len(self.skills), dtype=np.float64)
|
|
449
|
+
return np.asarray(self._bm25.get_scores(q), dtype=np.float64)
|
|
450
|
+
return keyword_overlap_scores(route_query, self._routing_cards)
|
|
451
|
+
return keyword_overlap_scores(route_query, self._routing_cards)
|
|
452
|
+
|
|
453
|
+
def _base_routing_scores(self, route_query: str, q: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
454
|
+
"""Dense cosine similarities and fused ranking scores (or dense-only if hybrid off)."""
|
|
455
|
+
sims = (self.matrix @ q).flatten()
|
|
456
|
+
if not _hybrid_mode_active(self._hybrid_mode):
|
|
457
|
+
return sims, sims
|
|
458
|
+
sparse = self._sparse_scores(route_query)
|
|
459
|
+
d_norm = normalize_minmax(sims)
|
|
460
|
+
s_norm = normalize_minmax(sparse)
|
|
461
|
+
fused = self._hybrid_alpha * d_norm + (1.0 - self._hybrid_alpha) * s_norm
|
|
462
|
+
return sims, fused
|
|
463
|
+
|
|
464
|
+
def _bias_with_learning_and_overlay(
|
|
465
|
+
self,
|
|
466
|
+
con: sqlite3.Connection,
|
|
467
|
+
biased: np.ndarray,
|
|
468
|
+
user_id: str,
|
|
469
|
+
*,
|
|
470
|
+
exclude_skills: frozenset[str] | None = None,
|
|
471
|
+
routing_boosts: dict[str, float] | None = None,
|
|
472
|
+
) -> None:
|
|
473
|
+
excl = exclude_skills or frozenset()
|
|
474
|
+
boosts = routing_boosts or {}
|
|
375
475
|
for i, s in enumerate(self.skills):
|
|
376
476
|
w, disabled = get_skill_weight(con, s.name, user_id=user_id)
|
|
377
|
-
if disabled:
|
|
477
|
+
if disabled or s.name in excl:
|
|
378
478
|
biased[i] = -999.0
|
|
379
479
|
else:
|
|
380
480
|
biased[i] += w
|
|
481
|
+
extra = boosts.get(s.name)
|
|
482
|
+
if extra is not None:
|
|
483
|
+
biased[i] += float(extra)
|
|
484
|
+
|
|
485
|
+
def shortlist(
|
|
486
|
+
self,
|
|
487
|
+
route_query,
|
|
488
|
+
con,
|
|
489
|
+
k=TOP_K_CANDIDATES,
|
|
490
|
+
user_id="",
|
|
491
|
+
*,
|
|
492
|
+
exclude_skills: frozenset[str] | None = None,
|
|
493
|
+
routing_boosts: dict[str, float] | None = None,
|
|
494
|
+
):
|
|
495
|
+
if len(self.skills) == 0:
|
|
496
|
+
return []
|
|
497
|
+
q = self.embed_model.encode(route_query, convert_to_numpy=True)
|
|
498
|
+
q = q / np.linalg.norm(q)
|
|
499
|
+
sims, rank_scores = self._base_routing_scores(route_query, q)
|
|
500
|
+
biased = rank_scores.copy()
|
|
501
|
+
self._bias_with_learning_and_overlay(
|
|
502
|
+
con,
|
|
503
|
+
biased,
|
|
504
|
+
user_id,
|
|
505
|
+
exclude_skills=exclude_skills,
|
|
506
|
+
routing_boosts=routing_boosts,
|
|
507
|
+
)
|
|
381
508
|
top_idx = np.argsort(-biased)[:k]
|
|
382
509
|
return [(self.skills[i], float(sims[i])) for i in top_idx if biased[i] > -100]
|
|
383
510
|
|
|
511
|
+
def shortlist_with_facets(
|
|
512
|
+
self,
|
|
513
|
+
route_query: str,
|
|
514
|
+
con: sqlite3.Connection,
|
|
515
|
+
*,
|
|
516
|
+
k: int | None = None,
|
|
517
|
+
user_id: str = "",
|
|
518
|
+
exclude_skills: frozenset[str] | None = None,
|
|
519
|
+
routing_boosts: dict[str, float] | None = None,
|
|
520
|
+
) -> list[dict[str, Any]]:
|
|
521
|
+
"""Embedding shortlist with cosine sim, learned weight, and routing score (no LLM)."""
|
|
522
|
+
limit = k if k is not None else TOP_K_CANDIDATES
|
|
523
|
+
if len(self.skills) == 0:
|
|
524
|
+
return []
|
|
525
|
+
q = self.embed_model.encode(route_query, convert_to_numpy=True)
|
|
526
|
+
q = q / np.linalg.norm(q)
|
|
527
|
+
sims, rank_scores = self._base_routing_scores(route_query, q)
|
|
528
|
+
sparse_full = (
|
|
529
|
+
self._sparse_scores(route_query) if _hybrid_mode_active(self._hybrid_mode) else np.zeros(
|
|
530
|
+
len(self.skills), dtype=np.float64
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
biased = rank_scores.copy()
|
|
534
|
+
self._bias_with_learning_and_overlay(
|
|
535
|
+
con,
|
|
536
|
+
biased,
|
|
537
|
+
user_id,
|
|
538
|
+
exclude_skills=exclude_skills,
|
|
539
|
+
routing_boosts=routing_boosts,
|
|
540
|
+
)
|
|
541
|
+
top_idx = np.argsort(-biased)[:limit]
|
|
542
|
+
out: list[dict[str, Any]] = []
|
|
543
|
+
for i in top_idx:
|
|
544
|
+
if biased[i] <= -100:
|
|
545
|
+
continue
|
|
546
|
+
s = self.skills[i]
|
|
547
|
+
w, _dis = get_skill_weight(con, s.name, user_id=user_id)
|
|
548
|
+
out.append({
|
|
549
|
+
"name": s.name,
|
|
550
|
+
"title": s.title,
|
|
551
|
+
"description_preview": (s.description or "")[:280],
|
|
552
|
+
"cosine_similarity": round(float(sims[i]), 6),
|
|
553
|
+
"sparse_signal": round(float(sparse_full[i]), 6),
|
|
554
|
+
"learned_weight": round(float(w), 4),
|
|
555
|
+
"routing_score": round(float(biased[i]), 6),
|
|
556
|
+
"source": s.source,
|
|
557
|
+
"router_hybrid": self._hybrid_mode,
|
|
558
|
+
})
|
|
559
|
+
return out
|
|
560
|
+
|
|
384
561
|
def build_context_items(
|
|
385
562
|
self,
|
|
386
563
|
prompt: str,
|
|
@@ -551,6 +728,77 @@ class Router:
|
|
|
551
728
|
rel_out.append(float(rel[i]))
|
|
552
729
|
return items, np.stack(em_rows), np.asarray(rel_out, dtype=np.float32)
|
|
553
730
|
|
|
731
|
+
async def rerank_candidates_haiku(
|
|
732
|
+
self,
|
|
733
|
+
route_query: str,
|
|
734
|
+
conversation: list | None,
|
|
735
|
+
candidates: list[tuple[Skill, float]],
|
|
736
|
+
) -> list[tuple[Skill, float]]:
|
|
737
|
+
if (
|
|
738
|
+
not candidates
|
|
739
|
+
or self.anthropic is None
|
|
740
|
+
or not _env_truthy("SKILLFORGE_HAIKU_RERANK", "0")
|
|
741
|
+
):
|
|
742
|
+
return candidates
|
|
743
|
+
cap = max(3, min(HAIKU_RERANK_MAX, len(candidates)))
|
|
744
|
+
head = candidates[:cap]
|
|
745
|
+
tail = candidates[cap:]
|
|
746
|
+
by_name = {s.name: (s, sc) for s, sc in head}
|
|
747
|
+
lines: list[str] = []
|
|
748
|
+
for idx, (s, _sc) in enumerate(head, start=1):
|
|
749
|
+
card = skill_routing_card(s)
|
|
750
|
+
preview = card[:220].replace("\n", " ")
|
|
751
|
+
lines.append(f"{idx}. {s.name} — {preview}")
|
|
752
|
+
hist = ""
|
|
753
|
+
if conversation:
|
|
754
|
+
msgs = conversation[-ROUTER_PROMPT_HISTORY_MSGS:]
|
|
755
|
+
parts: list[str] = []
|
|
756
|
+
for m in msgs:
|
|
757
|
+
if not isinstance(m, dict):
|
|
758
|
+
continue
|
|
759
|
+
role = str(m.get("role") or "user")
|
|
760
|
+
c = str(m.get("content") or "").strip()
|
|
761
|
+
if not c:
|
|
762
|
+
continue
|
|
763
|
+
parts.append(f"{role}: {c[:ROUTER_PROMPT_HISTORY_CHARS]}")
|
|
764
|
+
if parts:
|
|
765
|
+
hist = "\n\nConversation (recent):\n" + "\n".join(parts)
|
|
766
|
+
sys = (
|
|
767
|
+
"You reorder skill candidates by relevance to the user's task. "
|
|
768
|
+
"Output ONLY JSON: {\"order\": [\"skill_name\", ...]} with each candidate "
|
|
769
|
+
"skill name appearing exactly once, best match first. No extra keys."
|
|
770
|
+
)
|
|
771
|
+
user = (
|
|
772
|
+
f"Routing focus:\n{route_query}{hist}\n\nCandidates:\n" + "\n".join(lines)
|
|
773
|
+
)
|
|
774
|
+
try:
|
|
775
|
+
rerank_model = os.getenv("SKILLFORGE_HAIKU_RERANK_MODEL", "").strip() or ROUTER_MODEL
|
|
776
|
+
resp = await self.anthropic.messages.create(
|
|
777
|
+
model=rerank_model,
|
|
778
|
+
max_tokens=500,
|
|
779
|
+
system=sys,
|
|
780
|
+
messages=[{"role": "user", "content": user}],
|
|
781
|
+
)
|
|
782
|
+
text = resp.content[0].text.strip()
|
|
783
|
+
if text.startswith("```"):
|
|
784
|
+
text = text.split("```")[1]
|
|
785
|
+
if text.startswith("json"):
|
|
786
|
+
text = text[4:]
|
|
787
|
+
data = json.loads(text.strip())
|
|
788
|
+
order = data.get("order") or []
|
|
789
|
+
ordered: list[tuple[Skill, float]] = []
|
|
790
|
+
seen: set[str] = set()
|
|
791
|
+
for n in order:
|
|
792
|
+
if isinstance(n, str) and n in by_name and n not in seen:
|
|
793
|
+
ordered.append(by_name[n])
|
|
794
|
+
seen.add(n)
|
|
795
|
+
for s, sc in head:
|
|
796
|
+
if s.name not in seen:
|
|
797
|
+
ordered.append((s, sc))
|
|
798
|
+
return ordered + tail
|
|
799
|
+
except Exception:
|
|
800
|
+
return candidates
|
|
801
|
+
|
|
554
802
|
def pick_final_embedding_only(self, candidates):
|
|
555
803
|
"""Pick up to MAX_ACTIVE_SKILLS from the shortlist order (similarity + weights). No LLM call."""
|
|
556
804
|
if not candidates:
|
|
@@ -560,26 +808,46 @@ class Router:
|
|
|
560
808
|
"embedding-only: top candidates by similarity and learned weights"
|
|
561
809
|
)
|
|
562
810
|
|
|
563
|
-
async def pick_final(
|
|
811
|
+
async def pick_final(
|
|
812
|
+
self,
|
|
813
|
+
prompt,
|
|
814
|
+
conversation,
|
|
815
|
+
candidates,
|
|
816
|
+
route_query: str | None = None,
|
|
817
|
+
):
|
|
818
|
+
rq = (route_query if route_query is not None else prompt) or ""
|
|
564
819
|
if self.anthropic is None:
|
|
565
820
|
return self.pick_final_embedding_only(candidates)
|
|
566
821
|
if not candidates:
|
|
567
822
|
return [], "no candidates available"
|
|
568
823
|
catalog = "\n".join(
|
|
569
|
-
f"- {s.name}: {s
|
|
824
|
+
f"- {s.name}: {skill_routing_card(s)[:ROUTER_CATALOG_PREVIEW_CHARS]}"
|
|
825
|
+
for s, _ in candidates
|
|
570
826
|
)
|
|
571
827
|
recent = ""
|
|
572
828
|
if conversation:
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
829
|
+
msgs = conversation[-ROUTER_PROMPT_HISTORY_MSGS:]
|
|
830
|
+
parts: list[str] = []
|
|
831
|
+
for m in msgs:
|
|
832
|
+
if not isinstance(m, dict):
|
|
833
|
+
continue
|
|
834
|
+
role = str(m.get("role") or "user")
|
|
835
|
+
c = str(m.get("content") or "").strip()
|
|
836
|
+
if not c:
|
|
837
|
+
continue
|
|
838
|
+
parts.append(f"{role}: {c[:ROUTER_PROMPT_HISTORY_CHARS]}")
|
|
839
|
+
if parts:
|
|
840
|
+
recent = "\n\nRecent conversation:\n" + "\n".join(parts)
|
|
576
841
|
sys = (
|
|
577
842
|
"You are a skill router. Given a user prompt and a candidate list of skills, "
|
|
578
843
|
f"pick 0 to {MAX_ACTIVE_SKILLS} skills that would genuinely help answer this prompt. "
|
|
579
844
|
"Be ruthless — only include a skill if it directly applies. Empty list is valid. "
|
|
580
845
|
'Respond ONLY in JSON: {"skills": ["name1","name2"], "reasoning": "one sentence"}'
|
|
581
846
|
)
|
|
582
|
-
user =
|
|
847
|
+
user = (
|
|
848
|
+
f"User prompt:\n{prompt}\n\nRouting context (retrieval query):\n{rq}{recent}"
|
|
849
|
+
f"\n\nCandidate skills:\n{catalog}"
|
|
850
|
+
)
|
|
583
851
|
try:
|
|
584
852
|
resp = await self.anthropic.messages.create(
|
|
585
853
|
model=ROUTER_MODEL,
|
|
@@ -626,6 +894,26 @@ def format_context_items_markdown(context_items: list[dict[str, Any]]) -> str:
|
|
|
626
894
|
return "\n".join(blocks)
|
|
627
895
|
|
|
628
896
|
|
|
897
|
+
def normalize_host_picked_names(
|
|
898
|
+
raw: list[str] | None,
|
|
899
|
+
by_name: dict[str, Skill],
|
|
900
|
+
cap: int,
|
|
901
|
+
) -> list[str]:
|
|
902
|
+
"""Dedupe, order-stable, cap length; only catalog names."""
|
|
903
|
+
out: list[str] = []
|
|
904
|
+
seen: set[str] = set()
|
|
905
|
+
for item in raw or []:
|
|
906
|
+
if not isinstance(item, str):
|
|
907
|
+
continue
|
|
908
|
+
n = item.strip()
|
|
909
|
+
if n in by_name and n not in seen:
|
|
910
|
+
out.append(n)
|
|
911
|
+
seen.add(n)
|
|
912
|
+
if len(out) >= cap:
|
|
913
|
+
break
|
|
914
|
+
return out
|
|
915
|
+
|
|
916
|
+
|
|
629
917
|
async def run_route_turn(
|
|
630
918
|
con: sqlite3.Connection,
|
|
631
919
|
router: Router,
|
|
@@ -636,15 +924,165 @@ async def run_route_turn(
|
|
|
636
924
|
*,
|
|
637
925
|
project_root: str | None = None,
|
|
638
926
|
include_project_rag: bool = False,
|
|
927
|
+
picked_names_from_host: list[str] | None = None,
|
|
928
|
+
picked_names_from_host_supplied: bool = False,
|
|
639
929
|
) -> dict[str, Any]:
|
|
640
930
|
"""Shared routing + session + telemetry for MCP route_skills and ``skillforge route``.
|
|
641
931
|
|
|
642
|
-
|
|
932
|
+
``SKILLFORGE_ROUTER_MODE=host`` without ``picked_names`` returns a tight shortlist only (no ``uses``,
|
|
933
|
+
no skill chunks). Pass ``picked_names`` on the next call to finalize context.
|
|
934
|
+
|
|
935
|
+
When ``picked_names_from_host_supplied`` is True, skips rerank/Haiku and uses the supplied names
|
|
936
|
+
(after validation) in any router mode.
|
|
643
937
|
"""
|
|
644
938
|
sid = session_id or str(uuid.uuid4())
|
|
645
939
|
t0 = time.time()
|
|
646
|
-
|
|
647
|
-
|
|
940
|
+
route_query = build_route_query_text(prompt, conversation)
|
|
941
|
+
pr = (project_root or "").strip()
|
|
942
|
+
policies_cfg = load_route_policies_config(pr or None)
|
|
943
|
+
overlay_audit: list[dict[str, Any]] = []
|
|
944
|
+
exclude_skills, routing_boosts, project_notes_raw = parse_routing_overlay(
|
|
945
|
+
policies_cfg,
|
|
946
|
+
by_name=router._by_name,
|
|
947
|
+
audit_out=overlay_audit,
|
|
948
|
+
)
|
|
949
|
+
route_query = merge_project_notes_into_route_query(route_query, project_notes_raw, pr)
|
|
950
|
+
notes_effective = bool(project_notes_raw.strip() and pr)
|
|
951
|
+
routing_overlay_meta = build_routing_overlay_payload(
|
|
952
|
+
project_root=pr,
|
|
953
|
+
exclude_skills=exclude_skills,
|
|
954
|
+
routing_boosts=routing_boosts,
|
|
955
|
+
project_notes_applied=notes_effective,
|
|
956
|
+
project_notes_len=len(project_notes_raw) if project_notes_raw else 0,
|
|
957
|
+
audit=overlay_audit,
|
|
958
|
+
)
|
|
959
|
+
rules_list_early = policies_cfg.get("rules") if isinstance(policies_cfg.get("rules"), list) else []
|
|
960
|
+
rules_n = len(rules_list_early)
|
|
961
|
+
|
|
962
|
+
host_router = SKILLFORGE_ROUTER_MODE == "host"
|
|
963
|
+
|
|
964
|
+
if host_router and not picked_names_from_host_supplied:
|
|
965
|
+
k = max(3, min(TOP_K_CANDIDATES, int(os.getenv("SKILLFORGE_HOST_PICK_MAX", "12"))))
|
|
966
|
+
facets = router.shortlist_with_facets(
|
|
967
|
+
route_query,
|
|
968
|
+
con,
|
|
969
|
+
k=k,
|
|
970
|
+
user_id=user_id,
|
|
971
|
+
exclude_skills=exclude_skills,
|
|
972
|
+
routing_boosts=routing_boosts,
|
|
973
|
+
)
|
|
974
|
+
candidates = [
|
|
975
|
+
(router._by_name[nm], coerce_route_float(f.get("cosine_similarity")))
|
|
976
|
+
for f in facets
|
|
977
|
+
if (nm := f.get("name")) in router._by_name
|
|
978
|
+
]
|
|
979
|
+
md, rows = host_pick_shortlist_lines(
|
|
980
|
+
prompt=prompt,
|
|
981
|
+
route_query=route_query,
|
|
982
|
+
facet_rows=facets,
|
|
983
|
+
max_candidates=k,
|
|
984
|
+
)
|
|
985
|
+
route_ms = (time.time() - t0) * 1000
|
|
986
|
+
safe_prompt_snip = prompt[:300]
|
|
987
|
+
if redaction_enabled():
|
|
988
|
+
safe_prompt_snip, _ = redact_secret_patterns(prompt[:300])
|
|
989
|
+
route_quality = build_route_quality(
|
|
990
|
+
facet_list=facets,
|
|
991
|
+
router_mode=SKILLFORGE_ROUTER_MODE or "auto",
|
|
992
|
+
router_hybrid=router._hybrid_mode,
|
|
993
|
+
picked_names=[],
|
|
994
|
+
rerouted=False,
|
|
995
|
+
change=0.0,
|
|
996
|
+
policy_rules_loaded=rules_n,
|
|
997
|
+
policy_audit=[],
|
|
998
|
+
host_picked=False,
|
|
999
|
+
host_shortlist_only=True,
|
|
1000
|
+
haiku_rerank_applied=False,
|
|
1001
|
+
pick_path="host_shortlist",
|
|
1002
|
+
)
|
|
1003
|
+
feedback_effect = build_feedback_effect(con, [], user_id=user_id)
|
|
1004
|
+
event = {
|
|
1005
|
+
"type": "host_shortlist",
|
|
1006
|
+
"session_id": sid,
|
|
1007
|
+
"user_id": user_id,
|
|
1008
|
+
"prompt": safe_prompt_snip,
|
|
1009
|
+
"candidates": [{"name": s.name, "score": sc} for s, sc in candidates[:15]],
|
|
1010
|
+
"picked": [],
|
|
1011
|
+
"reasoning": "host_pick_shortlist",
|
|
1012
|
+
"route_ms": round(route_ms, 1),
|
|
1013
|
+
"ts": time.time(),
|
|
1014
|
+
"host_pick_candidates": rows,
|
|
1015
|
+
"policy": {"rules_loaded": rules_n, "audit": []},
|
|
1016
|
+
"route_quality": route_quality,
|
|
1017
|
+
"feedback_effect": feedback_effect,
|
|
1018
|
+
}
|
|
1019
|
+
if routing_overlay_meta is not None:
|
|
1020
|
+
event["routing_overlay"] = routing_overlay_meta
|
|
1021
|
+
log_event(con, sid, "host_shortlist", event, user_id=user_id)
|
|
1022
|
+
con.commit()
|
|
1023
|
+
ret_host: dict[str, Any] = {
|
|
1024
|
+
"session_id": sid,
|
|
1025
|
+
"picked_names": [],
|
|
1026
|
+
"reasoning": (
|
|
1027
|
+
"host_pick_shortlist — choose names from the list; call route_skills again "
|
|
1028
|
+
"with picked_names (reuse session_id if you use sessions)."
|
|
1029
|
+
),
|
|
1030
|
+
"candidates": candidates,
|
|
1031
|
+
"route_ms": route_ms,
|
|
1032
|
+
"rerouted": False,
|
|
1033
|
+
"change": 0.0,
|
|
1034
|
+
"event": event,
|
|
1035
|
+
"context_items": [],
|
|
1036
|
+
"host_pick_shortlist": True,
|
|
1037
|
+
"host_pick_markdown": md,
|
|
1038
|
+
"host_pick_candidates": rows,
|
|
1039
|
+
"route_query": route_query,
|
|
1040
|
+
"route_quality": route_quality,
|
|
1041
|
+
"feedback_effect": feedback_effect,
|
|
1042
|
+
}
|
|
1043
|
+
if routing_overlay_meta is not None:
|
|
1044
|
+
ret_host["routing_overlay"] = routing_overlay_meta
|
|
1045
|
+
return ret_host
|
|
1046
|
+
|
|
1047
|
+
facet_list = router.shortlist_with_facets(
|
|
1048
|
+
route_query,
|
|
1049
|
+
con,
|
|
1050
|
+
k=TOP_K_CANDIDATES,
|
|
1051
|
+
user_id=user_id,
|
|
1052
|
+
exclude_skills=exclude_skills,
|
|
1053
|
+
routing_boosts=routing_boosts,
|
|
1054
|
+
)
|
|
1055
|
+
candidates = [
|
|
1056
|
+
(router._by_name[nm], coerce_route_float(f.get("cosine_similarity")))
|
|
1057
|
+
for f in facet_list
|
|
1058
|
+
if (nm := f.get("name")) in router._by_name
|
|
1059
|
+
]
|
|
1060
|
+
haiku_rerank_applied = False
|
|
1061
|
+
if picked_names_from_host_supplied:
|
|
1062
|
+
picked_names = normalize_host_picked_names(
|
|
1063
|
+
picked_names_from_host, router._by_name, MAX_ACTIVE_SKILLS
|
|
1064
|
+
)
|
|
1065
|
+
reasoning = "host-picked: MCP picked_names"
|
|
1066
|
+
else:
|
|
1067
|
+
names_before = [s.name for s, _ in candidates]
|
|
1068
|
+
rerank_eligible = bool(
|
|
1069
|
+
candidates and router.anthropic is not None and _env_truthy("SKILLFORGE_HAIKU_RERANK", "0")
|
|
1070
|
+
)
|
|
1071
|
+
candidates = await router.rerank_candidates_haiku(route_query, conversation, candidates)
|
|
1072
|
+
names_after = [s.name for s, _ in candidates]
|
|
1073
|
+
haiku_rerank_applied = rerank_eligible and names_before != names_after
|
|
1074
|
+
picked_names, reasoning = await router.pick_final(
|
|
1075
|
+
prompt, conversation, candidates, route_query=route_query
|
|
1076
|
+
)
|
|
1077
|
+
picked_names, policy_audit = merge_policy_includes(
|
|
1078
|
+
prompt,
|
|
1079
|
+
picked_names,
|
|
1080
|
+
policies_cfg,
|
|
1081
|
+
router._by_name,
|
|
1082
|
+
con,
|
|
1083
|
+
user_id,
|
|
1084
|
+
max_active=MAX_ACTIVE_SKILLS,
|
|
1085
|
+
)
|
|
648
1086
|
route_ms = (time.time() - t0) * 1000
|
|
649
1087
|
|
|
650
1088
|
prev_active: set[str] = set()
|
|
@@ -658,7 +1096,6 @@ async def run_route_turn(
|
|
|
658
1096
|
change = jaccard_change(prev_active, set(picked_names))
|
|
659
1097
|
rerouted = change >= REROUTE_THRESHOLD and bool(prev_active)
|
|
660
1098
|
|
|
661
|
-
pr = (project_root or "").strip()
|
|
662
1099
|
want_fusion = CONTEXT_FUSION and include_project_rag and bool(pr)
|
|
663
1100
|
context_fusion: dict[str, Any] | None = None
|
|
664
1101
|
context_items: list[dict[str, Any]] = []
|
|
@@ -751,6 +1188,29 @@ async def run_route_turn(
|
|
|
751
1188
|
|
|
752
1189
|
project_rag_items_count = sum(1 for c in context_items if c.get("path"))
|
|
753
1190
|
|
|
1191
|
+
if picked_names_from_host_supplied:
|
|
1192
|
+
pick_path = "host_picked"
|
|
1193
|
+
elif router.anthropic is None or SKILLFORGE_ROUTER_MODE == "embedding":
|
|
1194
|
+
pick_path = "embedding_top"
|
|
1195
|
+
else:
|
|
1196
|
+
pick_path = "haiku_pick"
|
|
1197
|
+
|
|
1198
|
+
rules_list = policies_cfg.get("rules") if isinstance(policies_cfg.get("rules"), list) else []
|
|
1199
|
+
route_quality = build_route_quality(
|
|
1200
|
+
facet_list=facet_list,
|
|
1201
|
+
router_mode=SKILLFORGE_ROUTER_MODE or "auto",
|
|
1202
|
+
router_hybrid=router._hybrid_mode,
|
|
1203
|
+
picked_names=picked_names,
|
|
1204
|
+
rerouted=rerouted,
|
|
1205
|
+
change=change,
|
|
1206
|
+
policy_rules_loaded=len(rules_list),
|
|
1207
|
+
policy_audit=policy_audit,
|
|
1208
|
+
host_picked=picked_names_from_host_supplied,
|
|
1209
|
+
host_shortlist_only=False,
|
|
1210
|
+
haiku_rerank_applied=haiku_rerank_applied,
|
|
1211
|
+
pick_path=pick_path,
|
|
1212
|
+
)
|
|
1213
|
+
|
|
754
1214
|
reasoning_out = reasoning
|
|
755
1215
|
safe_prompt_snip = prompt[:300]
|
|
756
1216
|
context_redaction_stats: dict[str, Any] = {"enabled": False, "secret_hits": 0, "path_hits": 0}
|
|
@@ -770,6 +1230,8 @@ async def run_route_turn(
|
|
|
770
1230
|
for n in picked_names:
|
|
771
1231
|
update_skill_stat(con, n, "uses", 1, user_id=user_id)
|
|
772
1232
|
|
|
1233
|
+
feedback_effect = build_feedback_effect(con, picked_names, user_id=user_id)
|
|
1234
|
+
|
|
773
1235
|
event = {
|
|
774
1236
|
"type": "route",
|
|
775
1237
|
"session_id": sid,
|
|
@@ -788,6 +1250,10 @@ async def run_route_turn(
|
|
|
788
1250
|
"include_project_rag": bool(include_project_rag and pr),
|
|
789
1251
|
"context_fusion": context_fusion,
|
|
790
1252
|
"context_redaction": context_redaction_stats,
|
|
1253
|
+
"policy": {
|
|
1254
|
+
"rules_loaded": len(policies_cfg.get("rules") or []) if isinstance(policies_cfg.get("rules"), list) else 0,
|
|
1255
|
+
"audit": policy_audit,
|
|
1256
|
+
},
|
|
791
1257
|
"chunk_sources_preview": [
|
|
792
1258
|
{
|
|
793
1259
|
"skill": c.get("skill"),
|
|
@@ -798,9 +1264,14 @@ async def run_route_turn(
|
|
|
798
1264
|
}
|
|
799
1265
|
for c in context_items[:24]
|
|
800
1266
|
],
|
|
1267
|
+
"host_picked": bool(picked_names_from_host_supplied),
|
|
1268
|
+
"route_quality": route_quality,
|
|
1269
|
+
"feedback_effect": feedback_effect,
|
|
801
1270
|
}
|
|
1271
|
+
if routing_overlay_meta is not None:
|
|
1272
|
+
event["routing_overlay"] = routing_overlay_meta
|
|
802
1273
|
log_event(con, sid, "route", event, user_id=user_id)
|
|
803
|
-
|
|
1274
|
+
ret_main: dict[str, Any] = {
|
|
804
1275
|
"session_id": sid,
|
|
805
1276
|
"picked_names": picked_names,
|
|
806
1277
|
"reasoning": reasoning_out,
|
|
@@ -810,4 +1281,9 @@ async def run_route_turn(
|
|
|
810
1281
|
"change": change,
|
|
811
1282
|
"event": event,
|
|
812
1283
|
"context_items": context_items,
|
|
1284
|
+
"route_quality": route_quality,
|
|
1285
|
+
"feedback_effect": feedback_effect,
|
|
813
1286
|
}
|
|
1287
|
+
if routing_overlay_meta is not None:
|
|
1288
|
+
ret_main["routing_overlay"] = routing_overlay_meta
|
|
1289
|
+
return ret_main
|