@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.
@@ -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 and takes top skills from the shortlist only.
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(name=name, title=title, description=description, body=body, source=source)
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
- texts = [f"{s.title}: {s.description}" for s in skills]
329
- print(f"[skillforge] Embedding {len(skills)} skills (summary)...", file=sys.stderr)
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 shortlist(self, prompt, con, k=TOP_K_CANDIDATES, user_id=""):
369
- if len(self.skills) == 0:
370
- return []
371
- q = self.embed_model.encode(prompt, convert_to_numpy=True)
372
- q = q / np.linalg.norm(q)
373
- sims = self.matrix @ q
374
- biased = sims.copy()
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(self, prompt, conversation, candidates):
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.description[:200]}" for s, _ in candidates
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
- recent = "\n\nRecent conversation:\n" + "\n".join(
574
- f"{m['role']}: {m['content'][:200]}" for m in conversation[-4:]
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 = f"User prompt:\n{prompt}{recent}\n\nCandidate skills:\n{catalog}"
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
- Updates sessions, skill usage stats, and writes a route row to events.
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
- candidates = router.shortlist(prompt, con, user_id=user_id)
647
- picked_names, reasoning = await router.pick_final(prompt, conversation, candidates)
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
- return {
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