portawhip 0.1.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 +13 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SECURITY.md +15 -0
- package/VISION.md +281 -0
- package/adapters/hooks/hook-stub.mjs +21 -0
- package/adapters/hooks/hook-stub.test.mjs +43 -0
- package/adapters/hooks/universal-hook.mjs +348 -0
- package/adapters/hooks/universal-hook.test.mjs +294 -0
- package/adapters/instructions/generate.mjs +114 -0
- package/core/fixtures/bad-recipe.yaml +5 -0
- package/core/fixtures/capability-graph.json +10 -0
- package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
- package/core/registry/capability-docs.mjs +145 -0
- package/core/registry/capability-graph-compiler.mjs +90 -0
- package/core/registry/capability-graph.mjs +53 -0
- package/core/registry/capability-kind.mjs +9 -0
- package/core/registry/cli-enrich.mjs +316 -0
- package/core/registry/cli-enrich.test.mjs +119 -0
- package/core/registry/discover.mjs +379 -0
- package/core/registry/enrich.mjs +213 -0
- package/core/registry/enrich.test.mjs +53 -0
- package/core/registry/eval-harvest.mjs +93 -0
- package/core/registry/eval-harvest.test.mjs +64 -0
- package/core/registry/registry.mjs +197 -0
- package/core/router/concept-vector.mjs +91 -0
- package/core/router/concept-vector.test.mjs +36 -0
- package/core/router/curated-trigger.test.mjs +28 -0
- package/core/router/dense-embedder.mjs +242 -0
- package/core/router/fusion.mjs +18 -0
- package/core/router/hybrid-router.mjs +375 -0
- package/core/router/intent-evidence.mjs +60 -0
- package/core/router/prompt-hygiene.mjs +31 -0
- package/core/router/route-entry.mjs +74 -0
- package/core/router/router-cli.mjs +218 -0
- package/core/router/router-cli.test.mjs +25 -0
- package/core/router/router-eval.mjs +166 -0
- package/core/router/router-live.test.mjs +89 -0
- package/core/router/router.test.mjs +838 -0
- package/core/router/scorer.mjs +72 -0
- package/core/router/sparse-retriever.mjs +79 -0
- package/core/router/tokenize.mjs +42 -0
- package/core/state/bundle-state.mjs +124 -0
- package/core/state/bundle-state.test.mjs +175 -0
- package/core/state/config.mjs +115 -0
- package/core/state/feedback.mjs +129 -0
- package/core/state/feedback.test.mjs +181 -0
- package/core/state/runtime-root.test.mjs +22 -0
- package/core/state/stack-detect.mjs +102 -0
- package/core/state/stack-detect.test.mjs +64 -0
- package/core/surface/config-sync-backends.mjs +224 -0
- package/core/surface/connector-targets.mjs +205 -0
- package/core/surface/discover-hooks.mjs +151 -0
- package/core/surface/discover-hooks.test.mjs +63 -0
- package/core/surface/discover-surface.test.mjs +49 -0
- package/core/surface/extra-hosts.mjs +48 -0
- package/core/surface/extra-hosts.test.mjs +24 -0
- package/core/surface/hook-targets.mjs +102 -0
- package/core/surface/surface-copy-targets.mjs +37 -0
- package/core/surface/surface-inventory.mjs +106 -0
- package/core/surface/surface-matrix.mjs +133 -0
- package/core/surface/surface-matrix.test.mjs +90 -0
- package/docs/router-eval-set.jsonl +38 -0
- package/hooks.manifest.yaml +16 -0
- package/package.json +101 -0
- package/recipe.yaml +247 -0
- package/recipes/foundry.yaml +27 -0
- package/recipes/manifest.yaml +18 -0
- package/recipes/roles/backend-data.yaml +26 -0
- package/recipes/roles/coding.yaml +26 -0
- package/recipes/roles/frontend.yaml +14 -0
- package/recipes/roles/research.yaml +31 -0
- package/recipes/roles/secure.yaml +27 -0
- package/router.config.yaml +120 -0
- package/scripts/bundles.mjs +131 -0
- package/scripts/doctor.mjs +117 -0
- package/scripts/embedded-hooks.mjs +27 -0
- package/scripts/hosts.mjs +60 -0
- package/scripts/link/link-connectors.mjs +190 -0
- package/scripts/link/link-connectors.test.mjs +111 -0
- package/scripts/link/link-hooks.mjs +259 -0
- package/scripts/link/link-hooks.test.mjs +112 -0
- package/scripts/link/link-surfaces.mjs +188 -0
- package/scripts/link/link-surfaces.test.mjs +76 -0
- package/scripts/load.mjs +143 -0
- package/scripts/package-contract.test.mjs +26 -0
- package/scripts/surface-inventory.mjs +6 -0
- package/scripts/sync/agents-surface.mjs +55 -0
- package/scripts/sync/agents-surface.test.mjs +18 -0
- package/scripts/sync/auto-sync.mjs +135 -0
- package/scripts/sync/auto-sync.test.mjs +41 -0
- package/scripts/sync/import-surfaces.mjs +331 -0
- package/scripts/sync/import-surfaces.test.mjs +106 -0
- package/scripts/sync/sync-config.mjs +230 -0
- package/scripts/sync/sync-config.test.mjs +141 -0
- package/scripts/sync/sync-surfaces.mjs +229 -0
- package/scripts/sync/sync-surfaces.test.mjs +66 -0
- package/scripts/tui-actions.mjs +59 -0
- package/scripts/tui-actions.test.mjs +53 -0
- package/scripts/tui.mjs +782 -0
- package/scripts/uninstall-all.mjs +39 -0
- package/server/mcp-server.mjs +129 -0
- package/server/mcp-server.test.mjs +175 -0
- package/skills/portawhip/SKILL.md +80 -0
- package/surface-matrix.yaml +93 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Phase 4 semantic layer (PLAN.md): a second, meaning-based retrieval channel
|
|
2
|
+
// alongside core/sparse-retriever.mjs's lexical one. Margin-gate research
|
|
3
|
+
// (docs/router-eval-set.jsonl) proved score-geometry tricks hit a wall - the
|
|
4
|
+
// remaining miss (e2e-testing on "use Playwright to test login flow") is a
|
|
5
|
+
// paraphrase gap no amount of threshold/margin tuning on lexical scores can
|
|
6
|
+
// close. That needs actual semantic similarity.
|
|
7
|
+
//
|
|
8
|
+
// Zero-setup by design: BAAI/bge-m3 (MIT, 100+ languages including Thai,
|
|
9
|
+
// verified live 2026-07-06 - HF reachable from this machine, ~543MB
|
|
10
|
+
// quantized ONNX, works with transformers.js's plain feature-extraction
|
|
11
|
+
// pipeline with no instruction-prefix ceremony unlike some competitors).
|
|
12
|
+
// transformers.js downloads and caches the model itself on first use - no
|
|
13
|
+
// install script, no manual model management. Must degrade gracefully when
|
|
14
|
+
// the model/network isn't available (PLAN.md Phase 4 spec): every export
|
|
15
|
+
// here fails soft (returns [] / null), never throws into the caller.
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname } from "node:path";
|
|
19
|
+
|
|
20
|
+
const MODEL_ID = "Xenova/bge-m3";
|
|
21
|
+
|
|
22
|
+
// The resolved pipeline, or null until the (slow) first load finishes. Kept
|
|
23
|
+
// separate from the load promise so callers can synchronously ask "is it
|
|
24
|
+
// ready RIGHT NOW?" without awaiting - the whole point of the non-blocking
|
|
25
|
+
// path below.
|
|
26
|
+
let extractor = null;
|
|
27
|
+
let loadStarted = false;
|
|
28
|
+
let unavailable = false;
|
|
29
|
+
let loadPromise = null;
|
|
30
|
+
|
|
31
|
+
// Kicks off the model load in the background (idempotent). The first load is
|
|
32
|
+
// slow - measured ~73s cold in the live MCP server (download + ONNX init) -
|
|
33
|
+
// so a caller must NEVER sit inside an await on this while serving an
|
|
34
|
+
// interactive request: an MCP client times out (~30-60s) long before it
|
|
35
|
+
// finishes, drops the connection, and route() becomes uncallable for the
|
|
36
|
+
// whole session (found live 2026-07-07: dense-on-by-default silently broke
|
|
37
|
+
// the MCP server this way). This returns immediately; the load resolves
|
|
38
|
+
// `extractor` whenever it's done.
|
|
39
|
+
function startWarm() {
|
|
40
|
+
if (extractor || unavailable || loadStarted) return loadPromise;
|
|
41
|
+
loadStarted = true;
|
|
42
|
+
loadPromise = import("@huggingface/transformers")
|
|
43
|
+
.then(({ pipeline }) => pipeline("feature-extraction", MODEL_ID))
|
|
44
|
+
.then((ex) => {
|
|
45
|
+
extractor = ex;
|
|
46
|
+
return ex;
|
|
47
|
+
})
|
|
48
|
+
.catch(() => {
|
|
49
|
+
unavailable = true;
|
|
50
|
+
return null;
|
|
51
|
+
});
|
|
52
|
+
return loadPromise;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Let a long-lived caller (the MCP server) start the load at startup so the
|
|
56
|
+
// ~73s cold load overlaps idle time before the first route() call, instead
|
|
57
|
+
// of landing on top of it. Fire-and-forget: returns nothing, never throws.
|
|
58
|
+
export function warmDense() {
|
|
59
|
+
startWarm();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Docs are rebuilt fresh per call (core/hybrid-router.mjs's own comment on
|
|
63
|
+
// buildCapabilityDocs), so caching on object identity is useless across
|
|
64
|
+
// calls - key by id+text instead (same content-hash-cache shape as
|
|
65
|
+
// core/eval-harvest.mjs, just keyed by the raw text since collision risk
|
|
66
|
+
// over a few hundred short capability descriptions is not worth a hash).
|
|
67
|
+
// One process-lifetime cache: a fresh push-hook subprocess gets a fresh
|
|
68
|
+
// cache (acceptable - push hook opts out of dense entirely, see
|
|
69
|
+
// universal-hook.mjs), while the long-lived MCP server/CLI process reuses
|
|
70
|
+
// it across every route() call for the life of the process.
|
|
71
|
+
const embeddingCache = new Map();
|
|
72
|
+
|
|
73
|
+
// Persists embeddingCache to disk so a fresh MCP server process (a new one
|
|
74
|
+
// per Claude Code session, not a single long-lived daemon) doesn't re-pay the
|
|
75
|
+
// "ready-but-cache-cold" trap on every restart: found live, route() latency
|
|
76
|
+
// of 67-104s on the first request after model warm, because 559 capability
|
|
77
|
+
// docs were being embedded one-by-one in that request's hot path. Keying is
|
|
78
|
+
// still by id+text (embeddingCache's own key), so a changed doc text just
|
|
79
|
+
// misses the disk entry instead of serving a stale vector - no separate
|
|
80
|
+
// invalidation hash needed.
|
|
81
|
+
let diskCachePath = null;
|
|
82
|
+
let diskCacheLoaded = false;
|
|
83
|
+
let primeInFlight = null;
|
|
84
|
+
|
|
85
|
+
// Called once at server boot (see server/mcp-server.mjs) so denseRetrieve and
|
|
86
|
+
// primeDocCache below know where to read/write the persisted cache. Never
|
|
87
|
+
// throws - a caller that skips this just gets the always-worked in-memory-only
|
|
88
|
+
// behavior.
|
|
89
|
+
export function setDenseCachePath(path) {
|
|
90
|
+
diskCachePath = path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ensureDiskCacheLoaded() {
|
|
94
|
+
if (diskCacheLoaded || !diskCachePath) return;
|
|
95
|
+
diskCacheLoaded = true;
|
|
96
|
+
try {
|
|
97
|
+
if (!existsSync(diskCachePath)) return;
|
|
98
|
+
const raw = JSON.parse(readFileSync(diskCachePath, "utf8"));
|
|
99
|
+
for (const [key, vector] of Object.entries(raw)) {
|
|
100
|
+
if (!embeddingCache.has(key)) embeddingCache.set(key, vector);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Corrupt/unreadable cache file - proceed as if it never existed.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function persistDiskCache() {
|
|
108
|
+
if (!diskCachePath) return;
|
|
109
|
+
try {
|
|
110
|
+
mkdirSync(dirname(diskCachePath), { recursive: true });
|
|
111
|
+
writeFileSync(diskCachePath, JSON.stringify(Object.fromEntries(embeddingCache)));
|
|
112
|
+
} catch {
|
|
113
|
+
// Best-effort - a failed write just means next boot re-embeds.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Warms the doc-embedding cache in the BACKGROUND, deliberately called
|
|
118
|
+
// separately from warmDense() (which only starts the model load) so a caller
|
|
119
|
+
// controls exactly when the (docs, not just model) priming kicks off - here,
|
|
120
|
+
// right after loadIndex() at server boot, overlapping the idle time before
|
|
121
|
+
// the first real route() call instead of landing inside it. Safe to call
|
|
122
|
+
// concurrently/repeatedly: dedupes via primeInFlight, and every doc is a
|
|
123
|
+
// no-op past the first time it's embedded (same embeddingCache the request
|
|
124
|
+
// path reads).
|
|
125
|
+
export async function primeDocCache(docs) {
|
|
126
|
+
ensureDiskCacheLoaded();
|
|
127
|
+
if (primeInFlight) return primeInFlight;
|
|
128
|
+
primeInFlight = (async () => {
|
|
129
|
+
const ready = await startWarm();
|
|
130
|
+
if (!ready) return;
|
|
131
|
+
let embeddedAny = false;
|
|
132
|
+
for (const doc of docs) {
|
|
133
|
+
const key = `${doc.id}::${docText(doc)}`;
|
|
134
|
+
if (embeddingCache.has(key)) continue;
|
|
135
|
+
try {
|
|
136
|
+
embeddingCache.set(key, await embed(ready, docText(doc)));
|
|
137
|
+
embeddedAny = true;
|
|
138
|
+
} catch {
|
|
139
|
+
unavailable = true;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (embeddedAny) persistDiskCache();
|
|
144
|
+
})();
|
|
145
|
+
try {
|
|
146
|
+
await primeInFlight;
|
|
147
|
+
} finally {
|
|
148
|
+
primeInFlight = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function docText(doc) {
|
|
153
|
+
return [doc.id, (doc.triggers ?? []).join(" "), doc.description ?? ""].join(" ");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function embed(extractor, text) {
|
|
157
|
+
const output = await extractor(text, { pooling: "cls", normalize: true });
|
|
158
|
+
return Array.from(output.data);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cosineSim(a, b) {
|
|
162
|
+
let dot = 0;
|
|
163
|
+
for (let i = 0; i < a.length; i += 1) dot += a[i] * b[i];
|
|
164
|
+
return dot; // both vectors are already normalized, so dot product == cosine
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function embedDoc(extractor, doc) {
|
|
168
|
+
const key = `${doc.id}::${docText(doc)}`;
|
|
169
|
+
if (embeddingCache.has(key)) return embeddingCache.get(key);
|
|
170
|
+
const vector = await embed(extractor, docText(doc));
|
|
171
|
+
embeddingCache.set(key, vector);
|
|
172
|
+
return vector;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Same call shape as sparseRetrieve(docs, query, {k, minScore}) -> {id, doc,
|
|
176
|
+
// score}[], so hybrid-router.mjs can treat it as a second, optional channel.
|
|
177
|
+
// Score is raw cosine similarity (0..1), never comparable to sparse's
|
|
178
|
+
// hundreds/thousands-scale BM25-ish numbers - callers must not mix the two
|
|
179
|
+
// on one bar (see hybrid-router.mjs's own dense-specific threshold).
|
|
180
|
+
// block=false (the MCP server's tier): if the model isn't warm yet, start the
|
|
181
|
+
// background load and return [] immediately - this call is sparse-only, and
|
|
182
|
+
// dense silently joins in on a later call once warm. block=true (CLI/eval):
|
|
183
|
+
// wait for the load, because those paths want full, deterministic results and
|
|
184
|
+
// there's no interactive client to time out.
|
|
185
|
+
export async function denseRetrieve(docs, query, { k = 20, minScore = 0.6, block = true } = {}) {
|
|
186
|
+
if (docs.length === 0) return [];
|
|
187
|
+
if (unavailable) return [];
|
|
188
|
+
ensureDiskCacheLoaded();
|
|
189
|
+
if (!extractor) {
|
|
190
|
+
const pending = startWarm();
|
|
191
|
+
if (!block) return [];
|
|
192
|
+
await pending;
|
|
193
|
+
if (!extractor) return [];
|
|
194
|
+
}
|
|
195
|
+
const ready = extractor;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const queryVector = await embed(ready, query);
|
|
199
|
+
const scored = [];
|
|
200
|
+
for (const doc of docs) {
|
|
201
|
+
const docVector = await embedDoc(ready, doc);
|
|
202
|
+
const score = cosineSim(queryVector, docVector);
|
|
203
|
+
if (score >= minScore) scored.push({ id: doc.id, doc, score });
|
|
204
|
+
}
|
|
205
|
+
return scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, k);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
unavailable = true;
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Test-only seams: real model load takes seconds and needs network/disk, so
|
|
213
|
+
// unit tests for hybrid-router's fusion logic inject a fake extractor (or
|
|
214
|
+
// force the "unavailable"/"still loading" states) instead of paying that cost
|
|
215
|
+
// on every run. Never called from production code paths.
|
|
216
|
+
export function _setPipelineForTest(fakeExtractor) {
|
|
217
|
+
extractor = fakeExtractor;
|
|
218
|
+
unavailable = false;
|
|
219
|
+
loadStarted = true;
|
|
220
|
+
loadPromise = Promise.resolve(fakeExtractor);
|
|
221
|
+
embeddingCache.clear();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function _forceUnavailableForTest() {
|
|
225
|
+
extractor = null;
|
|
226
|
+
unavailable = true;
|
|
227
|
+
loadStarted = true;
|
|
228
|
+
loadPromise = Promise.resolve(null);
|
|
229
|
+
embeddingCache.clear();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Simulates "load kicked off but not resolved yet" - lets a test exercise the
|
|
233
|
+
// non-blocking (block:false) sparse-only path without triggering a real model
|
|
234
|
+
// download. loadStarted is pre-set so denseRetrieve won't start a real import;
|
|
235
|
+
// the promise deliberately never resolves, so only block:false is safe here.
|
|
236
|
+
export function _setPipelinePendingForTest() {
|
|
237
|
+
extractor = null;
|
|
238
|
+
unavailable = false;
|
|
239
|
+
loadStarted = true;
|
|
240
|
+
loadPromise = new Promise(() => {});
|
|
241
|
+
embeddingCache.clear();
|
|
242
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function reciprocalRankFusion(rankings, { k = 60 } = {}) {
|
|
2
|
+
const scores = new Map();
|
|
3
|
+
for (const ranking of rankings) {
|
|
4
|
+
for (let i = 0; i < ranking.length; i += 1) {
|
|
5
|
+
const item = ranking[i];
|
|
6
|
+
const current = scores.get(item.id) ?? { ...item, rrfScore: 0 };
|
|
7
|
+
current.rrfScore += 1 / (k + i + 1);
|
|
8
|
+
scores.set(item.id, current);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return [...scores.values()].sort(
|
|
12
|
+
(a, b) =>
|
|
13
|
+
b.rrfScore - a.rrfScore ||
|
|
14
|
+
(b.score ?? 0) - (a.score ?? 0) ||
|
|
15
|
+
a.id.localeCompare(b.id),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { buildCapabilityDocs } from "../registry/capability-docs.mjs";
|
|
2
|
+
import { expandWithGraph, loadCapabilityGraph } from "../registry/capability-graph.mjs";
|
|
3
|
+
import { capabilityKind, matchesSuggestKind } from "../registry/capability-kind.mjs";
|
|
4
|
+
import { actionAlignmentFactor } from "./concept-vector.mjs";
|
|
5
|
+
import { denseRetrieve } from "./dense-embedder.mjs";
|
|
6
|
+
import { reciprocalRankFusion } from "./fusion.mjs";
|
|
7
|
+
import { sparseRetrieve } from "./sparse-retriever.mjs";
|
|
8
|
+
|
|
9
|
+
const BROAD_TERMS = new Set([
|
|
10
|
+
"agent",
|
|
11
|
+
"architecture",
|
|
12
|
+
"automation",
|
|
13
|
+
"build",
|
|
14
|
+
"code",
|
|
15
|
+
"coding",
|
|
16
|
+
"context",
|
|
17
|
+
"debug",
|
|
18
|
+
"design",
|
|
19
|
+
"develop",
|
|
20
|
+
"engineering",
|
|
21
|
+
"framework",
|
|
22
|
+
"hook",
|
|
23
|
+
"implement",
|
|
24
|
+
"integration",
|
|
25
|
+
"pattern",
|
|
26
|
+
"project",
|
|
27
|
+
"route",
|
|
28
|
+
"router",
|
|
29
|
+
"skill",
|
|
30
|
+
"test",
|
|
31
|
+
"tool",
|
|
32
|
+
"workflow",
|
|
33
|
+
// core/enrich.mjs (router-cli enrich) seeds MCP tool triggers from a
|
|
34
|
+
// server's own tools/list — sub-tool names conventionally follow a
|
|
35
|
+
// verb_noun pattern (push_files, create_pull_request, list_issues), so
|
|
36
|
+
// these generic CRUD verbs are now real trigger tokens for almost every
|
|
37
|
+
// enriched MCP tool. Found live: "compare push and pull routing modes for
|
|
38
|
+
// an agent harness" (a router-architecture question) matched github
|
|
39
|
+
// through "push"/"pull" alone with zero other overlap. Same
|
|
40
|
+
// weakKeywordOnly mechanism as the rest of this list — only suppresses a
|
|
41
|
+
// candidate when EVERY matched term is generic; a query that also shares
|
|
42
|
+
// a tool-specific word (e.g. "push files to my github repo") still
|
|
43
|
+
// passes untouched.
|
|
44
|
+
"push",
|
|
45
|
+
"pull",
|
|
46
|
+
"create",
|
|
47
|
+
"get",
|
|
48
|
+
"list",
|
|
49
|
+
"update",
|
|
50
|
+
"search",
|
|
51
|
+
"fetch",
|
|
52
|
+
"add",
|
|
53
|
+
"remove",
|
|
54
|
+
"delete",
|
|
55
|
+
// The capability system's own vocabulary. "tool"/"skill"/"agent"/"hook"/
|
|
56
|
+
// "router" are already above; "mcp"/"cli"/"capability" complete the set.
|
|
57
|
+
// A prompt that overlaps a capability ONLY through these words is talking
|
|
58
|
+
// ABOUT the tool/skill/MCP machinery, not requesting a task - it's the
|
|
59
|
+
// intent-gate case (docs/router-live.test.mjs): "research MCP availability
|
|
60
|
+
// ... dynamic tools and skills ..." matched build-mcp-server purely on
|
|
61
|
+
// {mcp, tool, skill}, all meta-vocabulary, and cleared the bar because
|
|
62
|
+
// "mcp" alone wasn't yet classed as generic. Same weakKeywordOnly
|
|
63
|
+
// mechanism - a prompt that ALSO shares a task-specific word (e.g. "wrap
|
|
64
|
+
// this REST API as an MCP server") still passes untouched.
|
|
65
|
+
"mcp",
|
|
66
|
+
"cli",
|
|
67
|
+
"capability",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function isWordChar(value) {
|
|
71
|
+
return typeof value === "string" && /[\p{L}\p{N}_]/u.test(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function containsBoundedPhrase(text, phrase) {
|
|
75
|
+
const haystack = text.toLocaleLowerCase();
|
|
76
|
+
const needle = phrase.trim().toLocaleLowerCase();
|
|
77
|
+
if (!needle) return false;
|
|
78
|
+
let offset = 0;
|
|
79
|
+
while (offset <= haystack.length - needle.length) {
|
|
80
|
+
const index = haystack.indexOf(needle, offset);
|
|
81
|
+
if (index < 0) return false;
|
|
82
|
+
const before = index > 0 ? haystack[index - 1] : null;
|
|
83
|
+
const afterIndex = index + needle.length;
|
|
84
|
+
const after = afterIndex < haystack.length ? haystack[afterIndex] : null;
|
|
85
|
+
if (!isWordChar(before) && !isWordChar(after)) return true;
|
|
86
|
+
offset = index + 1;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hasDirectCuratedTrigger(candidate, prompt) {
|
|
92
|
+
return (
|
|
93
|
+
candidate.doc?.origin === "recipe" &&
|
|
94
|
+
(candidate.doc.triggers ?? []).some((trigger) => containsBoundedPhrase(prompt, trigger))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function candidateBar(candidate, prompt, { autoBar, recipeBar, toolBar }) {
|
|
99
|
+
const isTool = candidate.doc.type === "mcp" || candidate.doc.type === "cli";
|
|
100
|
+
const base = candidate.doc.origin === "recipe" ? recipeBar : isTool ? toolBar : autoBar;
|
|
101
|
+
// A full, bounded match on a deliberately authored recipe trigger is the
|
|
102
|
+
// hybrid equivalent of keyword scorer's trusted single-trigger hit. This
|
|
103
|
+
// prevents clean installs from depending on machine-local graph/feedback
|
|
104
|
+
// boosts while keeping generic token overlap behind the calibrated bar.
|
|
105
|
+
return hasDirectCuratedTrigger(candidate, prompt) ? Math.min(base, candidate.score) : base;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Infinity means "sole lane survivor, nothing to be tied with" - the same
|
|
109
|
+
// no-penalty case gateLane already treats as automatically dominant.
|
|
110
|
+
function laneMarginRatio(laneCandidates) {
|
|
111
|
+
if (laneCandidates.length < 2) return Infinity;
|
|
112
|
+
const sorted = [...laneCandidates].sort((a, b) => b.score - a.score);
|
|
113
|
+
const [top, runnerUp] = sorted;
|
|
114
|
+
return runnerUp.score <= 0 ? Infinity : top.score / runnerUp.score;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A lane where the top match barely beats the runner-up is diffuse-vocabulary
|
|
118
|
+
// noise, not a genuine pick — verified against docs/router-eval-set.jsonl's
|
|
119
|
+
// one remaining false positive: "what context signals should a router
|
|
120
|
+
// observe before injecting a skill" scores example-skill and
|
|
121
|
+
// skill-development within 0.3% of each other (both lit up by the same
|
|
122
|
+
// generic "router"/"skill"/"injecting" words), while every real match in the
|
|
123
|
+
// set has a clear top pick with no close runner-up. A lane with only one
|
|
124
|
+
// surviving candidate is never penalized (nothing to be tied with).
|
|
125
|
+
//
|
|
126
|
+
// Survivors are tagged with the same marginRatio used for the gate decision,
|
|
127
|
+
// not just recomputed silently - Stage 3 calibration (classifyCandidate)
|
|
128
|
+
// reads it to keep confidence honest for a candidate that barely cleared the
|
|
129
|
+
// gate, not just ones that got silenced entirely. See marginConfidenceFactor.
|
|
130
|
+
function gateLane(laneCandidates, ratio) {
|
|
131
|
+
const marginRatio = laneMarginRatio(laneCandidates);
|
|
132
|
+
if (laneCandidates.length >= 2 && marginRatio < ratio) return [];
|
|
133
|
+
return laneCandidates.map((candidate) => ({ ...candidate, marginRatio }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Ramps confidence down for a candidate that only barely cleared the
|
|
137
|
+
// peakedness gate (docs/intent-gate-bakeoff.md's calibration ask - Codex
|
|
138
|
+
// point 3: "broad-term/dense-only/no-margin candidates must not read
|
|
139
|
+
// confidence 1"). marginRatio===Infinity (sole lane survivor - nothing was
|
|
140
|
+
// close) is full confidence; right at the gate ratio is the floor; by 0.45
|
|
141
|
+
// above the gate ("clearly dominant" - empirically chosen headroom, not
|
|
142
|
+
// tuned to a specific eval case) it's back to full.
|
|
143
|
+
function marginConfidenceFactor(marginRatio, gateRatio) {
|
|
144
|
+
if (!Number.isFinite(marginRatio)) return 1.0;
|
|
145
|
+
const CONFIDENT = gateRatio + 0.45;
|
|
146
|
+
if (marginRatio <= gateRatio) return 0.5;
|
|
147
|
+
if (marginRatio >= CONFIDENT) return 1.0;
|
|
148
|
+
return 0.5 + ((marginRatio - gateRatio) / (CONFIDENT - gateRatio)) * 0.5;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Curated (recipe.yaml) entries are trusted outright - deliberately
|
|
152
|
+
// authored, same rationale as the recipe/auto threshold split. A candidate
|
|
153
|
+
// with no direct lexical evidence at all (terms.length===0 - always true for
|
|
154
|
+
// a dense-only or graph-only hit, since neither carries real matched terms)
|
|
155
|
+
// is a similarity/adjacency GUESS, not an exact match, and must not read as
|
|
156
|
+
// confidently as one. A sparse hit that survived the tier filter above
|
|
157
|
+
// always has at least one specific (non-broad) term by construction.
|
|
158
|
+
function specificityConfidenceFactor(item) {
|
|
159
|
+
if (item.doc?.origin === "recipe") return 1.0;
|
|
160
|
+
return (item.terms ?? []).length === 0 ? 0.6 : 1.0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function compactReason(item) {
|
|
164
|
+
const terms = (item.terms ?? []).slice(0, 5);
|
|
165
|
+
if (item.graphBoosted) {
|
|
166
|
+
return `graph-related to ${item.graphSource}`;
|
|
167
|
+
}
|
|
168
|
+
if (terms.length > 0) {
|
|
169
|
+
return `matched ${terms.join(", ")} in ${Object.keys(item.match ?? {}).slice(0, 3).join(", ") || "capability text"}`;
|
|
170
|
+
}
|
|
171
|
+
return "matched capability text";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function classifyCandidate(item, bar, peakednessRatio = 1.05) {
|
|
175
|
+
const score = item.score ?? item.rrfScore ?? 0;
|
|
176
|
+
const baseConfidence = bar > 0 ? Math.min(1, score / bar) : 1;
|
|
177
|
+
const terms = item.terms ?? [];
|
|
178
|
+
const specificTerms = terms.filter((term) => !BROAD_TERMS.has(term));
|
|
179
|
+
const weakKeywordOnly = terms.length > 0 && specificTerms.length === 0 && item.doc?.origin !== "recipe";
|
|
180
|
+
// Composite, not a single score/threshold ratio (docs/intent-gate-bakeoff.md
|
|
181
|
+
// / Codex point 3): a candidate that barely cleared the peakedness gate, or
|
|
182
|
+
// has no direct lexical evidence (dense/graph-only), must not read as
|
|
183
|
+
// confidently as one that's both dominant in its lane AND directly matched.
|
|
184
|
+
const confidence = Math.max(
|
|
185
|
+
0,
|
|
186
|
+
Math.min(
|
|
187
|
+
1,
|
|
188
|
+
baseConfidence *
|
|
189
|
+
specificityConfidenceFactor(item) *
|
|
190
|
+
marginConfidenceFactor(item.marginRatio ?? Infinity, peakednessRatio),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
if (score >= bar && !weakKeywordOnly) {
|
|
195
|
+
return {
|
|
196
|
+
tier: confidence >= 0.9 && item.doc?.origin === "recipe" ? "required" : "recommended",
|
|
197
|
+
confidence,
|
|
198
|
+
action: item.doc?.action ?? (item.doc?.type === "skill" ? "read_skill" : "use_capability"),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
tier: weakKeywordOnly ? "irrelevant_but_keyword_matched" : "weak_match",
|
|
204
|
+
confidence,
|
|
205
|
+
action: "ignore_by_default",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function formatResult(item, bar, peakednessRatio) {
|
|
210
|
+
const doc = item.doc;
|
|
211
|
+
const classification = classifyCandidate(item, bar, peakednessRatio);
|
|
212
|
+
return {
|
|
213
|
+
id: doc.id,
|
|
214
|
+
type: doc.type,
|
|
215
|
+
kind: capabilityKind(doc.type),
|
|
216
|
+
score: Number((item.score ?? item.rrfScore ?? 0).toFixed(4)),
|
|
217
|
+
tier: classification.tier,
|
|
218
|
+
confidence: Number(classification.confidence.toFixed(2)),
|
|
219
|
+
why: compactReason(item),
|
|
220
|
+
action: classification.action,
|
|
221
|
+
how_to_use: doc.description,
|
|
222
|
+
pointer: doc.pointer,
|
|
223
|
+
origin: doc.origin,
|
|
224
|
+
readyMarker: doc.readyMarker ?? null,
|
|
225
|
+
readyHint: doc.readyHint ?? null,
|
|
226
|
+
engine: "hybrid",
|
|
227
|
+
graphBoosted: item.graphBoosted === true || undefined,
|
|
228
|
+
graphSource: item.graphSource,
|
|
229
|
+
graphEdgeType: item.graphEdgeType,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function routeHybrid(
|
|
234
|
+
index,
|
|
235
|
+
prompt,
|
|
236
|
+
{
|
|
237
|
+
threshold = 2,
|
|
238
|
+
hybridThreshold,
|
|
239
|
+
hybridRecipeThreshold,
|
|
240
|
+
hybridToolThreshold,
|
|
241
|
+
graphPath = null,
|
|
242
|
+
graphBoost = 0.25,
|
|
243
|
+
k = 5,
|
|
244
|
+
suggest = "any",
|
|
245
|
+
factors = null,
|
|
246
|
+
includeWeak = false,
|
|
247
|
+
peakednessRatio = 1.05,
|
|
248
|
+
denseEnabled = true,
|
|
249
|
+
denseThreshold = 0.6,
|
|
250
|
+
// Default true = wait for the dense model (deterministic - what CLI and
|
|
251
|
+
// eval want). The MCP server passes false so a cold model load never
|
|
252
|
+
// blocks an interactive route() call into a client timeout; see
|
|
253
|
+
// core/dense-embedder.mjs's warmDense/denseRetrieve.
|
|
254
|
+
denseBlock = true,
|
|
255
|
+
} = {},
|
|
256
|
+
) {
|
|
257
|
+
const docs = buildCapabilityDocs(index);
|
|
258
|
+
const autoBar = hybridThreshold ?? threshold;
|
|
259
|
+
// Curated (recipe.yaml) entries need their own, much lower bar — same
|
|
260
|
+
// rationale as scorer.mjs's recipeThreshold split. Confirmed empirically:
|
|
261
|
+
// generic domain vocabulary ("architecture", "patterns") that's common
|
|
262
|
+
// across hundreds of auto-discovered skill docs can score higher than a
|
|
263
|
+
// curated entry's genuine match (react-patterns/vue-patterns out-scoring
|
|
264
|
+
// ripgrep/context7 on unrelated prompts) — no single global threshold
|
|
265
|
+
// separates "real auto-discovered match" from "generic-vocabulary noise"
|
|
266
|
+
// AND "real curated match" at the same time.
|
|
267
|
+
const recipeBar = hybridRecipeThreshold ?? Math.min(autoBar, 130);
|
|
268
|
+
// Auto-discovered MCP/CLI tool entries are a third bucket, not "skills
|
|
269
|
+
// with a lower bar" — their documents are just a bare name (add-mcp/mise
|
|
270
|
+
// give no description), so they have almost no text to accumulate a false
|
|
271
|
+
// match from generic vocabulary in the first place. Verified against the
|
|
272
|
+
// full negative eval set: no mcp/cli-type entry ever scored above 0 as a
|
|
273
|
+
// false positive, while real matches (bare tool name against a prompt
|
|
274
|
+
// naming that tool) score far below the skill-noise ceiling that forces
|
|
275
|
+
// autoBar so high. Reusing autoBar for them was silently dropping real
|
|
276
|
+
// "use <tool>" matches (exa/github/playwright tools).
|
|
277
|
+
const toolBar = hybridToolThreshold ?? Math.min(autoBar, 80);
|
|
278
|
+
// Direct curated triggers may score below the corpus-wide calibrated bars
|
|
279
|
+
// (short CLI docs are especially sparse), so retrieval must not discard
|
|
280
|
+
// them before candidateBar can apply the exact-trigger trust path.
|
|
281
|
+
const minScore = 0;
|
|
282
|
+
// No early-return on an empty sparse hit list: a genuine paraphrase can
|
|
283
|
+
// share zero lexical vocabulary with any doc field at all, and dense
|
|
284
|
+
// retrieval below runs independently of minisearch - exiting here would
|
|
285
|
+
// deny it the one case it exists to rescue. Graph expansion/candidate
|
|
286
|
+
// building all handle an empty seed list fine already (see "hybrid graph:
|
|
287
|
+
// abstains when retrieval has no seed").
|
|
288
|
+
const sparse = sparseRetrieve(docs, prompt, { k: Math.max(k * 4, 20), minScore });
|
|
289
|
+
|
|
290
|
+
const graph = loadCapabilityGraph(graphPath);
|
|
291
|
+
const expanded = expandWithGraph(sparse, docs, graph, { boost: graphBoost }).map((candidate) => ({
|
|
292
|
+
...candidate,
|
|
293
|
+
score:
|
|
294
|
+
candidate.score *
|
|
295
|
+
(factors?.get(candidate.doc.id) ?? 1.0) *
|
|
296
|
+
actionAlignmentFactor(candidate.doc, prompt),
|
|
297
|
+
}));
|
|
298
|
+
const candidates = expanded
|
|
299
|
+
.filter((candidate) => matchesSuggestKind(candidate.doc.type, suggest))
|
|
300
|
+
.map((candidate) => {
|
|
301
|
+
const bar = candidateBar(candidate, prompt, { autoBar, recipeBar, toolBar });
|
|
302
|
+
return { ...candidate, bar };
|
|
303
|
+
});
|
|
304
|
+
const filtered = candidates.filter((candidate) => {
|
|
305
|
+
const bar = candidateBar(candidate, prompt, { autoBar, recipeBar, toolBar });
|
|
306
|
+
return candidate.score >= bar && classifyCandidate(candidate, bar).tier !== "irrelevant_but_keyword_matched";
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Dense retrieval (core/dense-embedder.mjs, BGE-M3) is a second,
|
|
310
|
+
// meaning-based channel additive to the lexical one above - it runs
|
|
311
|
+
// regardless of what sparse found, so a genuine paraphrase with little or
|
|
312
|
+
// no shared vocabulary (the gap margin-gate/threshold tuning on lexical
|
|
313
|
+
// scores alone provably cannot close - see docs/router-eval-set.jsonl's
|
|
314
|
+
// e2e-testing miss) still gets a chance. Fails soft to [] whenever the
|
|
315
|
+
// model isn't installed/reachable or a caller opts out (push hook does -
|
|
316
|
+
// see universal-hook.mjs - a fresh process per keystroke can't amortize a
|
|
317
|
+
// 500MB+ model load), so every caller sees identical output to before
|
|
318
|
+
// whenever dense doesn't contribute. minScore:0 is deliberate - the real
|
|
319
|
+
// gate is `bar` below, applied after factors/actionAlignment, the same
|
|
320
|
+
// two-stage "loose net, then precise bar" shape the lexical path above
|
|
321
|
+
// already uses.
|
|
322
|
+
const filteredIds = new Set(filtered.map((candidate) => candidate.doc.id));
|
|
323
|
+
let denseOnly = [];
|
|
324
|
+
if (denseEnabled) {
|
|
325
|
+
const denseHits = await denseRetrieve(docs, prompt, {
|
|
326
|
+
k: Math.max(k * 4, 20),
|
|
327
|
+
minScore: 0,
|
|
328
|
+
block: denseBlock,
|
|
329
|
+
});
|
|
330
|
+
denseOnly = denseHits
|
|
331
|
+
.filter((hit) => matchesSuggestKind(hit.doc.type, suggest) && !filteredIds.has(hit.id))
|
|
332
|
+
.map((hit) => ({
|
|
333
|
+
...hit,
|
|
334
|
+
score: hit.score * (factors?.get(hit.doc.id) ?? 1.0) * actionAlignmentFactor(hit.doc, prompt),
|
|
335
|
+
bar: denseThreshold,
|
|
336
|
+
terms: [],
|
|
337
|
+
}))
|
|
338
|
+
.filter((hit) => hit.score >= denseThreshold);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const candidatesForLanes = [...filtered, ...denseOnly];
|
|
342
|
+
if (candidatesForLanes.length === 0) {
|
|
343
|
+
if (!includeWeak) return [];
|
|
344
|
+
return candidates.slice(0, k).map((candidate) => formatResult(candidate, candidate.bar, peakednessRatio));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Tools and skills get their own reserved k slots, not one shared slice.
|
|
348
|
+
// Found live: real callers (push hook, MCP route tool, router-cli route)
|
|
349
|
+
// all call with suggest:"any", so a query where BOTH a skill and a tool
|
|
350
|
+
// are genuinely relevant used to have them compete for the same k slots
|
|
351
|
+
// — a strong skill match could silently crowd a real tool match (or vice
|
|
352
|
+
// versa) out of the result entirely, even though a task usually wants
|
|
353
|
+
// both side by side (the tool to do it, the skill for how to do it well).
|
|
354
|
+
const lanes = new Map();
|
|
355
|
+
for (const candidate of candidatesForLanes) {
|
|
356
|
+
const kind = capabilityKind(candidate.doc.type);
|
|
357
|
+
if (!lanes.has(kind)) lanes.set(kind, []);
|
|
358
|
+
lanes.get(kind).push(candidate);
|
|
359
|
+
}
|
|
360
|
+
const laneWinners = [...lanes.values()].flatMap((laneCandidates) =>
|
|
361
|
+
reciprocalRankFusion([gateLane(laneCandidates, peakednessRatio)]).slice(0, k),
|
|
362
|
+
);
|
|
363
|
+
const fused = laneWinners.sort(
|
|
364
|
+
(a, b) => (b.score ?? 0) - (a.score ?? 0) || a.doc.id.localeCompare(b.doc.id),
|
|
365
|
+
);
|
|
366
|
+
const routed = fused.map((item) => formatResult(item, item.bar, peakednessRatio));
|
|
367
|
+
if (!includeWeak || routed.length >= k) return routed;
|
|
368
|
+
|
|
369
|
+
const routedIds = new Set(routed.map((item) => item.id));
|
|
370
|
+
const weak = candidates
|
|
371
|
+
.filter((candidate) => !routedIds.has(candidate.doc.id))
|
|
372
|
+
.slice(0, k - routed.length)
|
|
373
|
+
.map((candidate) => formatResult(candidate, candidate.bar, peakednessRatio));
|
|
374
|
+
return [...routed, ...weak];
|
|
375
|
+
}
|