@anionex/dsh-tool-search 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +160 -0
  4. package/README.zh.md +160 -0
  5. package/cordis.patch.yml +3 -0
  6. package/docs/benchmark-results.json +51 -0
  7. package/lib/bm25.js +83 -0
  8. package/lib/bm25.js.map +1 -0
  9. package/lib/catalog.js +53 -0
  10. package/lib/catalog.js.map +1 -0
  11. package/lib/client.js +388 -0
  12. package/lib/client.js.map +1 -0
  13. package/lib/index.js +24 -0
  14. package/lib/index.js.map +1 -0
  15. package/lib/runtime.js +286 -0
  16. package/lib/runtime.js.map +1 -0
  17. package/lib/settings.js +50 -0
  18. package/lib/settings.js.map +1 -0
  19. package/lib/shared.js +40 -0
  20. package/lib/shared.js.map +1 -0
  21. package/lib/types/bm25.d.ts +20 -0
  22. package/lib/types/bm25.d.ts.map +1 -0
  23. package/lib/types/catalog.d.ts +26 -0
  24. package/lib/types/catalog.d.ts.map +1 -0
  25. package/lib/types/client/index.d.ts +65 -0
  26. package/lib/types/client/index.d.ts.map +1 -0
  27. package/lib/types/index.d.ts +14 -0
  28. package/lib/types/index.d.ts.map +1 -0
  29. package/lib/types/runtime.d.ts +37 -0
  30. package/lib/types/runtime.d.ts.map +1 -0
  31. package/lib/types/settings.d.ts +16 -0
  32. package/lib/types/settings.d.ts.map +1 -0
  33. package/lib/types/shared.d.ts +31 -0
  34. package/lib/types/shared.d.ts.map +1 -0
  35. package/lib/types/web.d.ts +5 -0
  36. package/lib/types/web.d.ts.map +1 -0
  37. package/lib/web.js +28 -0
  38. package/lib/web.js.map +1 -0
  39. package/package.json +149 -0
  40. package/pnpm-workspace.yaml +11 -0
  41. package/scripts/benchmark.mjs +124 -0
  42. package/scripts/profile-e2e.mjs +366 -0
  43. package/scripts/validate.mjs +170 -0
  44. package/src/bm25.ts +104 -0
  45. package/src/catalog.ts +72 -0
  46. package/src/client/index.tsx +344 -0
  47. package/src/index.ts +32 -0
  48. package/src/runtime.ts +326 -0
  49. package/src/settings.ts +63 -0
  50. package/src/shared.ts +63 -0
  51. package/src/web.ts +34 -0
  52. package/src/wink-porter2-stemmer.d.ts +3 -0
  53. package/tests/bm25.spec.ts +75 -0
  54. package/tests/catalog.spec.ts +63 -0
  55. package/tests/client.spec.tsx +39 -0
  56. package/tests/execution-regression.spec.ts +200 -0
  57. package/tests/runtime.spec.ts +271 -0
  58. package/tests/settings.spec.ts +33 -0
  59. package/tsconfig.client.json +31 -0
  60. package/tsconfig.json +30 -0
  61. package/tsconfig.test.json +23 -0
  62. package/tsdown.config.mjs +37 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,326 @@
