dsh-skill-folder 0.3.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/lib/render.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * render.js — CatalogRenderer.
3
+ *
4
+ * Render the folded catalog text. The surrounding framing is copied VERBATIM
5
+ * from the host's renderCatalogMessage / renderCatalogUpdate
6
+ * (@deepseek-ai/dsh-tool-skill/lib/index.js :216-264) so the model sees the
7
+ * same <system-reminder>/<available_skills>/loading/direct-call guidance —
8
+ * only the entry lines and an optional footer differ.
9
+ *
10
+ * - core entries: description unlimited (safety bottom line).
11
+ * - dynamic entries: description truncated to maxDescLength (default 100).
12
+ * - footer (safety net): names of unselected, non-denied skills so the
13
+ * model knows they exist ("never lose a skill").
14
+ *
15
+ * Deterministic: entry order is whatever selectEntries produced
16
+ * ([...core(config), ...dynamic(name)]); footer order follows pool order.
17
+ */
18
+
19
+ /** Escape model-facing prose embedded inside skill markup (host parity). */
20
+ function escapeText(value) {
21
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
22
+ }
23
+
24
+ /**
25
+ * Normalize whitespace and length-bound a description.
26
+ * @param {string} value
27
+ * @param {number} max - max length; pass Infinity for core (unlimited).
28
+ * @returns {string}
29
+ */
30
+ export function desc(value, max) {
31
+ const normalized = String(value || "").replaceAll(/\s+/g, " ").trim();
32
+ if (max === Infinity) return normalized;
33
+ const limit = Number.isFinite(Number(max)) ? Math.max(0, Math.floor(Number(max))) : 100;
34
+ if (limit === 0) return "";
35
+ if (normalized.length <= limit) return normalized;
36
+ if (limit <= 3) return normalized.slice(0, limit);
37
+ return `${normalized.slice(0, limit - 3)}...`;
38
+ }
39
+
40
+ /**
41
+ * Render the folded catalog text.
42
+ * @param {Array<{name: string, description: string}>} selected - core + dynamic entries.
43
+ * @param {Array<{name: string, description: string}>} footer - unselected, non-denied entries.
44
+ * @param {object} opts - { update?: boolean, maxDescLength?: number, core?: Array<string>|Set<string> }
45
+ * @returns {string}
46
+ */
47
+ export function renderCatalogText(selected, footer, opts = {}) {
48
+ const maxDescLength = opts.maxDescLength ?? 100;
49
+ const coreNames =
50
+ opts.core instanceof Set
51
+ ? opts.core
52
+ : Array.isArray(opts.core)
53
+ ? new Set(opts.core)
54
+ : new Set();
55
+ const update = opts.update === true;
56
+ const entries = Array.isArray(selected) ? selected : [];
57
+ const extra = Array.isArray(footer) ? footer : [];
58
+
59
+ const lines = entries.map((entry) => {
60
+ const max = coreNames.has(entry.name) ? Infinity : maxDescLength;
61
+ return `- \`${entry.name}\`: ${escapeText(desc(entry.description, max))}`;
62
+ });
63
+
64
+ const header = update
65
+ ? [
66
+ "<system-reminder>",
67
+ "The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:",
68
+ "",
69
+ "<available_skills>",
70
+ ]
71
+ : [
72
+ "<system-reminder>",
73
+ "A skill is a reusable set of task-specific instructions. The following skills are available in this session:",
74
+ "",
75
+ "<available_skills>",
76
+ ];
77
+
78
+ const body = update
79
+ ? [
80
+ "</available_skills>",
81
+ "",
82
+ "Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.",
83
+ "A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.",
84
+ ]
85
+ : [
86
+ "</available_skills>",
87
+ "",
88
+ "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
89
+ "If the task clearly needs a capability but no skill description clearly matches, call the `skill_search` tool with a short intent to find the most relevant skill.",
90
+ "A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.",
91
+ ];
92
+
93
+ const parts = [...header, ...lines, ...body];
94
+ if (extra.length > 0) {
95
+ const names = extra.map((e) => `\`${e.name}\``).join(", ");
96
+ parts.push(
97
+ `Additional skills exist in this session: ${names}. Call the \`skill\` tool with the exact name to load one if the task calls for it.`,
98
+ );
99
+ }
100
+ parts.push("</system-reminder>");
101
+ return parts.join("\n");
102
+ }
package/lib/select.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * select.js — StaticEntrySelector (KV-cache-stable).
3
+ *
4
+ * v0.2.0: static selection — the rendered catalog depends ONLY on the skill
5
+ * set (which is fixed for a session), NEVER on the per-step query. This keeps
6
+ * the catalog message byte-identical across turns, so the prompt-cache prefix
7
+ * never invalidates (the dynamic per-query variant destroyed the KV cache).
8
+ *
9
+ * Ordering:
10
+ * 1. pool = entries minus deny (exact name or "prefix*" pattern).
11
+ * NOTE: core entries are NEVER denied — core is the safety
12
+ * bottom line; deny only filters non-core skills (a deny that
13
+ * names a core skill is ignored, so an admin cannot
14
+ * accidentally hide a security-critical skill).
15
+ * 2. core = cfg.core entries taken from the pool in CONFIG order
16
+ * (missing names are silently skipped — never throw).
17
+ * 3. rest = remaining pool entries sorted by name ascending
18
+ * (byte-stable, deterministic rendering).
19
+ * selected = [...core(config order), ...rest(name order)].
20
+ *
21
+ * There is no footer split: everything non-denied is listed (truncated
22
+ * descriptions), so the model sees every available skill — matching
23
+ * Anthropic's "metadata tier always loaded" progressive disclosure.
24
+ * Skill bodies are still loaded on demand via the `skill` tool.
25
+ */
26
+ import { matchesAnyPattern } from "./pattern.js";
27
+
28
+ /**
29
+ * @param {Array<{name: string, description: string}>} entries - catalog source.entries (host-normalized).
30
+ * @param {object} cfg - merged config (core/deny).
31
+ * @returns {Array<{name: string, description: string}>} static ordered selection.
32
+ */
33
+ export function selectEntries(entries, cfg) {
34
+ const source = Array.isArray(entries) ? entries : [];
35
+ const coreOrder = Array.isArray(cfg.core)
36
+ ? cfg.core.map((n) => String(n).trim()).filter(Boolean)
37
+ : [];
38
+ const coreSet = new Set(coreOrder);
39
+ const denyPatterns = Array.isArray(cfg.deny)
40
+ ? cfg.deny.map((n) => String(n).trim()).filter(Boolean)
41
+ : [];
42
+ // Core skills are exempt from deny (safety bottom line): a deny naming a
43
+ // core skill is ignored rather than hiding a security-critical skill.
44
+ const pool = source.filter(
45
+ (e) => coreSet.has(e.name) || !matchesAnyPattern(e.name, denyPatterns),
46
+ );
47
+
48
+ // P0 core — config order, duplicates deduped, missing silently skipped.
49
+ const core = [];
50
+ const picked = new Set();
51
+ for (const name of coreOrder) {
52
+ if (picked.has(name)) continue;
53
+ const entry = pool.find((e) => e.name === name);
54
+ if (entry) {
55
+ core.push(entry);
56
+ picked.add(entry.name);
57
+ }
58
+ }
59
+
60
+ // Remaining, name-ascending (byte-stable).
61
+ const rest = pool
62
+ .filter((e) => !picked.has(e.name))
63
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
64
+
65
+ return [...core, ...rest];
66
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * semantic.js — local semantic retrieval leg via Ollama bge-m3 (2026-08-30).
3
+ *
4
+ * Complements the BM25 lexical leg (bm25.js) with a cross-lingual semantic
5
+ * leg: Chinese intents can hit English-only tool/skill descriptions that
6
+ * share no surface tokens (the documented BM25 gap). Uses the machine's
7
+ * local Ollama (`http://127.0.0.1:11434/api/embed`, model bge-m3 — 1024 dims,
8
+ * ~1.2GB, already installed per ~/.dsh config). No npm deps, no network egress.
9
+ *
10
+ * Hard-fail design: every external path (fetch, fs cache) is optional. If
11
+ * Ollama is down, the cache is unreadable, or anything throws, callers get
12
+ * null/[] and their existing BM25 path just keeps working.
13
+ *
14
+ * Cache: document embeddings are keyed by text and persisted to
15
+ * `~/.dsh/state/semantic-cache.json` (fingerprint = hash of all doc texts), so
16
+ * the ~10s cold start for 300+ tools happens once, not per session.
17
+ * Query embeddings are cached in memory only (per-process, tiny).
18
+ */
19
+
20
+ const OLLAMA_BASE = "http://127.0.0.1:11434";
21
+ const DEFAULT_MODEL = "bge-m3";
22
+ const EMBED_TIMEOUT_MS = 8000;
23
+ const CACHE_FILE = ".dsh/state/semantic-cache.json";
24
+
25
+ import fs from "node:fs";
26
+
27
+ /* ------------------------------------------------------------------ */
28
+ /* hash + fs helpers (all fail-safe) */
29
+ /* ------------------------------------------------------------------ */
30
+
31
+ /** FNV-1a 32-bit hex — fast, dependency-free fingerprint. */
32
+ function hash(text) {
33
+ let h = 0x811c9dc5;
34
+ const s = String(text || "");
35
+ for (let i = 0; i < s.length; i++) {
36
+ h ^= s.charCodeAt(i);
37
+ h = (h * 0x01000193) >>> 0;
38
+ }
39
+ return h.toString(16).padStart(8, "0");
40
+ }
41
+
42
+ function homeDir() {
43
+ return process.env.USERPROFILE || process.env.HOME || "";
44
+ }
45
+
46
+ function readCacheFile() {
47
+ try {
48
+ const p = homeDir() + "/" + CACHE_FILE;
49
+ if (!p || !fs.existsSync(p)) return null;
50
+ const raw = fs.readFileSync(p, "utf8");
51
+ const j = JSON.parse(raw);
52
+ if (j && typeof j === "object") return j;
53
+ } catch {
54
+ /* unreadable → miss */
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function writeCacheFile(obj) {
60
+ try {
61
+ const p = homeDir() + "/" + CACHE_FILE;
62
+ const dir = p.slice(0, p.lastIndexOf("/"));
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ fs.writeFileSync(p, JSON.stringify(obj), "utf8");
65
+ } catch {
66
+ /* cache write is best-effort */
67
+ }
68
+ }
69
+
70
+ /* ------------------------------------------------------------------ */
71
+ /* Ollama embedding (fail-safe fetch) */
72
+ /* ------------------------------------------------------------------ */
73
+
74
+ let _model = DEFAULT_MODEL;
75
+ let _base = OLLAMA_BASE;
76
+
77
+ /**
78
+ * Configure the endpoint (called by plugin apply with cfg.ollamaBase/model).
79
+ * @param {{ollamaBase?: string, embedModel?: string}} cfg
80
+ */
81
+ export function configureSemantic(cfg = {}) {
82
+ if (cfg && typeof cfg === "object") {
83
+ if (typeof cfg.ollamaBase === "string" && cfg.ollamaBase) _base = cfg.ollamaBase;
84
+ if (typeof cfg.embedModel === "string" && cfg.embedModel) _model = cfg.embedModel;
85
+ }
86
+ }
87
+
88
+ /** One embedding vector for a text, or null on any failure. */
89
+ export async function embedText(text, timeoutMs = EMBED_TIMEOUT_MS) {
90
+ const s = String(text || "").trim();
91
+ if (!s) return null;
92
+ try {
93
+ const ctrl = new AbortController();
94
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
95
+ try {
96
+ const res = await fetch(_base + "/api/embed", {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({ model: _model, input: s }),
100
+ signal: ctrl.signal,
101
+ });
102
+ if (!res.ok) return null;
103
+ const j = await res.json();
104
+ const v = j?.embeddings?.[0];
105
+ return Array.isArray(v) && v.length > 0 ? v : null;
106
+ } finally {
107
+ clearTimeout(timer);
108
+ }
109
+ } catch {
110
+ return null; // offline / timeout / bad response → lexical leg only
111
+ }
112
+ }
113
+
114
+ /** Cosine similarity in [0,1] (both vectors plain arrays). */
115
+ export function cosine(a, b) {
116
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return 0;
117
+ let dot = 0;
118
+ let na = 0;
119
+ let nb = 0;
120
+ for (let i = 0; i < a.length; i++) {
121
+ dot += a[i] * b[i];
122
+ na += a[i] * a[i];
123
+ nb += b[i] * b[i];
124
+ }
125
+ if (na === 0 || nb === 0) return 0;
126
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
127
+ }
128
+
129
+ /* ------------------------------------------------------------------ */
130
+ /* Semantic index with persistent doc-embedding cache */
131
+ /* ------------------------------------------------------------------ */
132
+
133
+ /**
134
+ * Build a semantic index over docs. Doc embeddings come from the on-disk
135
+ * cache when the fingerprint matches; otherwise they are computed once and
136
+ * persisted. Never blocks forever: total batch time is bounded and a failure
137
+ * returns an index whose vectors are simply empty (lexical leg still works).
138
+ *
139
+ * @param {Array<{id: string, text: string}>} docs
140
+ * @param {number} [timeoutMs] per-embed timeout
141
+ * @returns {Promise<{vectors: Map<string, number[]>, available: boolean}>}
142
+ */
143
+ export async function buildSemanticIndex(docs, timeoutMs = EMBED_TIMEOUT_MS) {
144
+ const out = { vectors: new Map(), available: false };
145
+ const list = Array.isArray(docs) ? docs.filter((d) => d && d.id && d.text) : [];
146
+ if (list.length === 0) return out;
147
+
148
+ // Fast path: cache hit.
149
+ const fp = hash(list.map((d) => d.id + "\u0000" + d.text).join("\u0001"));
150
+ const cache = readCacheFile();
151
+ const cached = cache && cache.fp === fp && cache.vec && typeof cache.vec === "object" ? cache.vec : null;
152
+ if (cached) {
153
+ for (const d of list) {
154
+ const v = cached[d.id];
155
+ if (Array.isArray(v) && v.length > 0) out.vectors.set(d.id, v);
156
+ }
157
+ if (out.vectors.size === list.length) {
158
+ out.available = true;
159
+ return out;
160
+ }
161
+ }
162
+
163
+ // Miss: embed each doc (sequential — CPU-bound local model, avoids
164
+ // overloading Ollama), bounded by an overall deadline.
165
+ const deadline = Date.now() + 60_000;
166
+ const fresh = {};
167
+ let ok = 0;
168
+ for (const d of list) {
169
+ if (Date.now() > deadline) break;
170
+ const v = await embedText(d.text, timeoutMs);
171
+ if (v) {
172
+ fresh[d.id] = v;
173
+ out.vectors.set(d.id, v);
174
+ ok++;
175
+ }
176
+ }
177
+ if (ok === list.length) {
178
+ out.available = true;
179
+ writeCacheFile({ fp, vec: fresh }); // persist only on full success
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /**
185
+ * Rank docs by semantic similarity to a query.
186
+ * @param {Map<string, number[]>} vectors id → vector
187
+ * @param {Array<{id: string, text: string}>} docs
188
+ * @param {string} query
189
+ * @returns {Promise<Array<{id: string, score: number}>>} sorted desc, score = cosine
190
+ */
191
+ export async function searchSemantic(vectors, docs, query) {
192
+ const q = String(query || "").trim();
193
+ if (!q || !vectors || vectors.size === 0) return [];
194
+ const qv = await embedText(q);
195
+ if (!qv) return [];
196
+ const scored = [];
197
+ for (const d of docs) {
198
+ const v = vectors.get(d.id);
199
+ if (!v) continue;
200
+ const c = cosine(qv, v);
201
+ if (c > 0) scored.push({ id: d.id, score: c });
202
+ }
203
+ scored.sort((a, b) => b.score - a.score);
204
+ return scored;
205
+ }
206
+
207
+ /* ------------------------------------------------------------------ */
208
+ /* RRF hybrid: merge a BM25 ranking and a semantic ranking */
209
+ /* ------------------------------------------------------------------ */
210
+
211
+ const RRF_K = 60;
212
+
213
+ /**
214
+ * Reciprocal-rank fusion of two ranked id lists.
215
+ * @param {Array<{id: string}>} bm25Ranked BM25 hits in rank order
216
+ * @param {Array<{id: string}>} semRanked semantic hits in rank order
217
+ * @param {number} topK
218
+ * @returns {Array<{id: string, rrf: number, from: string}>} fused, sorted desc
219
+ */
220
+ export function rrfFuse(bm25Ranked, semRanked, topK) {
221
+ const scores = new Map();
222
+ const from = new Map();
223
+ bm25Ranked.forEach((h, i) => {
224
+ const key = h.id;
225
+ scores.set(key, (scores.get(key) || 0) + 1 / (RRF_K + i + 1));
226
+ from.set(key, (from.get(key) || "") + "bm25 ");
227
+ });
228
+ semRanked.forEach((h, i) => {
229
+ const key = h.id;
230
+ scores.set(key, (scores.get(key) || 0) + 1 / (RRF_K + i + 1));
231
+ from.set(key, (from.get(key) || "") + "sem ");
232
+ });
233
+ const k = Number(topK);
234
+ const n = Number.isFinite(k) && k > 0 ? Math.floor(k) : 5;
235
+ return [...scores.entries()]
236
+ .sort((a, b) => b[1] - a[1])
237
+ .slice(0, n)
238
+ .map(([id, r]) => ({ id, rrf: r, from: from.get(id)?.trim() || "" }));
239
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * skill-search.js — pure retrieval logic (BM25 over name + description +
3
+ * aliases). Zero dependencies — testable standalone. The defineTool wrapper
4
+ * lives in index.js so this module never pulls host packages.
5
+ *
6
+ * v0.3.0 (2026-08-30): semantic hybrid leg. `searchSkillsHybrid` fuses BM25
7
+ * with a local bge-m3 semantic ranking (RRF) — Chinese intents can hit
8
+ * English-only skills that share no surface tokens. The semantic index is
9
+ * injected by the caller (built lazily, disk-cached); the pure `searchSkills`
10
+ * stays as the sync/offline fallback.
11
+ */
12
+ import { buildIndex, search } from "./bm25.js";
13
+ import { matchesAnyPattern } from "./pattern.js";
14
+ import { rrfFuse } from "./semantic.js";
15
+
16
+ /**
17
+ * Filter a snapshot skill list to the searchable pool (model-invocable +
18
+ * deny removed). Pure function: the model-invocation predicate is injected
19
+ * so this module stays zero-dependency; the host wrapper passes the same
20
+ * rule the host catalog uses (`skill.invocation.modelInvocable === true`,
21
+ * dsh-tool-skill :195). Defaults to allow-all for standalone/test use.
22
+ * @param {Array<{name: string, description: string, invocation?: object}>} skills
23
+ * @param {object} cfg - merged config (deny).
24
+ * @param {(skill: object) => boolean} [isModelInvocableFn] - injected predicate.
25
+ * @returns {Array<{name: string, description: string}>}
26
+ */
27
+ export function filterPool(skills, cfg, isModelInvocableFn) {
28
+ const denyPatterns = Array.isArray(cfg.deny)
29
+ ? cfg.deny.map((n) => String(n).trim()).filter(Boolean)
30
+ : [];
31
+ const canInvoke = typeof isModelInvocableFn === "function" ? isModelInvocableFn : () => true;
32
+ return (Array.isArray(skills) ? skills : [])
33
+ .filter((s) => s && typeof s.name === "string")
34
+ .filter((s) => canInvoke(s))
35
+ .filter((s) => !matchesAnyPattern(s.name, denyPatterns))
36
+ .map((s) => ({ name: s.name, description: s.description || "" }));
37
+ }
38
+
39
+ /**
40
+ * Rank the pool against an intent using BM25 over name + description +
41
+ * aliases. Aliases let Chinese intents hit English-only skills.
42
+ * @param {Array<{name, description}>} pool
43
+ * @param {string} intent
44
+ * @param {object} cfg - merged config (aliases).
45
+ * @param {number} topK - max results; non-finite or <=0 falls back to 5.
46
+ * @returns {Array<{name: string, description: string, score: number}>}
47
+ * score is the BM25 rank (1 = most relevant), monotonic with the list order.
48
+ */
49
+ export function searchSkills(pool, intent, cfg, topK) {
50
+ const n = Number(topK);
51
+ const k = Number.isFinite(n) && n > 0 ? Math.floor(n) : 5;
52
+ const src = Array.isArray(pool) ? pool : [];
53
+ const q = String(intent || "").trim();
54
+ if (!src.length || !q) return [];
55
+ const aliasMap = cfg.aliases && typeof cfg.aliases === "object" ? cfg.aliases : {};
56
+ const docs = src.map((s) => {
57
+ const aliasWords = Array.isArray(aliasMap[s.name]) ? aliasMap[s.name] : [];
58
+ return { id: s.name, text: `${s.name} ${s.description || ""} ${aliasWords.join(" ")}` };
59
+ });
60
+ const index = buildIndex(docs);
61
+ const hits = search(index, q, k);
62
+ return hits.map((i, idx) => ({ ...src[i], score: idx + 1 }));
63
+ }
64
+
65
+ /** Build the BM25 doc corpus for a pool (shared by both search functions). */
66
+ function poolDocs(src, aliasMap) {
67
+ return src.map((s) => {
68
+ const aliasWords = Array.isArray(aliasMap[s.name]) ? aliasMap[s.name] : [];
69
+ return { id: s.name, text: `${s.name} ${s.description || ""} ${aliasWords.join(" ")}` };
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Hybrid search: BM25 + semantic (bge-m3) fused by RRF. The semantic leg is
75
+ * optional — pass `semIndex` (from buildSemanticIndex) or null; when absent
76
+ * or unavailable this falls back to pure BM25 (identical to searchSkills).
77
+ *
78
+ * @param {Array<{name, description}>} pool
79
+ * @param {string} intent
80
+ * @param {object} cfg - merged config (aliases).
81
+ * @param {number} topK
82
+ * @param {{vectors: Map<string, number[]>, available: boolean}|null} [semIndex]
83
+ * @param {(query: string) => Promise<Array<{id: string, score: number}>>} [semSearchFn]
84
+ * injectable semantic search (defaults to semantic.searchSemantic with the
85
+ * pool docs); tests may stub it.
86
+ * @returns {Promise<Array<{name: string, description: string, score: number}>>}
87
+ */
88
+ export async function searchSkillsHybrid(pool, intent, cfg, topK, semIndex, semSearchFn) {
89
+ const n = Number(topK);
90
+ const k = Number.isFinite(n) && n > 0 ? Math.floor(n) : 5;
91
+ const src = Array.isArray(pool) ? pool : [];
92
+ const q = String(intent || "").trim();
93
+ if (!src.length || !q) return [];
94
+ const aliasMap = cfg.aliases && typeof cfg.aliases === "object" ? cfg.aliases : {};
95
+ const docs = poolDocs(src, aliasMap);
96
+
97
+ const bm25Index = buildIndex(docs);
98
+ const bm25Hits = search(bm25Index, q, k * 2).map((i) => ({ id: src[i].name }));
99
+
100
+ let fused = bm25Hits;
101
+ if (semIndex && semIndex.available && semIndex.vectors && semIndex.vectors.size > 0) {
102
+ try {
103
+ const semRanked = semSearchFn
104
+ ? await semSearchFn(q)
105
+ : await import("./semantic.js").then((m) => m.searchSemantic(semIndex.vectors, docs, q));
106
+ fused = rrfFuse(bm25Hits, semRanked.slice(0, k * 2), k);
107
+ } catch {
108
+ /* semantic failure → BM25 only */
109
+ }
110
+ }
111
+ const byName = new Map(src.map((s) => [s.name, s]));
112
+ return fused.map((h, idx) => {
113
+ const s = byName.get(h.id) || { name: h.id, description: "" };
114
+ return { ...s, score: idx + 1 };
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Auto-route hint (P1): sync, cheap, conservative. Returns the name of ONE
120
+ * skill that the user message clearly points at, or null. Uses the alias
121
+ * table in both directions (alias word in query → skill; query token in
122
+ * skill name) plus a BM25 top hit as a second candidate — but only when the
123
+ * top hit shares a surface token with the query (so "你好" never routes).
124
+ * @param {Array<{name, description}>} pool - filtered searchable pool.
125
+ * @param {string} userText - the latest user message text.
126
+ * @param {object} cfg - merged config (aliases).
127
+ * @returns {string|null} skill name to suggest, or null.
128
+ */
129
+ export function routeHint(pool, userText, cfg) {
130
+ const q = String(userText || "").trim();
131
+ if (!q || !Array.isArray(pool) || pool.length === 0) return null;
132
+ const aliasMap = cfg.aliases && typeof cfg.aliases === "object" ? cfg.aliases : {};
133
+ const names = new Set(pool.map((s) => s.name));
134
+ const ql = q.toLowerCase();
135
+
136
+ // Direct alias hit: any alias word appearing in the query.
137
+ for (const [skill, words] of Object.entries(aliasMap)) {
138
+ if (!names.has(skill) || !Array.isArray(words)) continue;
139
+ for (const w of words) {
140
+ const wl = String(w).toLowerCase().trim();
141
+ if (wl.length >= 2 && ql.includes(wl)) return skill;
142
+ }
143
+ }
144
+
145
+ // Query token hits a skill name directly: token "memory" → "viking-memory-guide".
146
+ const tokens = ql.match(/[a-z0-9][a-z0-9_+-]{1,}/g) || [];
147
+ for (const t of tokens) {
148
+ if (t.length < 3) continue;
149
+ for (const s of pool) {
150
+ if (s.name.toLowerCase().includes(t)) return s.name;
151
+ }
152
+ }
153
+
154
+ return null;
155
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * tool-skill-search.js — defineTool wrapper for `skill_search`.
3
+ *
4
+ * Pure retrieval logic lives in ./skill-search.js (zero deps, testable);
5
+ * this module only wraps it in the host tool contract. Requires the
6
+ * `skills` service for the live snapshot (injected in index.js).
7
+ *
8
+ * v0.3.0 (2026-08-30): semantic hybrid leg — skill_search now fuses BM25
9
+ * with local bge-m3 similarity (RRF) when the semantic index is available;
10
+ * any failure degrades to pure BM25.
11
+ */
12
+ import { defineTool } from "@deepseek-ai/dsh-tools";
13
+ import { filterPool, searchSkills, searchSkillsHybrid } from "./skill-search.js";
14
+
15
+ /**
16
+ * Build the `skill_search` tool definition.
17
+ * @param {object} ctx - cordis context (ctx.skills).
18
+ * @param {object} cfg - merged config (deny/aliases).
19
+ * @param {object} logger - cordis logger.
20
+ * @param {() => Promise<{vectors: Map, available: boolean}>} [getSemIndex]
21
+ * lazy semantic-index provider (built by index.js); null disables the leg.
22
+ * @returns {object} defineTool payload.
23
+ */
24
+ export function buildSkillSearchTool(ctx, cfg, logger, getSemIndex) {
25
+ return defineTool({
26
+ name: "skill_search",
27
+ description:
28
+ "Search available skills by task intent. Call this when a task clearly needs a capability but you are unsure which skill to load. Returns the most relevant skills; then call the `skill` tool with the exact name to load full instructions.",
29
+ parameters: {
30
+ intent: {
31
+ type: "string",
32
+ required: true,
33
+ description:
34
+ "Short description of the capability you need, e.g. 'debug a crash', 'delegate to subagent', 'review code'.",
35
+ },
36
+ k: {
37
+ type: "number",
38
+ description: "Max results (default 5).",
39
+ },
40
+ },
41
+ output: {
42
+ schema: {
43
+ type: "array",
44
+ items: {
45
+ type: "object",
46
+ additionalProperties: false,
47
+ properties: {
48
+ name: { type: "string" },
49
+ description: { type: "string" },
50
+ score: { type: "number", description: "relevance rank, 1 = most relevant" },
51
+ },
52
+ },
53
+ },
54
+ render: (_a, value) => [
55
+ {
56
+ type: "text",
57
+ text: Array.isArray(value) && value.length
58
+ ? value.map((s, i) => `${i + 1}. ${s.name} — ${s.description || ""}`).join("\n")
59
+ : "No matching skills found for this intent.",
60
+ },
61
+ ],
62
+ },
63
+ async execute(args, exec) {
64
+ const intent = typeof args?.intent === "string" ? args.intent.trim() : "";
65
+ if (!intent) return [];
66
+ const lookup = {
67
+ cwd: exec?.agent?.session?.header?.cwd,
68
+ scope: exec?.agent,
69
+ signal: exec?.signal,
70
+ };
71
+ const snapshot = await ctx.skills.snapshot(lookup);
72
+ const skills = (snapshot && snapshot.skills) || [];
73
+ const pool = filterPool(skills, cfg, (s) => s.invocation?.modelInvocable === true);
74
+ let ranked;
75
+ if (typeof getSemIndex === "function") {
76
+ // Build the SAME doc corpus searchSkillsHybrid uses internally
77
+ // (name + description + aliases) so the cache fingerprint matches.
78
+ const aliasMap = cfg.aliases && typeof cfg.aliases === "object" ? cfg.aliases : {};
79
+ const docs = pool.map((s) => ({
80
+ id: s.name,
81
+ text: `${s.name} ${s.description || ""} ${(Array.isArray(aliasMap[s.name]) ? aliasMap[s.name] : []).join(" ")}`,
82
+ }));
83
+ const sem = await getSemIndex(docs);
84
+ ranked = await searchSkillsHybrid(pool, intent, cfg, args?.k, sem);
85
+ } else {
86
+ ranked = searchSkills(pool, intent, cfg, args?.k);
87
+ }
88
+ logger?.info?.("skill_search(%s) → %d of %d skills", intent.slice(0, 40), ranked.length, pool.length);
89
+ return ranked.map((s) => ({ name: s.name, description: s.description || "", score: s.score }));
90
+ },
91
+ });
92
+ }