@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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/README.zh.md +160 -0
- package/cordis.patch.yml +3 -0
- package/docs/benchmark-results.json +51 -0
- package/lib/bm25.js +83 -0
- package/lib/bm25.js.map +1 -0
- package/lib/catalog.js +53 -0
- package/lib/catalog.js.map +1 -0
- package/lib/client.js +388 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +24 -0
- package/lib/index.js.map +1 -0
- package/lib/runtime.js +286 -0
- package/lib/runtime.js.map +1 -0
- package/lib/settings.js +50 -0
- package/lib/settings.js.map +1 -0
- package/lib/shared.js +40 -0
- package/lib/shared.js.map +1 -0
- package/lib/types/bm25.d.ts +20 -0
- package/lib/types/bm25.d.ts.map +1 -0
- package/lib/types/catalog.d.ts +26 -0
- package/lib/types/catalog.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +65 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +37 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/settings.d.ts +16 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/shared.d.ts +31 -0
- package/lib/types/shared.d.ts.map +1 -0
- package/lib/types/web.d.ts +5 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/web.js +28 -0
- package/lib/web.js.map +1 -0
- package/package.json +149 -0
- package/pnpm-workspace.yaml +11 -0
- package/scripts/benchmark.mjs +124 -0
- package/scripts/profile-e2e.mjs +366 -0
- package/scripts/validate.mjs +170 -0
- package/src/bm25.ts +104 -0
- package/src/catalog.ts +72 -0
- package/src/client/index.tsx +344 -0
- package/src/index.ts +32 -0
- package/src/runtime.ts +326 -0
- package/src/settings.ts +63 -0
- package/src/shared.ts +63 -0
- package/src/web.ts +34 -0
- package/src/wink-porter2-stemmer.d.ts +3 -0
- package/tests/bm25.spec.ts +75 -0
- package/tests/catalog.spec.ts +63 -0
- package/tests/client.spec.tsx +39 -0
- package/tests/execution-regression.spec.ts +200 -0
- package/tests/runtime.spec.ts +271 -0
- package/tests/settings.spec.ts +33 -0
- package/tsconfig.client.json +31 -0
- package/tsconfig.json +30 -0
- package/tsconfig.test.json +23 -0
- 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
|
+
}
|