@lvy-jsd/dsh-tool-manager 0.2.0 → 0.4.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.
Files changed (52) hide show
  1. package/README.md +99 -2
  2. package/cordis.patch.yml +13 -0
  3. package/lib/exempt.d.ts +45 -0
  4. package/lib/exempt.d.ts.map +1 -0
  5. package/lib/exempt.js +122 -0
  6. package/lib/exempt.js.map +1 -0
  7. package/lib/index.d.ts +1 -0
  8. package/lib/index.d.ts.map +1 -1
  9. package/lib/index.js +243 -34
  10. package/lib/index.js.map +1 -1
  11. package/lib/manifest.d.ts +21 -0
  12. package/lib/manifest.d.ts.map +1 -0
  13. package/lib/manifest.js +112 -0
  14. package/lib/manifest.js.map +1 -0
  15. package/lib/metadata.d.ts +38 -0
  16. package/lib/metadata.d.ts.map +1 -0
  17. package/lib/metadata.js +102 -0
  18. package/lib/metadata.js.map +1 -0
  19. package/lib/router/bm25.d.ts +21 -0
  20. package/lib/router/bm25.d.ts.map +1 -0
  21. package/lib/router/bm25.js +63 -0
  22. package/lib/router/bm25.js.map +1 -0
  23. package/lib/router/index.d.ts +9 -0
  24. package/lib/router/index.d.ts.map +1 -0
  25. package/lib/router/index.js +422 -0
  26. package/lib/router/index.js.map +1 -0
  27. package/lib/router/sidecar.d.ts +34 -0
  28. package/lib/router/sidecar.d.ts.map +1 -0
  29. package/lib/router/sidecar.js +156 -0
  30. package/lib/router/sidecar.js.map +1 -0
  31. package/lib/router/types.d.ts +164 -0
  32. package/lib/router/types.d.ts.map +1 -0
  33. package/lib/router/types.js +7 -0
  34. package/lib/router/types.js.map +1 -0
  35. package/lib/settings.d.ts +32 -0
  36. package/lib/settings.d.ts.map +1 -0
  37. package/lib/settings.js +93 -0
  38. package/lib/settings.js.map +1 -0
  39. package/lib/types.d.ts +137 -1
  40. package/lib/types.d.ts.map +1 -1
  41. package/package.json +8 -4
  42. package/sidecar/bin/tool-router-sidecar +0 -0
  43. package/src/exempt.ts +148 -0
  44. package/src/index.ts +245 -35
  45. package/src/manifest.ts +116 -0
  46. package/src/metadata.ts +123 -0
  47. package/src/router/bm25.ts +68 -0
  48. package/src/router/index.ts +440 -0
  49. package/src/router/sidecar.ts +159 -0
  50. package/src/router/types.ts +149 -0
  51. package/src/settings.ts +108 -0
  52. package/src/types.ts +134 -3