1
+ /** Agent-local selected sets and model-visible tool filtering. */
2
+
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type { Agent } from '@deepseek-ai/dsh-agent'
5
+ import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
6
+ import {
7
+ defineTool,
8
+ type ToolDefinition,
9
+ type ToolExecution,
10
+ type ToolExecutionResult,
11
+ type ToolRunContext,
12
+ } from '@deepseek-ai/dsh-tools'
13
+ import { Bm25Index } from './bm25.ts'
14
+ import { buildCatalog, type ToolCatalogEntry, type ToolSchemaLike } from './catalog.ts'
15
+ import { ToolPolicyResolver, normalizeSettings } from './settings.ts'
16
+ import {
17
+ MAX_RESULT_LIMIT,
18
+ TOOL_SEARCH_NAME,
19
+ type CatalogSnapshot,
20
+ type ToolSearchSettings,
21
+ } from './shared.ts'
22
+
23
+ const RESULT_SCHEMA_VERSION = 1
24
+ const MAX_QUERY_LENGTH = 4096
25
+ const MAX_DESCRIPTION_LENGTH = 240
26
+
27
+ export interface ToolSearchMatch {
28
+ name: string
29
+ description: string
30
+ score: number
31
+ }
32
+
33
+ export interface ToolSearchResult {
34
+ schemaVersion: 1
35
+ query: string
36
+ tools: ToolSearchMatch[]
37
+ }
38
+
39
+ interface AgentState {
40
+ agent: Agent
41
+ selected: Set<string>
42
+ catalog: ToolCatalogEntry[]
43
+ catalogGeneration: number
44
+ disposeAssembly: () => void
45
+ }
46
+
47
+ function compareNames(left: string, right: string): number {
48
+ return left < right ? -1 : left > right ? 1 : 0
49
+ }
50
+
51
+ function summarize(description: string): string {
52
+ const normalized = description.replace(/\s+/gu, ' ').trim()
53
+ if (normalized.length <= MAX_DESCRIPTION_LENGTH) return normalized
54
+ return `${normalized.slice(0, MAX_DESCRIPTION_LENGTH - 3)}...`
55
+ }
56
+
57
+ function parseSelectionResult(value: unknown): string[] {
58
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return []
59
+ const record = value as Record<string, unknown>
60
+ if (record['schemaVersion'] !== RESULT_SCHEMA_VERSION || !Array.isArray(record['tools'])) return []
61
+ const names: string[] = []
62
+ for (const candidate of record['tools']) {
63
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) continue
64
+ const name = (candidate as Record<string, unknown>)['name']
65
+ if (typeof name === 'string' && name.length > 0) names.push(name)
66
+ }
67
+ return names
68
+ }
69
+
70
+ function sessionEvents(agent: Agent): readonly unknown[] {
71
+ const session = agent.session as unknown as {
72
+ events?: readonly unknown[]
73
+ snapshotEvents?: () => readonly unknown[]
74
+ }
75
+ if (typeof session.snapshotEvents === 'function') return session.snapshotEvents()
76
+ return Array.isArray(session.events) ? session.events : []
77
+ }
78
+
79
+ function selectedFromSession(agent: Agent): Set<string> {
80
+ const searchCalls = new Set<string>()
81
+ const selected = new Set<string>()
82
+ for (const rawEvent of sessionEvents(agent)) {
83
+ if (typeof rawEvent !== 'object' || rawEvent === null || Array.isArray(rawEvent)) continue
84
+ const event = rawEvent as { type?: unknown, data?: Record<string, unknown> }
85
+ if (event.type === 'tool/call' && event.data?.['name'] === TOOL_SEARCH_NAME) {
86
+ searchCalls.add(String(event.data['callId']))
87
+ continue
88
+ }
89
+ if (event.type !== 'tool/result' || typeof event.data?.['message'] !== 'object' || event.data['message'] === null) continue
90
+ const content = (event.data['message'] as { content?: unknown }).content
91
+ const result = Array.isArray(content) ? content[0] : undefined
92
+ if (typeof result !== 'object' || result === null || Array.isArray(result)) continue
93
+ const resultRecord = result as Record<string, unknown>
94
+ if (!searchCalls.has(String(resultRecord['toolCallId'])) || resultRecord['isError'] === true) continue
95
+ const blocks = resultRecord['content']
96
+ if (!Array.isArray(blocks)) continue
97
+ const text = blocks
98
+ .filter((block): block is { type: 'text', text: string } => (
99
+ typeof block === 'object'
100
+ && block !== null
101
+ && !Array.isArray(block)
102
+ && (block as Record<string, unknown>)['type'] === 'text'
103
+ && typeof (block as Record<string, unknown>)['text'] === 'string'
104
+ ))
105
+ .map(block => block.text)
106
+ .join('')
107
+ try {
108
+ for (const name of parseSelectionResult(JSON.parse(text))) selected.add(name)
109
+ } catch {
110
+ // Failed and legacy tool results are not successful selection transitions.
111
+ }
112
+ }
113
+ return selected
114
+ }
115
+
116
+ function toolSectionName(sectionName: string): string | undefined {
117
+ if (!sectionName.startsWith('tool:')) return undefined
118
+ const name = sectionName.slice('tool:'.length)
119
+ return name.length > 0 ? name : undefined
120
+ }
121
+
122
+ export class ToolSearchRuntime {
123
+ readonly definition: ToolDefinition
124
+ private readonly states = new Map<Agent, AgentState>()
125
+ private readonly policy: ToolPolicyResolver
126
+ private settings: ToolSearchSettings
127
+ private generation = 0
128
+
129
+ constructor(
130
+ private readonly ctx: Context,
131
+ settings: ToolSearchSettings,
132
+ ) {
133
+ this.settings = normalizeSettings(settings)
134
+ this.policy = new ToolPolicyResolver(this.settings)
135
+ this.definition = this.createDefinition()
136
+ }
137
+
138
+ install(): () => void {
139
+ const disposers = [
140
+ this.ctx.on('agent/created', ({ agent }) => { this.attach(agent) }),
141
+ this.ctx.on('agent/disposed', ({ agent }) => { this.detach(agent) }),
142
+ this.ctx.on('tools/result', (exec, result) => { this.commitSelection(exec, result) }),
143
+ this.ctx.on('tools/change', () => { this.generation += 1 }),
144
+ this.ctx.on('system-prompt/change', () => { this.generation += 1 }),
145
+ ]
146
+ for (const agent of this.ctx.agents.list()) this.attach(agent)
147
+ return () => {
148
+ for (const state of this.states.values()) state.disposeAssembly()
149
+ this.states.clear()
150
+ for (const dispose of disposers.reverse()) dispose()
151
+ }
152
+ }
153
+
154
+ updateSettings(value: ToolSearchSettings): void {
155
+ this.settings = normalizeSettings(value)
156
+ this.policy.update(this.settings)
157
+ }
158
+
159
+ snapshot(): CatalogSnapshot {
160
+ const tools = new Map<string, ToolCatalogEntry>()
161
+ for (const entry of buildCatalog(this.ctx.tools.schemas() as ToolSchemaLike[])) tools.set(entry.name, entry)
162
+ for (const state of this.states.values()) {
163
+ this.refreshCatalog(state)
164
+ for (const entry of state.catalog) tools.set(entry.name, entry)
165
+ }
166
+ return {
167
+ schemaVersion: 1,
168
+ generation: this.generation,
169
+ tools: [...tools.values()].sort((left, right) => compareNames(left.name, right.name)).map(tool => ({
170
+ name: tool.name,
171
+ description: summarize(tool.description),
172
+ policy: this.policy.classify(tool.name),
173
+ selectedBy: [...this.states.values()]
174
+ .filter(state => state.selected.has(tool.name))
175
+ .map(state => String(state.agent.id))
176
+ .sort(compareNames),
177
+ })),
178
+ }
179
+ }
180
+
181
+ selectedFor(agent: Agent): ReadonlySet<string> {
182
+ return this.requireState(agent).selected
183
+ }
184
+
185
+ private createDefinition(): ToolDefinition {
186
+ return defineTool({
187
+ name: TOOL_SEARCH_NAME,
188
+ description: 'Search deferred tool metadata with deterministic local BM25. Matching tools become available by their real names on the next model call. Results contain summaries, not full schemas.',
189
+ parameters: {
190
+ query: {
191
+ type: 'string',
192
+ required: true,
193
+ description: 'Search query for deferred tools.',
194
+ },
195
+ limit: {
196
+ type: 'integer',
197
+ description: 'Maximum number of tools to return. Defaults to 5.',
198
+ },
199
+ },
200
+ output: {
201
+ schema: {
202
+ type: 'object',
203
+ additionalProperties: false,
204
+ properties: {
205
+ schemaVersion: { type: 'integer', const: RESULT_SCHEMA_VERSION, required: true },
206
+ query: { type: 'string', required: true },
207
+ tools: {
208
+ type: 'array',
209
+ required: true,
210
+ items: {
211
+ type: 'object',
212
+ additionalProperties: false,
213
+ properties: {
214
+ name: { type: 'string', required: true },
215
+ description: { type: 'string', required: true },
216
+ score: { type: 'number', required: true },
217
+ },
218
+ },
219
+ },
220
+ },
221
+ },
222
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
223
+ },
224
+ execute: (args, exec) => Promise.resolve(this.search(args, exec)),
225
+ finalizeContent: (_exec, result) => result.isError
226
+ ? undefined
227
+ : [{ type: 'text', text: JSON.stringify(result.value) }],
228
+ presentCall: args => ({
229
+ card: 'generic',
230
+ title: 'Search deferred tools',
231
+ kind: 'read',
232
+ rawInput: typeof args === 'object' && args !== null && 'query' in args
233
+ ? String((args as { query: unknown }).query)
234
+ : undefined,
235
+ }),
236
+ })
237
+ }
238
+
239
+ private search(args: { query: string; limit?: number }, exec: ToolRunContext): ToolSearchResult {
240
+ const agent = exec.agent
241
+ if (agent === undefined) throw new Error(`${TOOL_SEARCH_NAME}: an Agent Session is required`)
242
+ const query = args.query.trim()
243
+ if (query.length === 0) throw new Error(`${TOOL_SEARCH_NAME}: query must not be empty`)
244
+ if (query.length > MAX_QUERY_LENGTH) throw new Error(`${TOOL_SEARCH_NAME}: query exceeds ${MAX_QUERY_LENGTH} characters`)
245
+ if (args.limit !== undefined && (!Number.isSafeInteger(args.limit) || args.limit < 1 || args.limit > MAX_RESULT_LIMIT)) {
246
+ throw new Error(`${TOOL_SEARCH_NAME}: limit must be an integer between 1 and ${MAX_RESULT_LIMIT}`)
247
+ }
248
+
249
+ const state = this.requireState(agent)
250
+ this.refreshCatalog(state)
251
+ const deferred = state.catalog.filter(tool => this.policy.classify(tool.name, state.selected) === 'deferred')
252
+ const index = new Bm25Index(deferred.map(tool => ({ id: tool, name: tool.name, text: tool.searchText })))
253
+ const limit = args.limit ?? this.settings.defaultLimit
254
+ const tools = index.search(query, limit).map(match => ({
255
+ name: match.id.name,
256
+ description: summarize(match.id.description),
257
+ score: Number(match.score.toFixed(6)),
258
+ }))
259
+ return { schemaVersion: RESULT_SCHEMA_VERSION, query, tools }
260
+ }
261
+
262
+ private commitSelection(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): void {
263
+ if (exec.name !== TOOL_SEARCH_NAME || exec.agent === undefined || result.isError) return
264
+ const state = this.requireState(exec.agent)
265
+ for (const name of parseSelectionResult(result.value)) state.selected.add(name)
266
+ }
267
+
268
+ private attach(agent: Agent): void {
269
+ if (this.states.has(agent)) return
270
+ const selected = selectedFromSession(agent)
271
+ const state = {
272
+ agent,
273
+ selected,
274
+ catalog: [],
275
+ catalogGeneration: -1,
276
+ disposeAssembly: () => {},
277
+ }
278
+ state.disposeAssembly = agent.ctx.on(
279
+ 'system-prompt/assemble',
280
+ async (_assembly, _context, next) => this.filterAssembly(state, await next()),
281
+ { prepend: true },
282
+ )
283
+ this.states.set(agent, state)
284
+ }
285
+
286
+ private detach(agent: Agent): void {
287
+ const state = this.states.get(agent)
288
+ if (state === undefined) return
289
+ this.states.delete(agent)
290
+ }
291
+
292
+ private requireState(agent: Agent): AgentState {
293
+ this.attach(agent)
294
+ const state = this.states.get(agent)
295
+ if (state === undefined) throw new Error(`${TOOL_SEARCH_NAME}: Agent state could not be initialized`)
296
+ return state
297
+ }
298
+
299
+ private refreshCatalog(state: AgentState): void {
300
+ if (state.catalogGeneration === this.generation) return
301
+ state.catalog = buildCatalog(this.ctx.tools.schemas(state.agent) as ToolSchemaLike[])
302
+ state.catalogGeneration = this.generation
303
+ }
304
+
305
+ private filterAssembly(state: AgentState, assembly: PromptAssembly): PromptAssembly {
306
+ if (assembly.sections.some(section => section.name === 'tools:sdk' && section.text.trim().length > 0)) {
307
+ throw new Error('dsh-tool-search requires native tool presentation; code/PTC and both modes are unsupported')
308
+ }
309
+ state.catalog = buildCatalog(assembly.tools as ToolSchemaLike[])
310
+ state.catalogGeneration = this.generation
311
+ const visible = new Set(
312
+ assembly.tools
313
+ .map(tool => tool.name)
314
+ .filter(name => this.policy.classify(name, state.selected) === 'always'),
315
+ )
316
+ const hidden = new Set(assembly.tools.map(tool => tool.name).filter(name => !visible.has(name)))
317
+ return {
318
+ ...assembly,
319
+ tools: assembly.tools.filter(tool => visible.has(tool.name)),
320
+ sections: assembly.sections.filter(section => {
321
+ const name = toolSectionName(section.name)
322
+ return name === undefined || !hidden.has(name)
323
+ }),
324
+ }
325
+ }
326
+ }
@@ -0,0 +1,63 @@
1
+ /** Validated Host settings and policy resolution. */
2
+
3
+ import z from '@deepseek-ai/schemastery'
4
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
5
+ import {
6
+ DEFAULT_CORE_TOOLS,
7
+ DEFAULT_RESULT_LIMIT,
8
+ MAX_RESULT_LIMIT,
9
+ TOOL_SEARCH_NAME,
10
+ TOOL_SEARCH_SETTINGS_NAMESPACE,
11
+ type ToolPolicy,
12
+ type ToolSearchSettings,
13
+ } from './shared.ts'
14
+
15
+ export const TOOL_SEARCH_SETTINGS_NS = TOOL_SEARCH_SETTINGS_NAMESPACE as SettingsNamespace
16
+
17
+ export const Config: z<ToolSearchSettings> = z.object({
18
+ defaultLimit: z.number().step(1).min(1).max(MAX_RESULT_LIMIT).default(DEFAULT_RESULT_LIMIT),
19
+ alwaysVisible: z.array(z.string()).default([]),
20
+ neverSearch: z.array(z.string()).default([]),
21
+ })
22
+
23
+ function normalizeNames(names: readonly string[]): string[] {
24
+ const normalized = new Set<string>()
25
+ for (const value of names) {
26
+ const name = value.trim()
27
+ if (name.length > 0) normalized.add(name)
28
+ }
29
+ return [...normalized].sort()
30
+ }
31
+
32
+ export function normalizeSettings(value: ToolSearchSettings): ToolSearchSettings {
33
+ const defaultLimit = Number.isSafeInteger(value.defaultLimit)
34
+ ? Math.min(MAX_RESULT_LIMIT, Math.max(1, value.defaultLimit))
35
+ : DEFAULT_RESULT_LIMIT
36
+ return {
37
+ defaultLimit,
38
+ alwaysVisible: normalizeNames(value.alwaysVisible),
39
+ neverSearch: normalizeNames(value.neverSearch).filter(name => name !== TOOL_SEARCH_NAME),
40
+ }
41
+ }
42
+
43
+ export class ToolPolicyResolver {
44
+ private readonly core = new Set<string>(DEFAULT_CORE_TOOLS)
45
+ private always = new Set<string>()
46
+ private blocked = new Set<string>()
47
+
48
+ constructor(settings: ToolSearchSettings) {
49
+ this.update(settings)
50
+ }
51
+
52
+ update(settings: ToolSearchSettings): void {
53
+ this.always = new Set(settings.alwaysVisible)
54
+ this.blocked = new Set(settings.neverSearch)
55
+ }
56
+
57
+ classify(name: string, selected?: ReadonlySet<string>): ToolPolicy {
58
+ if (name === TOOL_SEARCH_NAME) return 'always'
59
+ if (this.blocked.has(name)) return 'blocked'
60
+ if (this.core.has(name) || this.always.has(name) || selected?.has(name) === true) return 'always'
61
+ return 'deferred'
62
+ }
63
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,63 @@
1
+ /** Values shared by the Host runtime and Web settings client. */
2
+
3
+ export const TOOL_SEARCH_NAME = 'tool_search'
4
+ export const TOOL_SEARCH_SETTINGS_NAMESPACE = 'tool-search'
5
+ export const TOOL_SEARCH_CATALOG_ROUTE = '/_dsh/tool-search/catalog'
6
+ export const DEFAULT_RESULT_LIMIT = 5
7
+ export const MAX_RESULT_LIMIT = 20
8
+
9
+ /**
10
+ * Stable bootstrap surface for current DSH releases. Unknown future first-party
11
+ * names are deferred until this list or the user allowlist is updated because
12
+ * ToolDefinition does not expose package provenance.
13
+ */
14
+ export const DEFAULT_CORE_TOOLS = Object.freeze([
15
+ 'apply_patch',
16
+ 'ask_user_question',
17
+ 'bash',
18
+ 'create_goal',
19
+ 'dsh_im_return_file',
20
+ 'exit_plan_mode',
21
+ 'get_goal',
22
+ 'interrupt_agent',
23
+ 'job_kill',
24
+ 'job_list',
25
+ 'job_output',
26
+ 'list_agents',
27
+ 'ralph',
28
+ 'report',
29
+ 'send_message',
30
+ 'skill',
31
+ 'subagent',
32
+ 'subagent_fork',
33
+ 'todo_write',
34
+ 'update_goal',
35
+ 'workflow',
36
+ ] as const)
37
+
38
+ export interface ToolSearchSettings {
39
+ defaultLimit: number
40
+ alwaysVisible: string[]
41
+ neverSearch: string[]
42
+ }
43
+
44
+ export const DEFAULT_SETTINGS: Readonly<ToolSearchSettings> = Object.freeze({
45
+ defaultLimit: DEFAULT_RESULT_LIMIT,
46
+ alwaysVisible: [],
47
+ neverSearch: [],
48
+ })
49
+
50
+ export type ToolPolicy = 'always' | 'deferred' | 'blocked'
51
+
52
+ export interface CatalogToolSummary {
53
+ name: string
54
+ description: string
55
+ policy: ToolPolicy
56
+ selectedBy: string[]
57
+ }
58
+
59
+ export interface CatalogSnapshot {
60
+ schemaVersion: 1
61
+ generation: number
62
+ tools: CatalogToolSummary[]
63
+ }
package/src/web.ts ADDED
@@ -0,0 +1,34 @@
1
+ /** Read-only live catalog endpoint for the Web settings page. */
2
+
3
+ import type { ServerResponse } from 'node:http'
4
+ import type { Context } from '@deepseek-ai/cordis'
5
+ import type {} from '@deepseek-ai/dsh-host-webserver'
6
+ import { TOOL_SEARCH_CATALOG_ROUTE } from './shared.ts'
7
+ import type { ToolSearchRuntime } from './runtime.ts'
8
+
9
+ function sendJson(res: ServerResponse, status: number, value: unknown): void {
10
+ const body = Buffer.from(JSON.stringify(value))
11
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
12
+ res.setHeader('Content-Length', String(body.length))
13
+ res.setHeader('Cache-Control', 'no-store')
14
+ res.setHeader('X-Content-Type-Options', 'nosniff')
15
+ res.writeHead(status)
16
+ res.end(body)
17
+ }
18
+
19
+ export function installCatalogRoute(ctx: Context, runtime: ToolSearchRuntime): void {
20
+ ctx.inject(['webServer'], webCtx => {
21
+ webCtx.effect(() => webCtx.webServer.register({
22
+ kind: 'exact',
23
+ path: TOOL_SEARCH_CATALOG_ROUTE,
24
+ handler: (req, res) => {
25
+ if (req.method !== 'GET') {
26
+ res.setHeader('Allow', 'GET')
27
+ sendJson(res, 405, { error: 'method-not-allowed' })
28
+ return
29
+ }
30
+ sendJson(res, 200, runtime.snapshot())
31
+ },
32
+ }), 'dsh-tool-search: Web catalog route')
33
+ })
34
+ }
@@ -0,0 +1,3 @@
1
+ declare module 'wink-porter2-stemmer' {
2
+ export default function stem(token: string): string
3
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { Bm25Index, tokenize } from '../src/bm25.ts'
3
+ import { toolSearchText, type ToolSchemaLike } from '../src/catalog.ts'
4
+
5
+ const tools: ToolSchemaLike[] = [
6
+ {
7
+ name: 'browser_take_screenshot',
8
+ description: 'Capture a screenshot of a web page or selected element.',
9
+ parameters: { properties: { target: { description: 'Element to capture.' } } },
10
+ },
11
+ {
12
+ name: 'web_search',
13
+ description: 'Search the public web for current information and sources.',
14
+ parameters: { properties: { queries: { description: 'Search queries.' } } },
15
+ },
16
+ {
17
+ name: 'read_image',
18
+ description: 'Inspect a local PNG JPEG WebP or GIF image.',
19
+ parameters: { properties: { file_path: { description: 'Image path.' } } },
20
+ },
21
+ {
22
+ name: 'browser_file_upload',
23
+ description: 'Upload one or more local files through a browser file chooser.',
24
+ parameters: { properties: { paths: { description: 'Absolute file paths.' } } },
25
+ },
26
+ {
27
+ name: 'browser_network_requests',
28
+ description: 'List HTTP network requests made by the current browser page.',
29
+ parameters: { properties: { filter: { description: 'URL regular expression.' } } },
30
+ },
31
+ {
32
+ name: 'dsh_im_return_file',
33
+ description: 'Send an existing local file to the user.',
34
+ parameters: { properties: { path: { description: 'File to deliver.' } } },
35
+ },
36
+ {
37
+ name: 'browser_navigate',
38
+ description: 'Navigate the browser to a URL.',
39
+ parameters: { properties: { url: { description: 'Destination URL.' } } },
40
+ },
41
+ ]
42
+
43
+ function index() {
44
+ return new Bm25Index(tools.map(tool => ({ id: tool.name, name: tool.name, text: toolSearchText(tool) })))
45
+ }
46
+
47
+ describe('deterministic BM25', () => {
48
+ it('guarantees exact-name Recall@1 even for a weak lexical score', () => {
49
+ expect(index().search('browser_network_requests', 1)[0]?.name).toBe('browser_network_requests')
50
+ })
51
+
52
+ it.each([
53
+ ['take a screenshot of this page', 'browser_take_screenshot'],
54
+ ['search online for recent sources', 'web_search'],
55
+ ['inspect this png image', 'read_image'],
56
+ ['upload a file in the web page', 'browser_file_upload'],
57
+ ['send this local file to the user', 'dsh_im_return_file'],
58
+ ])('places capability query %j within Top-5', (query, expected) => {
59
+ expect(index().search(query, 5).map(result => result.name)).toContain(expected)
60
+ })
61
+
62
+ it('uses lexical tool name as the stable equal-score tie-break', () => {
63
+ const tied = new Bm25Index([
64
+ { id: 2, name: 'zeta_tool', text: 'shared capability' },
65
+ { id: 1, name: 'alpha_tool', text: 'shared capability' },
66
+ ])
67
+ const first = tied.search('shared capability', 2)
68
+ for (let run = 0; run < 20; run += 1) expect(tied.search('shared capability', 2)).toEqual(first)
69
+ expect(first.map(result => result.name)).toEqual(['alpha_tool', 'zeta_tool'])
70
+ })
71
+
72
+ it('normalizes, removes English stop words, and stems terms', () => {
73
+ expect(tokenize('The uploaded files are RUNNING')).toEqual(['upload', 'file', 'run'])
74
+ })
75
+ })
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { appendSchemaSearchText, toolSearchText } from '../src/catalog.ts'
3
+
4
+ describe('Codex-aligned search documents', () => {
5
+ it('indexes the exact required fields and recursively nested descriptions', () => {
6
+ const text = toolSearchText({
7
+ name: 'browser_take_screenshot',
8
+ description: 'Capture the active browser page.',
9
+ parameters: {
10
+ type: 'object',
11
+ description: 'Screenshot request.',
12
+ properties: {
13
+ target: {
14
+ type: 'object',
15
+ description: 'Optional element target.',
16
+ properties: {
17
+ selector: { type: 'string', description: 'Unique CSS selector.' },
18
+ },
19
+ },
20
+ formats: {
21
+ type: 'array',
22
+ description: 'Requested encodings.',
23
+ items: { type: 'string', description: 'One image encoding.' },
24
+ },
25
+ destination: {
26
+ anyOf: [
27
+ { type: 'string', description: 'Output filename.' },
28
+ {
29
+ type: 'object',
30
+ properties: {
31
+ artifact: { type: 'boolean', description: 'Return an artifact.' },
32
+ },
33
+ },
34
+ ],
35
+ },
36
+ },
37
+ },
38
+ })
39
+
40
+ expect(text).toContain('browser_take_screenshot browser take screenshot')
41
+ expect(text).toContain('Capture the active browser page.')
42
+ expect(text).toContain('Screenshot request.')
43
+ expect(text).toContain('destination')
44
+ expect(text).toContain('formats Requested encodings. One image encoding.')
45
+ expect(text).toContain('target Optional element target. selector Unique CSS selector.')
46
+ expect(text).toContain('Output filename.')
47
+ expect(text).toContain('artifact Return an artifact.')
48
+ expect(text.indexOf('destination')).toBeLessThan(text.indexOf('formats'))
49
+ expect(text.indexOf('formats')).toBeLessThan(text.indexOf('target'))
50
+ })
51
+
52
+ it('ignores unsupported schema annotations and malformed nodes', () => {
53
+ const parts: string[] = []
54
+ appendSchemaSearchText({
55
+ title: 'Not indexed',
56
+ examples: ['Not indexed'],
57
+ properties: null,
58
+ items: 'not-a-schema',
59
+ anyOf: [{ description: 'Indexed branch' }, null],
60
+ }, parts)
61
+ expect(parts).toEqual(['Indexed branch'])
62
+ })
63
+ })
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { ToolSearchSettingsSection } from '../src/client/index.tsx'
6
+ import type { ToolSearchSettings } from '../src/shared.ts'
7
+
8
+ describe('Tool Search settings section', () => {
9
+ it('renders the live catalog and persists policy changes', async () => {
10
+ const set = vi.fn(() => Promise.resolve())
11
+ const value: ToolSearchSettings = { defaultLimit: 5, alwaysVisible: [], neverSearch: [] }
12
+ const snapshot = { status: 'ready' as const, value, writable: true }
13
+ const scope = {
14
+ getSnapshot: () => snapshot,
15
+ subscribe: () => () => {},
16
+ set,
17
+ }
18
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({
19
+ schemaVersion: 1,
20
+ generation: 2,
21
+ tools: [{
22
+ name: 'browser_take_screenshot',
23
+ description: 'Capture a browser screenshot.',
24
+ policy: 'deferred',
25
+ selectedBy: [],
26
+ }],
27
+ }), { status: 200 }))))
28
+
29
+ render(<ToolSearchSettingsSection scope={scope} />)
30
+ expect(await screen.findByText('browser_take_screenshot')).toBeDefined()
31
+ expect(screen.getByLabelText('Live agents: 0').getAttribute('data-label')).toBe('Live agents')
32
+ fireEvent.change(screen.getByLabelText('Policy: browser_take_screenshot'), { target: { value: 'always' } })
33
+ await waitFor(() => {
34
+ expect(set).toHaveBeenCalledWith('alwaysVisible', ['browser_take_screenshot'])
35
+ expect(set).toHaveBeenCalledWith('neverSearch', [])
36
+ })
37
+ vi.unstubAllGlobals()
38
+ })
39
+ })