@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
@@ -0,0 +1,366 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { access, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
3
+ import { createServer } from 'node:http'
4
+ import { tmpdir } from 'node:os'
5
+ import { dirname, join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const root = dirname(dirname(fileURLToPath(import.meta.url)))
9
+ const dsh = process.env.DSH_BIN ?? 'dsh'
10
+ const keepTemp = process.argv.includes('--keep-temp')
11
+ const timeoutMs = Number(process.env.DSH_TOOL_SEARCH_E2E_TIMEOUT_MS ?? 180_000)
12
+
13
+ function run(command, args, options = {}) {
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn(command, args, {
16
+ cwd: options.cwd ?? root,
17
+ env: { ...process.env, ...options.env },
18
+ stdio: ['ignore', 'pipe', 'pipe'],
19
+ })
20
+ const stdout = []
21
+ const stderr = []
22
+ child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk)))
23
+ child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)))
24
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
25
+ child.once('error', error => {
26
+ clearTimeout(timer)
27
+ reject(error)
28
+ })
29
+ child.once('close', code => {
30
+ clearTimeout(timer)
31
+ resolve({
32
+ code: code ?? -1,
33
+ stdout: Buffer.concat(stdout).toString('utf8'),
34
+ stderr: Buffer.concat(stderr).toString('utf8'),
35
+ })
36
+ })
37
+ })
38
+ }
39
+
40
+ async function checked(command, args, options) {
41
+ const result = await run(command, args, options)
42
+ if (result.code !== 0) {
43
+ throw new Error(`${command} ${args.join(' ')} failed (${result.code})\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
44
+ }
45
+ return result
46
+ }
47
+
48
+ async function pack(directory, destination) {
49
+ const result = await checked('npm', ['pack', '--ignore-scripts', '--pack-destination', destination, '--json'], {
50
+ cwd: directory,
51
+ })
52
+ const filename = JSON.parse(result.stdout)[0]?.filename
53
+ if (typeof filename !== 'string') throw new Error(`npm pack returned no filename: ${result.stdout}`)
54
+ return join(destination, filename)
55
+ }
56
+
57
+ async function fixturePackage(directory) {
58
+ await mkdir(join(directory, 'lib'), { recursive: true })
59
+ await writeFile(join(directory, 'package.json'), `${JSON.stringify({
60
+ name: '@dsh-tool-search/e2e-fixture',
61
+ version: '1.0.0',
62
+ type: 'module',
63
+ main: './lib/index.js',
64
+ files: ['lib', 'cordis.patch.yml'],
65
+ dsh: { bundle: { patch: './cordis.patch.yml' } },
66
+ peerDependencies: {
67
+ '@deepseek-ai/dsh-tools': '^0.1.0-rc.8',
68
+ },
69
+ }, null, 2)}\n`)
70
+ await writeFile(join(directory, 'cordis.patch.yml'), [
71
+ '- insert:',
72
+ ' - id: tool-search-e2e-fixture',
73
+ " name: '@dsh-tool-search/e2e-fixture'",
74
+ '',
75
+ ].join('\n'))
76
+ await writeFile(join(directory, 'lib', 'index.js'), [
77
+ "import { defineTool } from '@deepseek-ai/dsh-tools'",
78
+ "export const name = '@dsh-tool-search/e2e-fixture'",
79
+ "export const inject = ['tools']",
80
+ 'export function apply(ctx) {',
81
+ ' ctx.tools.register(defineTool({',
82
+ " name: 'fixture_echo',",
83
+ " description: 'Echo a text value for deferred tool Agent acceptance.',",
84
+ " parameters: { value: { type: 'string', required: true, description: 'Text value to echo.' } },",
85
+ ' output: {',
86
+ " schema: { type: 'object', additionalProperties: false, properties: { echo: { type: 'string', required: true } } },",
87
+ " render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],",
88
+ ' },',
89
+ ' execute: args => Promise.resolve({ echo: args.value }),',
90
+ ' }))',
91
+ '}',
92
+ '',
93
+ ].join('\n'))
94
+ }
95
+
96
+ async function startLlm(script = [
97
+ { kind: 'tool', name: 'tool_search', arguments: '{"query":"fixture_echo","limit":1}' },
98
+ { kind: 'tool', name: 'fixture_echo', arguments: '{"value":"real-agent-ok"}' },
99
+ { kind: 'text', text: 'tool search e2e done' },
100
+ ]) {
101
+ const requests = []
102
+ let index = 0
103
+ const server = createServer((request, response) => {
104
+ if (request.method !== 'POST' || !request.url?.endsWith('/chat/completions')) {
105
+ response.writeHead(404, { 'content-type': 'application/json' })
106
+ response.end('{"error":"not-found"}')
107
+ return
108
+ }
109
+ const chunks = []
110
+ request.on('data', chunk => chunks.push(Buffer.from(chunk)))
111
+ request.on('end', () => {
112
+ let body
113
+ try {
114
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
115
+ } catch {
116
+ response.writeHead(400, { 'content-type': 'application/json' })
117
+ response.end('{"error":"invalid-json"}')
118
+ return
119
+ }
120
+ requests.push(body)
121
+ const step = script[index++]
122
+ if (step === undefined) {
123
+ response.writeHead(500, { 'content-type': 'application/json' })
124
+ response.end('{"error":{"message":"script-exhausted"}}')
125
+ return
126
+ }
127
+ response.writeHead(200, {
128
+ 'content-type': 'text/event-stream; charset=utf-8',
129
+ 'cache-control': 'no-cache',
130
+ connection: 'keep-alive',
131
+ })
132
+ const send = payload => response.write(`data: ${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n\n`)
133
+ if (step.kind === 'tool') {
134
+ send({
135
+ choices: [{
136
+ index: 0,
137
+ delta: {
138
+ tool_calls: [{
139
+ index: 0,
140
+ id: `tool-search-e2e-${index}`,
141
+ type: 'function',
142
+ function: { name: step.name, arguments: step.arguments },
143
+ }],
144
+ },
145
+ finish_reason: null,
146
+ }],
147
+ })
148
+ send({
149
+ choices: [{ index: 0, delta: { content: '' }, finish_reason: 'tool_calls' }],
150
+ usage: { prompt_tokens: 10, completion_tokens: 5 },
151
+ })
152
+ } else {
153
+ send({ choices: [{ index: 0, delta: { content: step.text }, finish_reason: null }] })
154
+ send({
155
+ choices: [{ index: 0, delta: { content: '' }, finish_reason: 'stop' }],
156
+ usage: { prompt_tokens: 10, completion_tokens: 5 },
157
+ })
158
+ }
159
+ send('[DONE]')
160
+ response.end()
161
+ })
162
+ })
163
+ await new Promise((resolve, reject) => {
164
+ server.once('error', reject)
165
+ server.listen(0, '127.0.0.1', resolve)
166
+ })
167
+ const address = server.address()
168
+ if (address === null || typeof address === 'string') throw new Error('mock LLM did not bind a TCP port')
169
+ return {
170
+ baseUrl: `http://127.0.0.1:${address.port}/v1`,
171
+ requests,
172
+ close: () => new Promise((resolve, reject) => {
173
+ server.close(error => error === undefined ? resolve() : reject(error))
174
+ server.closeAllConnections()
175
+ }),
176
+ }
177
+ }
178
+
179
+ function requestToolNames(body) {
180
+ return body?.tools?.map(tool => tool?.function?.name).filter(name => typeof name === 'string') ?? []
181
+ }
182
+
183
+ function toolResultTexts(body) {
184
+ return body?.messages
185
+ ?.filter(message => message?.role === 'tool')
186
+ .map(message => typeof message.content === 'string' ? message.content : JSON.stringify(message.content)) ?? []
187
+ }
188
+
189
+ function assert(condition, message) {
190
+ if (!condition) throw new Error(message)
191
+ }
192
+
193
+ async function exposedDshScript(command) {
194
+ if (process.env.DSH_BIN === undefined) {
195
+ return join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
196
+ }
197
+ const profileCandidate = join(dirname(dirname(command)), '@deepseek-ai', 'dsh', 'lib', 'bin.js')
198
+ try {
199
+ await access(profileCandidate)
200
+ return profileCandidate
201
+ } catch {
202
+ return await realpath(command)
203
+ }
204
+ }
205
+
206
+ const home = await mkdtemp(join(tmpdir(), 'dsh-tool-search-e2e-'))
207
+ const packageDirectory = join(home, 'packages')
208
+ const fixtureDirectory = join(home, 'fixture')
209
+ const workspaceDirectory = join(home, 'workspace')
210
+ await mkdir(packageDirectory)
211
+ await mkdir(workspaceDirectory)
212
+ const llm = await startLlm()
213
+
214
+ try {
215
+ const dshVersion = (await checked(dsh, ['--version'])).stdout.trim()
216
+ const profileModulePrefix = dshVersion.startsWith('0.1.1-')
217
+ ? './node_modules'
218
+ : './profiles/headless/node_modules'
219
+ const dshExecutable = dsh.includes('/')
220
+ ? dsh
221
+ : (await checked('/usr/bin/which', [dsh])).stdout.trim()
222
+ const dshScript = await exposedDshScript(dshExecutable)
223
+ const nodeExecutable = (await checked('/usr/bin/which', ['node'])).stdout.trim()
224
+ const runDshExposed = (args, options) => checked(
225
+ nodeExecutable,
226
+ ['--expose-internals', dshScript, ...args],
227
+ options,
228
+ )
229
+ let runDsh = (args, options) => checked(dsh, args, options)
230
+ if (dshVersion.startsWith('0.1.1-')) {
231
+ runDsh = runDshExposed
232
+ }
233
+ await fixturePackage(fixtureDirectory)
234
+ const pluginTarball = await pack(root, packageDirectory)
235
+ const fixtureTarball = await pack(fixtureDirectory, packageDirectory)
236
+ await runDsh(['plugin', '--profile', 'headless', 'add', pluginTarball], {
237
+ env: { DSH_HOME: home },
238
+ })
239
+ await runDsh(['plugin', '--profile', 'headless', 'add', fixtureTarball], {
240
+ env: { DSH_HOME: home },
241
+ })
242
+ const dump = await runDsh(['--profile', 'headless', '--dump-config'], {
243
+ env: { DSH_HOME: home },
244
+ })
245
+ assert(
246
+ dump.stdout.includes('@anionex/dsh-tool-search'),
247
+ `installed profile omits dsh-tool-search\nstdout:\n${dump.stdout}\nstderr:\n${dump.stderr}`,
248
+ )
249
+ assert(
250
+ dump.stdout.includes('@dsh-tool-search/e2e-fixture'),
251
+ `installed profile omits E2E fixture\nstdout:\n${dump.stdout}\nstderr:\n${dump.stderr}`,
252
+ )
253
+
254
+ let entryMode = dshVersion.startsWith('0.1.1-') ? 'bare-exposed-internals' : 'bare'
255
+ let bareFailure
256
+ const bareLlm = await startLlm([{ kind: 'text', text: 'bare package boot ok' }])
257
+ const bareArgs = [
258
+ '--profile', 'headless',
259
+ 'Verify standard package loading.',
260
+ ]
261
+ const bareOptions = {
262
+ cwd: workspaceDirectory,
263
+ env: {
264
+ DSH_HOME: home,
265
+ DSH_TELEMETRY_DISABLED: '1',
266
+ DSH_PERMISSION_MODE: 'danger-full-access',
267
+ DEEPSEEK_API_KEY: 'tool-search-e2e-key',
268
+ DEEPSEEK_BASE_URL: bareLlm.baseUrl,
269
+ },
270
+ }
271
+ try {
272
+ const bareResult = await runDsh(bareArgs, bareOptions)
273
+ assert(bareResult.stdout.trim() === 'bare package boot ok', `unexpected bare-load smoke output: ${bareResult.stdout}`)
274
+ } catch (error) {
275
+ if (!String(error).includes('Cannot find package')) throw error
276
+ bareFailure = /Cannot find package '[^']+'/u.exec(String(error))?.[0]
277
+ if (bareLlm.requests.length === 0 && runDsh !== runDshExposed) {
278
+ try {
279
+ const exposedResult = await runDshExposed(bareArgs, bareOptions)
280
+ assert(exposedResult.stdout.trim() === 'bare package boot ok', `unexpected exposed-internals smoke output: ${exposedResult.stdout}`)
281
+ runDsh = runDshExposed
282
+ entryMode = 'bare-exposed-internals'
283
+ } catch {
284
+ entryMode = 'relative-compat'
285
+ }
286
+ } else {
287
+ entryMode = 'relative-compat'
288
+ }
289
+ if (entryMode !== 'bare' && process.env.DSH_TOOL_SEARCH_REQUIRE_BARE === '1') {
290
+ throw new Error(`standard installed package loading is required\n${String(error)}`)
291
+ }
292
+ if (entryMode === 'relative-compat' && process.env.DSH_TOOL_SEARCH_ALLOW_RELATIVE_COMPAT !== '1') {
293
+ throw new Error(`installed package loading failed even with Node internals exposed\n${String(error)}`)
294
+ }
295
+ } finally {
296
+ await bareLlm.close()
297
+ }
298
+
299
+ const patch = join(home, 'e2e.patch.yml')
300
+ const patchLines = [
301
+ '# Disable unrelated title generation for deterministic model-call accounting.',
302
+ '- id: session-title-llm',
303
+ ' disabled: true',
304
+ ]
305
+ if (entryMode === 'relative-compat') {
306
+ patchLines.unshift(
307
+ '# CLI-only relative paths work around bare-specifier resolution in standalone dsh launchers.',
308
+ '- id: dsh-tool-search',
309
+ " name: '@anionex/dsh-tool-search'",
310
+ ' disabled: true',
311
+ '- id: tool-search-e2e-fixture',
312
+ " name: '@dsh-tool-search/e2e-fixture'",
313
+ ' disabled: true',
314
+ '- insert:',
315
+ ' - id: dsh-tool-search-e2e-relative',
316
+ ` name: ${profileModulePrefix}/@anionex/dsh-tool-search/lib/index.js`,
317
+ ' - id: tool-search-e2e-fixture-relative',
318
+ ` name: ${profileModulePrefix}/@dsh-tool-search/e2e-fixture/lib/index.js`,
319
+ )
320
+ }
321
+ patchLines.push('')
322
+ await writeFile(patch, patchLines.join('\n'))
323
+ const runResult = await runDsh([
324
+ '--profile', 'headless',
325
+ '--patch', patch,
326
+ 'Find and call the fixture echo tool.',
327
+ ], {
328
+ cwd: workspaceDirectory,
329
+ env: {
330
+ DSH_HOME: home,
331
+ DSH_TELEMETRY_DISABLED: '1',
332
+ DSH_PERMISSION_MODE: 'danger-full-access',
333
+ DEEPSEEK_API_KEY: 'tool-search-e2e-key',
334
+ DEEPSEEK_BASE_URL: llm.baseUrl,
335
+ },
336
+ })
337
+
338
+ assert(runResult.stdout.trim() === 'tool search e2e done', `unexpected Agent output: ${runResult.stdout}`)
339
+ assert(llm.requests.length === 3, `expected 3 model calls, received ${llm.requests.length}`)
340
+ const initialNames = requestToolNames(llm.requests[0])
341
+ const searchedNames = requestToolNames(llm.requests[1])
342
+ assert(initialNames.includes('tool_search'), 'initial model call omits tool_search')
343
+ assert(!initialNames.includes('fixture_echo'), 'initial model call exposes deferred fixture_echo')
344
+ assert(searchedNames.includes('fixture_echo'), 'next model call omits selected fixture_echo')
345
+ const searchResult = toolResultTexts(llm.requests[1]).at(-1) ?? ''
346
+ assert(searchResult.includes('fixture_echo'), 'tool_search result omits fixture_echo summary')
347
+ assert(!searchResult.includes('"parameters"'), 'tool_search result duplicates a full schema')
348
+ const echoResult = toolResultTexts(llm.requests[2]).at(-1) ?? ''
349
+ assert(echoResult.includes('real-agent-ok'), 'fixture_echo did not execute through the real Agent loop')
350
+
351
+ process.stdout.write(`${JSON.stringify({
352
+ ok: true,
353
+ dsh: dshVersion,
354
+ entryMode,
355
+ bareFailure,
356
+ modelCalls: llm.requests.length,
357
+ initialToolCount: initialNames.length,
358
+ selectedToolCount: searchedNames.length,
359
+ selectedOnNextCall: searchedNames.includes('fixture_echo'),
360
+ package: '@anionex/dsh-tool-search@0.1.0',
361
+ temporaryHome: keepTemp ? home : undefined,
362
+ }, null, 2)}\n`)
363
+ } finally {
364
+ await llm.close()
365
+ if (!keepTemp) await rm(home, { recursive: true, force: true })
366
+ }
@@ -0,0 +1,170 @@
1
+ import { readFile, stat } from 'node:fs/promises'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { createHash } from 'node:crypto'
4
+ import { createRequire } from 'node:module'
5
+ import { dirname, join } from 'node:path'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+ import { runInNewContext } from 'node:vm'
8
+
9
+ const root = dirname(dirname(fileURLToPath(import.meta.url)))
10
+ const require = createRequire(import.meta.url)
11
+ const errors = []
12
+
13
+ function check(condition, message) {
14
+ if (!condition) errors.push(message)
15
+ }
16
+
17
+ async function exists(path) {
18
+ try {
19
+ return (await stat(join(root, path))).isFile()
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
26
+ check(pkg.name === '@anionex/dsh-tool-search', 'package name is incorrect')
27
+ check(pkg.version === '0.1.0', 'release version is incorrect')
28
+ check(pkg.main === './lib/index.js', 'main entrypoint is incorrect')
29
+ check(pkg.types === './lib/types/index.d.ts', 'types entrypoint is incorrect')
30
+ check(pkg.exports?.['./client']?.default === './lib/client.js', 'client export is incorrect')
31
+ check(pkg.dsh?.bundle?.patch === './cordis.patch.yml', 'bundle patch is not declared')
32
+ check(pkg.dsh?.client?.platform === 'web', 'Web client is not declared')
33
+ for (const edge of [
34
+ '@deepseek-ai/dsh-client-locale',
35
+ '@deepseek-ai/dsh-client-ui-renderer',
36
+ '@deepseek-ai/dsh-client-ui-settings',
37
+ ]) {
38
+ check(pkg.dsh?.client?.inject?.includes(edge), `Web client injection edge is missing: ${edge}`)
39
+ }
40
+ check(pkg.dependencies?.['wink-porter2-stemmer'] === '^2.0.1', 'runtime stemmer dependency is missing')
41
+ check(pkg.peerDependenciesMeta?.['@deepseek-ai/dsh-host-webserver']?.optional === true, 'Web server peer must remain optional')
42
+
43
+ for (const group of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
44
+ for (const [name, spec] of Object.entries(pkg[group] ?? {})) {
45
+ check(typeof spec === 'string', `${group}.${name} must be a string`)
46
+ if (typeof spec !== 'string') continue
47
+ check(!/^(?:file:|link:|workspace:|\/|[A-Za-z]:[\\/])/u.test(spec), `${group}.${name} is machine-local: ${spec}`)
48
+ }
49
+ }
50
+
51
+ for (const path of [
52
+ 'LICENSE',
53
+ 'README.md',
54
+ 'README.zh.md',
55
+ 'cordis.patch.yml',
56
+ 'pnpm-lock.yaml',
57
+ 'lib/index.js',
58
+ 'lib/types/index.d.ts',
59
+ 'lib/client.js',
60
+ 'lib/types/client/index.d.ts',
61
+ 'docs/benchmark-results.json',
62
+ 'scripts/benchmark.mjs',
63
+ 'scripts/profile-e2e.mjs',
64
+ 'scripts/validate.mjs',
65
+ 'tests/runtime.spec.ts',
66
+ 'pnpm-lock.yaml',
67
+ 'pnpm-workspace.yaml',
68
+ 'tsconfig.json',
69
+ 'tsconfig.client.json',
70
+ 'tsconfig.test.json',
71
+ 'tsdown.config.mjs',
72
+ ]) check(await exists(path), `required package file is missing: ${path}`)
73
+
74
+ if (await exists('docs/benchmark-results.json')) {
75
+ try {
76
+ const benchmark = JSON.parse(await readFile(join(root, 'docs/benchmark-results.json'), 'utf8'))
77
+ const generatorPath = join(root, benchmark.fixture?.generator ?? '')
78
+ const generatorHash = createHash('sha256').update(await readFile(generatorPath)).digest('hex')
79
+ check(benchmark.schemaVersion === 2, 'benchmark schema version is incorrect')
80
+ check(benchmark.packageVersion === pkg.version, 'benchmark package version is stale')
81
+ check(benchmark.fixture?.generatorSha256 === generatorHash, 'benchmark generator hash is stale')
82
+ check(JSON.stringify(benchmark.fixture?.sizes) === JSON.stringify([10, 30, 50, 100]), 'benchmark fixture sizes are incorrect')
83
+ const rows = benchmark.rows.map(row => (
84
+ `| ${row.tools} | ${row.fullTokens.toLocaleString('en-US')} | ${row.deferredInitialTokens.toLocaleString('en-US')} | ${row.initialReductionPercent}% | ${row.afterTop5Tokens.toLocaleString('en-US')} |`
85
+ ))
86
+ for (const readme of ['README.md', 'README.zh.md']) {
87
+ const text = await readFile(join(root, readme), 'utf8')
88
+ for (const row of rows) check(text.includes(row), `${readme} benchmark table is stale: ${row}`)
89
+ }
90
+ } catch (error) {
91
+ errors.push(`benchmark artifact validation failed: ${String(error)}`)
92
+ }
93
+ }
94
+
95
+ if (await exists('lib/client.js')) {
96
+ const client = await readFile(join(root, 'lib/client.js'), 'utf8')
97
+ check(client.startsWith('window.__ModuleLoader__.load('), 'client artifact is not wrapped for the DSH module loader')
98
+ check(client.includes('@anionex/dsh-tool-search'), 'client artifact is missing its plugin id')
99
+ let descriptor
100
+ try {
101
+ runInNewContext(client, {
102
+ window: {
103
+ __ModuleLoader__: {
104
+ load(value) { descriptor = value },
105
+ },
106
+ },
107
+ })
108
+ check(descriptor?.id === '@anionex/dsh-tool-search', 'client loader descriptor id is incorrect')
109
+ check(typeof descriptor?.factory === 'function', 'client loader descriptor has no factory')
110
+ const exports = descriptor?.factory(name => require(name))
111
+ check(typeof exports?.apply === 'function', 'client loader factory has no apply() export')
112
+ } catch (error) {
113
+ errors.push(`client loader execution failed: ${String(error)}`)
114
+ }
115
+ }
116
+
117
+ if (await exists('cordis.patch.yml')) {
118
+ const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8')
119
+ check(patch.includes('id: dsh-tool-search'), 'bundle patch does not declare dsh-tool-search')
120
+ check(patch.includes("name: '@anionex/dsh-tool-search'"), 'bundle patch package name is incorrect')
121
+ }
122
+
123
+ if (await exists('lib/index.js')) {
124
+ const runtime = await import(`${pathToFileURL(join(root, 'lib/index.js')).href}?validate=${Date.now()}`)
125
+ check(runtime.name === '@anionex/dsh-tool-search', 'built Host plugin id is incorrect')
126
+ check(typeof runtime.apply === 'function', 'built Host plugin has no apply()')
127
+ check(typeof runtime.Bm25Index === 'function', 'built Host plugin has no BM25 export')
128
+ }
129
+
130
+ const packed = spawnSync('npm', ['pack', '--ignore-scripts', '--dry-run', '--json'], {
131
+ cwd: root,
132
+ encoding: 'utf8',
133
+ timeout: 120_000,
134
+ })
135
+ check(packed.status === 0, `npm pack --dry-run failed: ${packed.stderr}`)
136
+ if (packed.status === 0) {
137
+ let rows
138
+ try {
139
+ rows = JSON.parse(packed.stdout)
140
+ } catch {
141
+ errors.push('npm pack --dry-run did not return JSON')
142
+ }
143
+ const files = new Set(rows?.[0]?.files?.map(file => file.path) ?? [])
144
+ for (const path of [
145
+ 'lib/index.js',
146
+ 'lib/client.js',
147
+ 'lib/types/index.d.ts',
148
+ 'src/runtime.ts',
149
+ 'scripts/benchmark.mjs',
150
+ 'tests/runtime.spec.ts',
151
+ 'tsconfig.json',
152
+ 'docs/benchmark-results.json',
153
+ 'cordis.patch.yml',
154
+ ]) {
155
+ check(files.has(path), `packed tarball is missing ${path}`)
156
+ }
157
+ check(!files.has('node_modules'), 'packed tarball includes node_modules')
158
+ check(![...files].some(path => path.endsWith('.env')), 'packed tarball includes an .env file')
159
+ }
160
+
161
+ if (errors.length > 0) {
162
+ process.stderr.write(`${errors.map(error => `- ${error}`).join('\n')}\n`)
163
+ process.exitCode = 1
164
+ } else {
165
+ process.stdout.write(JSON.stringify({
166
+ ok: true,
167
+ package: `${pkg.name}@${pkg.version}`,
168
+ packedFiles: packed.status === 0 ? JSON.parse(packed.stdout)[0].files.length : 0,
169
+ }, null, 2) + '\n')
170
+ }
package/src/bm25.ts ADDED
@@ -0,0 +1,104 @@
1
+ /** Deterministic local BM25, aligned with Codex's bm25 2.3.2 defaults. */
2
+
3
+ import stem from 'wink-porter2-stemmer'
4
+
5
+ const K1 = 1.2
6
+ const B = 0.75
7
+
8
+ // stop-words 0.9.0 uses the NLTK English list before stemming.
9
+ const ENGLISH_STOP_WORDS = new Set([
10
+ 'a', 'about', 'above', 'after', 'again', 'against', 'ain', 'all', 'am', 'an', 'and', 'any', 'are',
11
+ 'aren', "aren't", 'as', 'at', 'be', 'because', 'been', 'before', 'being', 'below', 'between', 'both',
12
+ 'but', 'by', 'can', 'couldn', "couldn't", 'd', 'did', 'didn', "didn't", 'do', 'does', 'doesn',
13
+ "doesn't", 'doing', 'don', "don't", 'down', 'during', 'each', 'few', 'for', 'from', 'further', 'had',
14
+ 'hadn', "hadn't", 'has', 'hasn', "hasn't", 'have', 'haven', "haven't", 'having', 'he', 'her', 'here',
15
+ 'hers', 'herself', 'him', 'himself', 'his', 'how', 'i', 'if', 'in', 'into', 'is', 'isn', "isn't",
16
+ 'it', "it's", 'its', 'itself', 'just', 'll', 'm', 'ma', 'me', 'mightn', "mightn't", 'more', 'most',
17
+ 'mustn', "mustn't", 'my', 'myself', 'needn', "needn't", 'no', 'nor', 'not', 'now', 'o', 'of', 'off',
18
+ 'on', 'once', 'only', 'or', 'other', 'our', 'ours', 'ourselves', 'out', 'over', 'own', 're', 's',
19
+ 'same', 'shan', "shan't", 'she', "she's", 'should', "should've", 'shouldn', "shouldn't", 'so', 'some',
20
+ 'such', 't', 'than', 'that', "that'll", 'the', 'their', 'theirs', 'them', 'themselves', 'then',
21
+ 'there', 'these', 'they', 'this', 'those', 'through', 'to', 'too', 'under', 'until', 'up', 've',
22
+ 'very', 'was', 'wasn', "wasn't", 'we', 'were', 'weren', "weren't", 'what', 'when', 'where', 'which',
23
+ 'while', 'who', 'whom', 'why', 'will', 'with', 'won', "won't", 'wouldn', "wouldn't", 'y', 'you',
24
+ "you'd", "you'll", "you're", "you've", 'your', 'yours', 'yourself', 'yourselves',
25
+ ])
26
+
27
+ function asciiFold(value: string): string {
28
+ return value.normalize('NFKD').replace(/\p{M}+/gu, '')
29
+ }
30
+
31
+ export function tokenize(value: string): string[] {
32
+ const matches = asciiFold(value).toLowerCase().match(/[\p{L}\p{N}]+(?:['.][\p{L}\p{N}]+)*/gu) ?? []
33
+ return matches.filter(token => !ENGLISH_STOP_WORDS.has(token)).map(token => stem(token))
34
+ }
35
+
36
+ export interface Bm25Document<T> {
37
+ id: T
38
+ name: string
39
+ text: string
40
+ }
41
+
42
+ export interface Bm25Match<T> {
43
+ id: T
44
+ name: string
45
+ score: number
46
+ }
47
+
48
+ interface IndexedDocument<T> extends Bm25Document<T> {
49
+ tokens: string[]
50
+ counts: Map<string, number>
51
+ order: number
52
+ }
53
+
54
+ export class Bm25Index<T> {
55
+ private readonly documents: IndexedDocument<T>[]
56
+ private readonly frequencies = new Map<string, number>()
57
+ private readonly averageLength: number
58
+
59
+ constructor(documents: readonly Bm25Document<T>[]) {
60
+ this.documents = documents.map((document, order) => {
61
+ const tokens = tokenize(document.text)
62
+ const counts = new Map<string, number>()
63
+ for (const token of tokens) counts.set(token, (counts.get(token) ?? 0) + 1)
64
+ for (const token of counts.keys()) this.frequencies.set(token, (this.frequencies.get(token) ?? 0) + 1)
65
+ return { ...document, tokens, counts, order }
66
+ })
67
+ this.averageLength = this.documents.length === 0
68
+ ? 256
69
+ : this.documents.reduce((total, document) => total + document.tokens.length, 0) / this.documents.length
70
+ }
71
+
72
+ search(query: string, limit: number): Bm25Match<T>[] {
73
+ if (limit <= 0 || this.documents.length === 0) return []
74
+ const queryTokens = tokenize(query)
75
+ const normalizedQuery = query.trim()
76
+ const matches: Array<Bm25Match<T> & { exact: boolean; order: number }> = []
77
+
78
+ for (const document of this.documents) {
79
+ let score = 0
80
+ for (const token of queryTokens) {
81
+ const termFrequency = document.counts.get(token) ?? 0
82
+ if (termFrequency === 0) continue
83
+ const documentFrequency = this.frequencies.get(token) ?? 0
84
+ const inverseDocumentFrequency = Math.log1p(
85
+ (this.documents.length - documentFrequency + 0.5) / (documentFrequency + 0.5),
86
+ )
87
+ const lengthRatio = document.tokens.length / this.averageLength
88
+ const weight = (termFrequency * (K1 + 1))
89
+ / (termFrequency + K1 * (1 - B + B * lengthRatio))
90
+ score += inverseDocumentFrequency * weight
91
+ }
92
+ const exact = document.name === normalizedQuery
93
+ if (score > 0 || exact) matches.push({ id: document.id, name: document.name, score, exact, order: document.order })
94
+ }
95
+
96
+ matches.sort((left, right) => {
97
+ if (left.exact !== right.exact) return left.exact ? -1 : 1
98
+ if (left.score !== right.score) return right.score - left.score
99
+ if (left.name !== right.name) return left.name < right.name ? -1 : 1
100
+ return left.order - right.order
101
+ })
102
+ return matches.slice(0, limit).map(({ id, name, score }) => ({ id, name, score }))
103
+ }
104
+ }