@@ -0,0 +1,440 @@
1
+ // The retrieval + routing layer, merged into dsh-tool-manager.
2
+ //
3
+ // dsh-tool-manager gives the model a directory and on-demand loading, but the
4
+ // model still has to guess the right keywords and spend turns searching. This
5
+ // module adds the retrieval layer that makes an unbounded catalog usable:
6
+ //
7
+ // tools_find(query) -> ranked matches (BM25, model-free, always available)
8
+ // tools_route(query) -> ranked matches, then loads them (the router)
9
+ //
10
+ // Retrieval is BM25 by default: no model, no network, works offline, and scales
11
+ // to a large catalog. When a model is configured (config.model {provider,
12
+ // model}), tools_route re-ranks the BM25 candidates with the model, so a
13
+ // natural-language intent that shares no words with a tool name still finds it.
14
+ // That is the RAG-MCP pattern: retrieve a small candidate set, then rank it.
15
+ //
16
+ // The BM25 index lives in a Rust sidecar process (sidecar/, tantivy over a Unix
17
+ // socket) for headroom: the hot path is native and the TypeScript here is a
18
+ // thin client. A dependency-free JS BM25 index is kept as the always-available
19
+ // fallback, so retrieval never breaks if the sidecar binary is missing or the
20
+ // process dies.
21
+ //
22
+ // It reuses the core's `toolCatalog` service (passed in by the core's apply),
23
+ // so it shares the catalog, the load path, and the token accounting. Without a
24
+ // catalog it falls back to the raw tool schemas and its own token estimate;
25
+ // tools_route then only retrieves (there is no load path to drive).
26
+ //
27
+ // Tool definitions are built by hand (no @deepseek-ai/dsh-tools import) so the
28
+ // retrieval layer stays self-contained.
29
+
30
+ import { existsSync } from 'node:fs'
31
+ import { dirname, join } from 'node:path'
32
+ import { fileURLToPath } from 'node:url'
33
+ import { Bm25 } from './bm25.js'
34
+ import { Sidecar } from './sidecar.js'
35
+ import type {
36
+ Hit,
37
+ HostContext,
38
+ LlmService,
39
+ Logger,
40
+ RouterConfig,
41
+ SkillRef,
42
+ SkillsService,
43
+ ToolCatalogService,
44
+ ToolDef,
45
+ ToolRef,
46
+ } from './types.js'
47
+
48
+ const pluginDir = dirname(fileURLToPath(import.meta.url))
49
+ // This module compiles to lib/router/index.js; the package root is two levels
50
+ // up. The npm package ships a prebuilt binary under sidecar/bin/; a source
51
+ // checkout has a dev build under sidecar/target/release/. Prefer the shipped one.
52
+ const sidecarBin = [
53
+ join(pluginDir, '..', '..', 'sidecar', 'bin', 'tool-router-sidecar'),
54
+ join(pluginDir, '..', '..', 'sidecar', 'target', 'release', 'tool-router-sidecar'),
55
+ ].find(existsSync)
56
+ // One socket per plugin load: the plugin is loaded once per DSH process, but
57
+ // tests load it many times (across processes and within one process), and a
58
+ // shared fixed path would make them fight over the same socket. The PID plus a
59
+ // per-load sequence keeps every instance on its own socket.
60
+ let sidecarSocketSeq = 0
61
+ function sidecarSocketPath(): string {
62
+ sidecarSocketSeq += 1
63
+ return join(pluginDir, '..', '..', `.sidecar-${process.pid}-${sidecarSocketSeq}.sock`)
64
+ }
65
+
66
+ // --- helpers -----------------------------------------------------------------
67
+
68
+ function estimateTokens(schema: unknown): number {
69
+ const text = JSON.stringify(schema)
70
+ let wide = 0
71
+ for (const ch of text) if ((ch.codePointAt(0) ?? 0) > 0x2e7f) wide += 1
72
+ return Math.ceil((text.length - wide) / 4 + wide)
73
+ }
74
+
75
+ function fmtTokens(n: number): string {
76
+ return n >= 1000 ? `~${(n / 1000).toFixed(1)}k tok` : `~${n} tok`
77
+ }
78
+
79
+ function firstLine(text: string | undefined, max = 80): string {
80
+ const flat = String(text ?? '').replace(/\s+/g, ' ').trim()
81
+ const first = flat.split(/(?<=[.!?。!?])\s/)[0] ?? ''
82
+ return first.length > max ? `${first.slice(0, max - 1)}…` : first
83
+ }
84
+
85
+ function toolText(t: ToolRef): string {
86
+ return `${t.name} ${t.summary} ${t.description} ${t.group} ${t.category}`
87
+ }
88
+
89
+ function skillText(s: SkillRef): string {
90
+ return `${s.name} ${s.summary} ${s.whenToUse ?? ''}`
91
+ }
92
+
93
+ function formatFind(hits: Hit<ToolRef>[]): string {
94
+ if (!hits.length) return 'No tools matched. Try different words, or tools_ls("/") to browse the directory.'
95
+ const lines = hits.map((h, i) => {
96
+ const t = h.ref
97
+ const risk = t.highRisk ? ' [high risk]' : ''
98
+ return `${i + 1}. ${t.name}\n ${t.path} · ${fmtTokens(t.tokens)}${risk}\n ${t.summary}`
99
+ })
100
+ return `Best matches (load with tools_route, or tools_load by path):\n${lines.join('\n')}`
101
+ }
102
+
103
+ function formatSkillFind(hits: Hit<SkillRef>[]): string {
104
+ if (!hits.length) return 'No skills matched. Try different words.'
105
+ const lines = hits.map((h, i) => {
106
+ const s = h.ref
107
+ const extra = s.whenToUse ? ` — ${s.whenToUse}` : ''
108
+ return `${i + 1}. ${s.name}\n ${s.summary}${extra}`
109
+ })
110
+ return `Best matching skills (load one with skills_route):\n${lines.join('\n')}`
111
+ }
112
+
113
+ /** Build a tool definition by hand (no @deepseek-ai/dsh-tools dependency). */
114
+ function makeTool(tool: Omit<ToolDef, 'output'>): ToolDef {
115
+ return {
116
+ ...tool,
117
+ output: {
118
+ schema: { type: 'string' },
119
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
120
+ },
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Register the retrieval + routing tools (tools_find, tools_route, skills_find,
126
+ * skills_route) on the host. Called by the core's apply() after it provides the
127
+ * toolCatalog service, so the router reuses the catalog, the load path, and the
128
+ * token accounting.
129
+ */
130
+ export function registerRouter(ctx: HostContext, config: RouterConfig = {}, catalog?: ToolCatalogService): void {
131
+ const logger: Logger = (typeof ctx.logger === 'function' ? ctx.logger('tool-router') : ctx.logger) ?? console
132
+ const cat = (catalog ?? ctx.toolCatalog ?? (typeof ctx.get === 'function' ? ctx.get('toolCatalog') : undefined)) as ToolCatalogService | undefined
133
+ const bm25 = new Bm25<ToolRef>({ k1: config.k1, b: config.b })
134
+ const skillBm25 = new Bm25<SkillRef>({ k1: config.k1, b: config.b })
135
+ const topK = config.topK ?? 8
136
+ const maxBudget = config.maxBudget ?? 20000
137
+ const model = config.model ?? {}
138
+ const llm = (typeof ctx.get === 'function' ? ctx.get('llm') : undefined) as LlmService | undefined
139
+ const sidecar = new Sidecar({
140
+ bin: config.sidecar?.bin ?? sidecarBin ?? join(pluginDir, '..', '..', 'sidecar', 'bin', 'tool-router-sidecar'),
141
+ socket: config.sidecar?.socket ?? sidecarSocketPath(),
142
+ timeoutMs: config.sidecar?.timeoutMs,
143
+ logger,
144
+ })
145
+
146
+ // Refs aligned to index doc ids (the sidecar returns doc ids; we map them
147
+ // back to these refs). Kept in sync with the JS BM25 fallback.
148
+ let toolRefs: ToolRef[] = []
149
+ let skillRefs: SkillRef[] = []
150
+
151
+ /** The default working directory, used to scope the skill catalog view. */
152
+ function cwdOf(): string | undefined {
153
+ try {
154
+ const wd = typeof ctx.get === 'function' ? ctx.get('workingDirectory') : undefined
155
+ return (wd as { defaultDirectory?: string } | undefined)?.defaultDirectory
156
+ } catch {
157
+ return undefined
158
+ }
159
+ }
160
+
161
+ /** Rebuild the tool index (JS fallback + sidecar) from the catalog. */
162
+ function rebuild(): void {
163
+ let tools: ToolRef[]
164
+ if (cat && typeof cat.get === 'function') {
165
+ const c = cat.get()
166
+ tools = [...c.tools.values()].map((t) => ({
167
+ name: t.name,
168
+ path: `/${t.category}/${t.group}/${t.short}`,
169
+ summary: t.summary,
170
+ description: t.description,
171
+ group: t.group,
172
+ category: t.category,
173
+ tokens: t.tokens,
174
+ highRisk: t.highRisk === true,
175
+ }))
176
+ } else {
177
+ tools = ctx.tools.schemas().map((s) => ({
178
+ name: s.name,
179
+ path: s.name,
180
+ summary: firstLine(s.description),
181
+ description: s.description ?? '',
182
+ group: '',
183
+ category: '',
184
+ tokens: estimateTokens(s),
185
+ highRisk: false,
186
+ }))
187
+ }
188
+ toolRefs = tools
189
+ bm25.build(tools.map((t) => ({ ref: t, text: toolText(t) })))
190
+ void sidecar.index('tools', tools.map((t) => ({ ref: t, text: toolText(t) })))
191
+ }
192
+ rebuild()
193
+
194
+ if (cat) {
195
+ ctx.effect(() => ctx.on('tool-catalog/after-rebuild', () => rebuild()), 'tool-router.rebuild()')
196
+ }
197
+
198
+ // Tools register after this plugin's apply() runs (plugin load order), so the
199
+ // catalog is often empty on the first rebuild. Rebuild again, debounced, when
200
+ // the tool set changes so the index catches up without rebuilding per tool.
201
+ let rebuildTimer: NodeJS.Timeout | null = null
202
+ function scheduleRebuild(): void {
203
+ if (rebuildTimer) return
204
+ rebuildTimer = setTimeout(() => {
205
+ rebuildTimer = null
206
+ rebuild()
207
+ }, 200)
208
+ rebuildTimer.unref?.()
209
+ }
210
+ try {
211
+ ctx.effect(() => ctx.on('tools/change', () => { scheduleRebuild() }), 'tool-router.tools-change()')
212
+ } catch (caught) {
213
+ logger?.warn?.(`tool-router: tools/change hook skipped: ${caught instanceof Error ? caught.message : String(caught)}`)
214
+ }
215
+
216
+ /**
217
+ * Rebuild the skill index from the skill registry. Skills have the same
218
+ * progressive-disclosure shape as tools: a cheap summary (name + description)
219
+ * here, the full body loaded on demand by skills_route.
220
+ */
221
+ async function rebuildSkills(): Promise<void> {
222
+ // The skill registry is the cordis `skills` service, looked up via ctx.get
223
+ // (ctx.skills throws when the service is not injected into this plugin).
224
+ // list() takes {cwd, scope}; scope is left undefined for the global layer.
225
+ try {
226
+ const skillsApi = (typeof ctx.get === 'function' ? ctx.get('skills') : undefined) as SkillsService | undefined
227
+ if (!skillsApi || typeof skillsApi.list !== 'function') return
228
+ const cwd = cwdOf()
229
+ const skills = await skillsApi.list({ cwd })
230
+ const refs: SkillRef[] = skills
231
+ .filter((s) => s.invocation?.modelInvocable !== false)
232
+ .map((s) => ({
233
+ name: s.name,
234
+ summary: s.description ?? '',
235
+ whenToUse: s.whenToUse ?? '',
236
+ source: s.source,
237
+ provider: s.provider,
238
+ }))
239
+ skillRefs = refs
240
+ skillBm25.build(refs.map((s) => ({ ref: s, text: skillText(s) })))
241
+ void sidecar.index('skills', refs.map((s) => ({ ref: s, text: skillText(s) })))
242
+ } catch (caught) {
243
+ logger?.warn?.(`tool-router: skill index rebuild failed: ${caught instanceof Error ? caught.message : String(caught)}`)
244
+ }
245
+ }
246
+
247
+ // Start the sidecar, then rebuild the indexes. The initial rebuild() and
248
+ // rebuildSkills() runs fire before the sidecar is ready (and the catalog may
249
+ // still be empty at apply() time); rebuilding after start() re-reads the
250
+ // catalog and pushes a fresh index now that the sidecar is up.
251
+ void (async () => {
252
+ const ok = await sidecar.start()
253
+ if (!ok) return
254
+ rebuild()
255
+ void rebuildSkills()
256
+ })()
257
+
258
+ void rebuildSkills()
259
+ try {
260
+ ctx.effect(() => ctx.on('skills/change', () => { void rebuildSkills() }), 'tool-router.skills-rebuild()')
261
+ } catch (caught) {
262
+ logger?.warn?.(`tool-router: skills change hook skipped: ${caught instanceof Error ? caught.message : String(caught)}`)
263
+ }
264
+
265
+ // Kill the sidecar child and clear the pending rebuild on disposal.
266
+ ctx.effect(() => () => {
267
+ if (rebuildTimer) clearTimeout(rebuildTimer)
268
+ sidecar.stop()
269
+ }, 'tool-router.sidecar-stop()')
270
+
271
+ /** Search the tool index: sidecar first, JS BM25 fallback. */
272
+ async function searchTools(query: string, limit: number): Promise<Hit<ToolRef>[]> {
273
+ const hits = await sidecar.search('tools', query, limit)
274
+ if (hits) return hits.map((h) => ({ ref: toolRefs[h.id], score: h.score }))
275
+ return bm25.search(query, limit)
276
+ }
277
+
278
+ /** Search the skill index: sidecar first, JS BM25 fallback. */
279
+ async function searchSkills(query: string, limit: number): Promise<Hit<SkillRef>[]> {
280
+ const hits = await sidecar.search('skills', query, limit)
281
+ if (hits) return hits.map((h) => ({ ref: skillRefs[h.id], score: h.score }))
282
+ return skillBm25.search(query, limit)
283
+ }
284
+
285
+ /**
286
+ * Optional model re-rank of the BM25 candidates. Returns the top-k refs.
287
+ * Any failure falls back to the BM25 order, so the router never breaks on a
288
+ * model hiccup.
289
+ */
290
+ async function rerank<T extends { name: string; summary: string }>(query: string, candidates: Hit<T>[], k: number): Promise<Hit<T>[]> {
291
+ if (!llm || !model.provider || !model.model || candidates.length <= k) return candidates.slice(0, k)
292
+ const list = candidates.map((c, i) => `${i + 1}. ${c.ref.name}: ${c.ref.summary}`).join('\n')
293
+ const prompt = [
294
+ 'You are a tool router. Pick the tools that best fit the task.',
295
+ '',
296
+ `Task: ${query}`,
297
+ '',
298
+ 'Candidates:',
299
+ list,
300
+ '',
301
+ `Return the numbers of the best tools, comma-separated, at most ${k}. If none fit, return "none".`,
302
+ ].join('\n')
303
+ try {
304
+ let text = ''
305
+ let finished = false
306
+ const options = {
307
+ provider: model.provider,
308
+ model: model.model,
309
+ system: 'You are a strict tool router. You answer with numbers only.',
310
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
311
+ maxTokens: config.maxTokens ?? 24,
312
+ temperature: 0,
313
+ }
314
+ for await (const chunk of llm.stream(options)) {
315
+ if (finished) break
316
+ if (chunk.type === 'text-delta') text += chunk.text ?? ''
317
+ else if (chunk.type === 'finish') finished = true
318
+ }
319
+ const picks = String(text).split(/[,\s]+/).map((s) => Number.parseInt(s, 10)).filter((n) => Number.isInteger(n) && n >= 1 && n <= candidates.length)
320
+ const unique = [...new Set(picks)].slice(0, k).map((n) => candidates[n - 1])
321
+ return unique.length ? unique : candidates.slice(0, k)
322
+ } catch (caught) {
323
+ logger?.warn?.(`tool-router: re-rank failed, using BM25 order: ${caught instanceof Error ? caught.message : String(caught)}`)
324
+ return candidates.slice(0, k)
325
+ }
326
+ }
327
+
328
+ function budgetReport(agent: unknown): string {
329
+ if (!cat || typeof cat.wireTokens !== 'function') return ''
330
+ const tokens = cat.wireTokens(agent)
331
+ const pct = Math.round((tokens / maxBudget) * 100)
332
+ return `Context budget: ${tokens} tokens of loaded tool schemas (${pct}% of the ${maxBudget} budget)${tokens > maxBudget ? ' — OVER budget; unload tools you no longer need' : ''}`
333
+ }
334
+
335
+ // --- tools_find --------------------------------------------------------------
336
+ ctx.tools.register(makeTool({
337
+ name: 'tools_find',
338
+ description:
339
+ 'Search the whole tool catalog by intent. Describe what you want to do in plain words (e.g. "send an email", "search the web", "read a PDF"); it returns the best-matching tools ranked by relevance, each with its path, one-line summary, and schema size. Use this before loading when you are not sure which tool fits. It retrieves only; it does not load anything.',
340
+ parameters: {
341
+ type: 'object',
342
+ properties: {
343
+ query: { type: 'string', description: 'What you want to do, in plain words.' },
344
+ limit: { type: 'number', description: `How many matches to return (default ${topK}).` },
345
+ },
346
+ required: ['query'],
347
+ additionalProperties: false,
348
+ },
349
+ isConcurrencySafe: () => true,
350
+ async execute(args) {
351
+ return formatFind(await searchTools(String(args.query), Number(args.limit ?? topK)))
352
+ },
353
+ }))
354
+
355
+ // --- tools_route -------------------------------------------------------------
356
+ ctx.tools.register(makeTool({
357
+ name: 'tools_route',
358
+ description:
359
+ 'Find the tools that fit a task and load them in one step. Describe the task in plain words; it retrieves the best matches, re-ranks them with the model when one is configured, and loads them so they are callable from your next step. Use this to get set up for a task without browsing the directory. Pass load=false to only retrieve, without loading.',
360
+ parameters: {
361
+ type: 'object',
362
+ properties: {
363
+ query: { type: 'string', description: 'The task, in plain words.' },
364
+ limit: { type: 'number', description: `How many tools to load (default ${topK}).` },
365
+ load: { type: 'boolean', description: 'Load the matches (default true). Set false to only retrieve.' },
366
+ },
367
+ required: ['query'],
368
+ additionalProperties: false,
369
+ },
370
+ async execute(args, exec) {
371
+ const agent = exec?.agent
372
+ const want = Number(args.limit ?? topK)
373
+ const candidates = await searchTools(String(args.query), Math.max(want, 10))
374
+ const ranked = await rerank(String(args.query), candidates, want)
375
+ if (args.load === false || !cat) return formatFind(ranked as Hit<ToolRef>[])
376
+ if (!agent) return formatFind(ranked as Hit<ToolRef>[])
377
+ const targets = (ranked as Hit<ToolRef>[]).map((h) => h.ref.name)
378
+ const result = await cat.load(agent, targets)
379
+ const budget = budgetReport(agent)
380
+ return budget ? `${result}\n${budget}` : result
381
+ },
382
+ }))
383
+
384
+ // --- skills_find -------------------------------------------------------------
385
+ ctx.tools.register(makeTool({
386
+ name: 'skills_find',
387
+ description:
388
+ 'Search the whole skill catalog by intent. Describe the kind of task you are doing (e.g. "write a pull request", "develop a DSH plugin", "add a badge"); it returns the best-matching skills ranked by relevance, each with its name and one-line description. Use this before loading when you are not sure which skill fits. It retrieves only; it does not load anything.',
389
+ parameters: {
390
+ type: 'object',
391
+ properties: {
392
+ query: { type: 'string', description: 'The kind of task you are doing, in plain words.' },
393
+ limit: { type: 'number', description: `How many matches to return (default ${topK}).` },
394
+ },
395
+ required: ['query'],
396
+ additionalProperties: false,
397
+ },
398
+ isConcurrencySafe: () => true,
399
+ async execute(args) {
400
+ return formatSkillFind(await searchSkills(String(args.query), Number(args.limit ?? topK)))
401
+ },
402
+ }))
403
+
404
+ // --- skills_route ------------------------------------------------------------
405
+ ctx.tools.register(makeTool({
406
+ name: 'skills_route',
407
+ description:
408
+ 'Find the best skill for a task and load its full instructions in one step. Describe the task in plain words; it retrieves the best matches, re-ranks them with the model when one is configured, and returns the top skill\'s complete body so you can act on it immediately. Use this to get the right instructions without browsing the skill catalog.',
409
+ parameters: {
410
+ type: 'object',
411
+ properties: {
412
+ query: { type: 'string', description: 'The task, in plain words.' },
413
+ limit: { type: 'number', description: `How many candidates to consider (default ${topK}).` },
414
+ },
415
+ required: ['query'],
416
+ additionalProperties: false,
417
+ },
418
+ async execute(args) {
419
+ const want = Number(args.limit ?? topK)
420
+ const candidates = await searchSkills(String(args.query), Math.max(want, 5))
421
+ if (!candidates.length) return 'No skills matched. Try different words.'
422
+ const ranked = await rerank(String(args.query), candidates, want)
423
+ const top = ranked[0]
424
+ let body = ''
425
+ try {
426
+ const skillsApi = (typeof ctx.get === 'function' ? ctx.get('skills') : undefined) as SkillsService | undefined
427
+ const def = skillsApi ? await skillsApi.get(top.ref.name, { cwd: cwdOf() }) : undefined
428
+ if (def?.content) body = def.content
429
+ } catch {
430
+ // fall through to the summary
431
+ }
432
+ const others = ranked.slice(1).map((r) => r.ref.name)
433
+ const header = `Loaded skill "${top.ref.name}"${top.ref.source ? ` (source: ${top.ref.source})` : ''}:`
434
+ const rest = body ? `\n\n${body}` : `\n\n${top.ref.summary}${top.ref.whenToUse ? ` — ${top.ref.whenToUse}` : ''}`
435
+ return others.length ? `${header}${rest}\n\nOther candidates: ${others.join(', ')}` : `${header}${rest}`
436
+ },
437
+ }))
438
+
439
+ if (!cat) logger?.info?.('tool-router: no toolCatalog service; tools_route retrieves only')
440
+ }
@@ -0,0 +1,159 @@
1
+ // A thin client over the Rust sidecar. It spawns the binary, pushes the
2
+ // catalog on change, and asks for ranked hits. Any failure (missing binary,
3
+ // dead process, timeout) resolves to null so the caller falls back to the JS
4
+ // BM25 index.
5
+
6
+ import { spawn, type ChildProcess } from 'node:child_process'
7
+ import { connect, type Socket } from 'node:net'
8
+ import { existsSync } from 'node:fs'
9
+ import type { Logger } from './types.js'
10
+
11
+ interface Pending {
12
+ resolve: (msg: SidecarMsg | null) => void
13
+ timer: NodeJS.Timeout
14
+ }
15
+
16
+ interface SidecarMsg {
17
+ id?: number
18
+ ok?: boolean
19
+ error?: string
20
+ hits?: { id: number; score: number }[]
21
+ [key: string]: unknown
22
+ }
23
+
24
+ export interface SidecarOptions {
25
+ bin: string
26
+ socket: string
27
+ timeoutMs?: number
28
+ logger?: Logger
29
+ }
30
+
31
+ export class Sidecar {
32
+ private bin: string
33
+ private socket: string
34
+ private timeoutMs: number
35
+ private logger?: Logger
36
+ private child: ChildProcess | null = null
37
+ private sock: Socket | null = null
38
+ private buf = ''
39
+ private pending = new Map<number, Pending>()
40
+ private nextId = 1
41
+ private ready = false
42
+
43
+ constructor({ bin, socket, timeoutMs = 2000, logger }: SidecarOptions) {
44
+ this.bin = bin
45
+ this.socket = socket
46
+ this.timeoutMs = timeoutMs
47
+ this.logger = logger
48
+ }
49
+
50
+ async start(): Promise<boolean> {
51
+ if (!existsSync(this.bin)) {
52
+ this.logger?.warn?.(`tool-router: sidecar binary not found at ${this.bin}; using JS BM25`)
53
+ return false
54
+ }
55
+ try {
56
+ this.child = spawn(this.bin, ['--socket', this.socket], { stdio: ['ignore', 'ignore', 'pipe'] })
57
+ this.child.stderr?.on('data', (d) => this.logger?.debug?.(`tool-router sidecar: ${String(d).trim()}`))
58
+ this.child.on('exit', (code) => {
59
+ this.logger?.warn?.(`tool-router: sidecar exited (code ${code}); falling back to JS BM25`)
60
+ this.ready = false
61
+ this.sock?.destroy()
62
+ this.sock = null
63
+ })
64
+ for (let attempt = 0; attempt < 50; attempt++) {
65
+ try {
66
+ await this._connect()
67
+ break
68
+ } catch {
69
+ await new Promise((r) => setTimeout(r, 100))
70
+ }
71
+ }
72
+ if (!this.sock) { this.logger?.warn?.('tool-router: sidecar start: no socket after connect loop'); return false }
73
+ const pong = await this._request({ op: 'ping' })
74
+ if (!pong?.ok) { this.logger?.warn?.(`tool-router: sidecar start: ping failed: ${JSON.stringify(pong)}`); return false }
75
+ this.ready = true
76
+ this.logger?.info?.('tool-router: Rust sidecar active (tantivy BM25)')
77
+ return true
78
+ } catch (caught) {
79
+ this.logger?.warn?.(`tool-router: sidecar start failed: ${caught instanceof Error ? caught.message : String(caught)}`)
80
+ return false
81
+ }
82
+ }
83
+
84
+ private _connect(): Promise<void> {
85
+ return new Promise((resolve, reject) => {
86
+ const s = connect({ path: this.socket })
87
+ s.once('connect', () => {
88
+ this.sock = s
89
+ s.setEncoding('utf8')
90
+ s.on('data', (chunk) => this._onData(chunk as unknown as string))
91
+ s.on('close', () => { this.ready = false; this.sock = null })
92
+ s.on('error', () => {})
93
+ resolve()
94
+ })
95
+ s.once('error', reject)
96
+ })
97
+ }
98
+
99
+ private _onData(chunk: string): void {
100
+ this.buf += chunk
101
+ let idx
102
+ while ((idx = this.buf.indexOf('\n')) >= 0) {
103
+ const line = this.buf.slice(0, idx)
104
+ this.buf = this.buf.slice(idx + 1)
105
+ if (!line.trim()) continue
106
+ let msg: SidecarMsg
107
+ try { msg = JSON.parse(line) as SidecarMsg } catch { continue }
108
+ const id = msg.id
109
+ if (id === undefined) continue
110
+ const p = this.pending.get(id)
111
+ if (!p) continue
112
+ this.pending.delete(id)
113
+ clearTimeout(p.timer)
114
+ p.resolve(msg)
115
+ }
116
+ }
117
+
118
+ private _request(msg: Record<string, unknown>, timeoutMs = this.timeoutMs): Promise<SidecarMsg | null> {
119
+ // Only gate on the socket, not on this.ready: the ping is what sets ready,
120
+ // so gating on ready would make the ping itself unreachable. this.sock is
121
+ // kept in sync with the connection lifecycle (set on connect, nulled on
122
+ // close/exit/stop), so it is the correct liveness check.
123
+ if (!this.sock) return Promise.resolve(null)
124
+ const sock = this.sock
125
+ const id = this.nextId++
126
+ msg.id = id
127
+ return new Promise((resolve) => {
128
+ const timer = setTimeout(() => {
129
+ this.pending.delete(id)
130
+ resolve(null)
131
+ }, timeoutMs)
132
+ this.pending.set(id, { resolve, timer })
133
+ sock.write(JSON.stringify(msg) + '\n')
134
+ })
135
+ }
136
+
137
+ async index(name: string, docs: { ref: unknown; text: string }[]): Promise<boolean> {
138
+ if (!this.ready) return false
139
+ const res = await this._request({ op: 'index', name, docs })
140
+ if (res?.ok) return true
141
+ this.logger?.warn?.(`tool-router: sidecar index ${name} failed: ${res?.error ?? 'no response'}`)
142
+ return false
143
+ }
144
+
145
+ async search(index: string, query: string, limit: number): Promise<{ id: number; score: number }[] | null> {
146
+ if (!this.ready) return null
147
+ const res = await this._request({ op: 'search', index, query, limit })
148
+ if (!res?.ok) return null
149
+ return res.hits ?? null
150
+ }
151
+
152
+ stop(): void {
153
+ try { this.sock?.destroy() } catch { /* ignore */ }
154
+ this.sock = null
155
+ try { this.child?.kill() } catch { /* ignore */ }
156
+ this.child = null
157
+ this.ready = false
158
+ }
159
+ }